瀏覽代碼

feat: file manager folders

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

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

@@ -220,7 +220,6 @@ exports.up = async knex => {
     .createTable('pages', table => {
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
       table.string('path').notNullable()
-      table.specificType('dotPath', 'ltree').notNullable().index()
       table.string('hash').notNullable()
       table.string('alias')
       table.string('title').notNullable()
@@ -285,6 +284,18 @@ exports.up = async knex => {
       table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
       table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())
     })
+    // TREE --------------------------------
+    .createTable('tree', table => {
+      table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
+      table.specificType('folderPath', 'ltree').index().index('tree_folderpath_gist_index', { indexType: 'GIST' })
+      table.string('fileName').notNullable().index()
+      table.enu('type', ['folder', 'page', 'asset']).notNullable().index()
+      table.uuid('targetId').index()
+      table.string('title').notNullable()
+      table.jsonb('meta').notNullable().defaultTo('{}')
+      table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
+      table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())
+    })
     // USER AVATARS ------------------------
     .createTable('userAvatars', table => {
       table.uuid('id').notNullable().primary()
@@ -379,6 +390,9 @@ exports.up = async knex => {
       table.uuid('siteId').notNullable().references('id').inTable('sites')
       table.unique(['siteId', 'tag'])
     })
+    .table('tree', table => {
+      table.uuid('siteId').notNullable().references('id').inTable('sites')
+    })
     .table('userKeys', table => {
       table.uuid('userId').notNullable().references('id').inTable('users')
     })

+ 13 - 0
server/graph/resolvers/asset.js

@@ -2,6 +2,7 @@ const _ = require('lodash')
 const sanitize = require('sanitize-filename')
 const graphHelper = require('../../helpers/graph')
 const assetHelper = require('../../helpers/asset')
+const { setTimeout } = require('node:timers/promises')
 
 module.exports = {
   Query: {
@@ -181,6 +182,18 @@ module.exports = {
         return graphHelper.generateError(err)
       }
     },
+    /**
+     * Upload Assets
+     */
+    async uploadAssets(obj, args, context) {
+      try {
+        return {
+          operation: graphHelper.generateSuccess('Asset(s) uploaded successfully.')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    },
     /**
      * Flush Temporary Uploads
      */

+ 148 - 0
server/graph/resolvers/tree.js

@@ -0,0 +1,148 @@
+const _ = require('lodash')
+const graphHelper = require('../../helpers/graph')
+
+const typeResolvers = {
+  folder: 'TreeItemFolder',
+  page: 'TreeItemPage',
+  asset: 'TreeItemAsset'
+}
+
+const rePathName = /^[a-z0-9_]+$/
+const reTitle = /^[^<>"]+$/
+
+module.exports = {
+  Query: {
+    async tree (obj, args, context, info) {
+      // Offset
+      const offset = args.offset || 0
+      if (offset < 0) {
+        throw new Error('Invalid Offset')
+      }
+
+      // Limit
+      const limit = args.limit || 100
+      if (limit < 1 || limit > 100) {
+        throw new Error('Invalid Limit')
+      }
+
+      // Order By
+      const orderByDirection = args.orderByDirection || 'asc'
+      const orderBy = args.orderBy || 'title'
+
+      // Parse depth
+      const depth = args.depth || 0
+      if (depth < 0 || depth > 10) {
+        throw new Error('Invalid Depth')
+      }
+      const depthCondition = depth > 0 ? `*{,${depth}}` : '*{0}'
+
+      // Get parent path
+      let parentPath = ''
+      if (args.parentId) {
+        const parent = await WIKI.db.knex('tree').where('id', args.parentId).first()
+        if (parent) {
+          parentPath = parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName
+        }
+      } else if (args.parentPath) {
+        parentPath = args.parentPath.replaceAll('/', '.').replaceAll('-', '_').toLowerCase()
+      }
+      const folderPathCondition = parentPath ? `${parentPath}.${depthCondition}` : depthCondition
+
+      // Fetch Items
+      const items = await WIKI.db.knex('tree')
+        .select(WIKI.db.knex.raw('tree.*, nlevel(tree."folderPath") AS depth'))
+        .where(builder => {
+          builder.where('folderPath', '~', folderPathCondition)
+          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)
+              })
+            }
+          }
+        })
+        .andWhere(builder => {
+          if (args.types && args.types.length > 0) {
+            builder.whereIn('type', args.types)
+          }
+        })
+        .limit(limit)
+        .offset(offset)
+        .orderBy([
+          { column: 'depth' },
+          { column: orderBy, order: orderByDirection }
+        ])
+
+      return items.map(item => ({
+        id: item.id,
+        depth: item.depth,
+        type: item.type,
+        folderPath: item.folderPath.replaceAll('.', '/').replaceAll('_', '-'),
+        fileName: item.fileName,
+        title: item.title,
+        createdAt: item.createdAt,
+        updatedAt: item.updatedAt,
+        ...(item.type === 'folder') && {
+          childrenCount: 0
+        }
+      }))
+    }
+  },
+  Mutation: {
+    async createFolder (obj, args, context) {
+      try {
+        // 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
+          }
+        }
+
+        // Validate path name
+        const pathName = args.pathName.replaceAll('-', '_')
+        if (!rePathName.test(pathName)) {
+          throw new Error('ERR_INVALID_PATH_NAME')
+        }
+
+        // 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: pathName
+        }).first()
+        if (existingFolder) {
+          throw new Error('ERR_FOLDER_ALREADY_EXISTS')
+        }
+
+        // Create folder
+        await WIKI.db.knex('tree').insert({
+          folderPath: parentPath,
+          fileName: pathName,
+          type: 'folder',
+          title: args.title,
+          siteId: args.siteId
+        })
+        return {
+          operation: graphHelper.generateSuccess('Folder created successfully')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    }
+  },
+  TreeItem: {
+    __resolveType (obj, context, info) {
+      return typeResolvers[obj.type] ?? null
+    }
+  }
+}

+ 5 - 0
server/graph/schemas/asset.graphql

@@ -29,6 +29,11 @@ extend type Mutation {
     id: Int!
   ): DefaultResponse
 
+  uploadAssets(
+    siteId: UUID!
+    files: [Upload!]!
+  ): DefaultResponse
+
   flushTempUploads: DefaultResponse
 }
 

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

@@ -0,0 +1,98 @@
+# ===============================================
+# TREE
+# ===============================================
+
+extend type Query {
+  tree(
+    siteId: UUID!
+    parentId: UUID
+    parentPath: String
+    types: [TreeItemType]
+    limit: Int
+    offset: Int
+    orderBy: TreeOrderBy
+    orderByDirection: OrderByDirection
+    depth: Int
+    includeAncestors: Boolean
+    ): [TreeItem]
+}
+
+extend type Mutation {
+  createFolder(
+    siteId: UUID!
+    parentId: UUID
+    pathName: String!
+    title: String!
+  ): DefaultResponse
+  deleteFolder(
+    folderId: UUID!
+  ): DefaultResponse
+  duplicateFolder(
+    folderId: UUID!
+    targetParentId: UUID
+    targetPathName: String!
+    targetTitle: String!
+  ): DefaultResponse
+  moveFolder(
+    folderId: UUID!
+    targetParentId: UUID
+  ): DefaultResponse
+  renameFolder(
+    folderId: UUID!
+    pathName: String
+    title: String
+  ): DefaultResponse
+}
+
+# -----------------------------------------------
+# TYPES
+# -----------------------------------------------
+
+enum TreeItemType {
+  asset
+  folder
+  page
+}
+
+enum TreeOrderBy {
+  createdAt
+  fileName
+  title
+  updatedAt
+}
+
+type TreeItemFolder {
+  id: UUID
+  childrenCount: Int
+  depth: Int
+  fileName: String
+  folderPath: String
+  title: String
+}
+
+type TreeItemPage {
+  id: UUID
+  createdAt: Date
+  depth: Int
+  fileName: String
+  folderPath: String
+  pageEditor: String
+  pageType: String
+  title: String
+  updatedAt: Date
+}
+
+type TreeItemAsset {
+  id: UUID
+  createdAt: Date
+  depth: Int
+  fileName: String
+  # In Bytes
+  fileSize: Int
+  fileType: String
+  folderPath: String
+  title: String
+  updatedAt: Date
+}
+
+union TreeItem = TreeItemFolder | TreeItemPage | TreeItemAsset

+ 2 - 2
server/models/pages.js

@@ -249,7 +249,7 @@ module.exports = class Page extends Model {
     }
 
     opts.path = opts.path.toLowerCase()
-    const dotPath = opts.path.replaceAll('/', '.').replaceAll('-', '_')
+    // const dotPath = opts.path.replaceAll('/', '.').replaceAll('-', '_')
 
     // -> Check for page access
     if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
@@ -310,7 +310,7 @@ module.exports = class Page extends Model {
       },
       contentType: WIKI.data.editors[opts.editor]?.contentType ?? 'text',
       description: opts.description,
-      dotPath: dotPath,
+      // dotPath: dotPath,
       editor: opts.editor,
       hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
       icon: opts.icon,

+ 1 - 0
ux/package.json

@@ -72,6 +72,7 @@
     "pinia": "2.0.23",
     "pug": "3.0.2",
     "quasar": "2.10.1",
+    "slugify": "1.6.5",
     "socket.io-client": "4.5.3",
     "tippy.js": "6.3.7",
     "uuid": "9.0.0",

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M1.5,35.5v-31h10.293l3,3H38.5V34c0,0.827-0.673,1.5-1.5,1.5H1.5z"/><path fill="#4788c7" d="M11.586,5l2.707,2.707L14.586,8H15h23v26c0,0.551-0.449,1-1,1H2V5H11.586 M12,4H1v32h36 c1.105,0,2-0.895,2-2V7H15L12,4L12,4z"/><path fill="#dff0fe" d="M1.5,35.5v-26h10.651l3-2H38.5V34c0,0.827-0.673,1.5-1.5,1.5H1.5z"/><path fill="#4788c7" d="M38,8v26c0,0.551-0.449,1-1,1H2V10h10h0.303l0.252-0.168L15.303,8H38 M39,7H15l-3,2H1v27h36 c1.105,0,2-0.895,2-2V7L39,7z"/><path fill="#98ccfd" d="M31 22.5A8.5 8.5 0 1 0 31 39.5A8.5 8.5 0 1 0 31 22.5Z"/><path fill="#4788c7" d="M31,23c4.411,0,8,3.589,8,8s-3.589,8-8,8s-8-3.589-8-8S26.589,23,31,23 M31,22 c-4.971,0-9,4.029-9,9s4.029,9,9,9s9-4.029,9-9S35.971,22,31,22L31,22z"/><path fill="none" stroke="#fff" stroke-miterlimit="10" stroke-width="2" d="M31 36L31 26M26 31L36 31"/></svg>

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M1.5 35.5L1.5 4.5 11.793 4.5 14.793 7.5 38.5 7.5 38.5 35.5z"/><path fill="#4788c7" d="M11.586,5l2.707,2.707L14.586,8H15h23v27H2V5H11.586 M12,4H1v32h38V7H15L12,4L12,4z"/><g><path fill="#dff0fe" d="M12.5 35.5L12.5 15.5 20.793 15.5 23.793 18.5 38.5 18.5 38.5 35.5z"/><path fill="#4788c7" d="M20.586,16l2.707,2.707L23.586,19H24h14v16H13V16H20.586 M21,15h-9v21h27V18H24L21,15L21,15z"/></g></svg>

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M1.5 35.5L1.5 4.5 11.793 4.5 14.793 7.5 38.5 7.5 38.5 35.5z"/><path fill="#4788c7" d="M11.586,5l2.707,2.707L14.586,8H15h23v27H2V5H11.586 M12,4H1v32h38V7H15L12,4L12,4z"/><g><path fill="#dff0fe" d="M1.5 35.5L1.5 9.5 12.151 9.5 15.151 7.5 38.5 7.5 38.5 35.5z"/><path fill="#4788c7" d="M38,8v27H2V10h10h0.303l0.252-0.168L15.303,8H38 M39,7H15l-3,2H1v27h38V7L39,7z"/></g></svg>

+ 643 - 104
ux/src/components/FileManager.vue

@@ -1,5 +1,5 @@
 <template lang="pug">
-q-layout(view='hHh lpR lFr', container)
+q-layout.fileman(view='hHh lpR lFr', container)
   q-header.card-header
     q-toolbar(dark)
       q-icon(name='img:/_assets/icons/fluent-folder.svg', left, size='md')
@@ -28,113 +28,214 @@ q-layout(view='hHh lpR lFr', container)
       q-btn(
         flat
         dense
-        color='white'
-        :label='t(`common.actions.close`)'
+        no-caps
+        color='red-3'
         :aria-label='t(`common.actions.close`)'
         icon='las la-times'
         @click='close'
         )
         q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.close`)}}
-  q-drawer.bg-blue-grey-1(:model-value='true', :width='350')
+  q-drawer.fileman-left(:model-value='true', :width='350')
     .q-px-md.q-pb-sm
       tree(
         :nodes='state.treeNodes'
         :roots='state.treeRoots'
         v-model:selected='state.currentFolderId'
+        @lazy-load='treeLazyLoad'
+        :use-lazy-load='true'
+        @context-action='treeContextAction'
       )
-  q-drawer.bg-grey-1(:model-value='true', :width='350', side='right')
+  q-drawer.fileman-right(:model-value='true', :width='350', side='right')
     .q-pa-md
-      q-img.rounded-borders(
-        src='https://picsum.photos/id/134/340/340'
-        width='100%'
-        fit='cover'
-        :ratio='16/10'
-      )
-  q-page-container
-    q-page.bg-white
-      q-toolbar.bg-grey-1
-        q-space
-        q-btn.q-mr-sm(
-          flat
-          dense
-          no-caps
-          color='grey'
-          :aria-label='t(`common.actions.refresh`)'
-          icon='las la-redo-alt'
-          @click=''
-          )
-          q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.refresh`)}}
-        q-separator.q-mr-sm(inset, vertical)
-        q-btn.q-mr-sm(
-          flat
-          dense
-          no-caps
-          color='blue'
-          :label='t(`common.actions.new`)'
-          :aria-label='t(`common.actions.new`)'
-          icon='las la-plus-circle'
-          @click=''
-          )
-          new-menu(hide-asset-btn)
-        q-btn(
-          flat
-          dense
-          no-caps
-          color='positive'
-          :label='t(`common.actions.upload`)'
-          :aria-label='t(`common.actions.upload`)'
-          icon='las la-cloud-upload-alt'
-          @click=''
+      template(v-if='currentFileDetails')
+        q-img.rounded-borders.q-mb-md(
+          src='https://picsum.photos/id/134/340/340'
+          width='100%'
+          fit='cover'
+          :ratio='16/10'
+          no-spinner
+        )
+        .fileman-details-row(
+          v-for='item of currentFileDetails.items'
           )
+          label {{item.label}}
+          span {{item.value}}
+  q-page-container
+    q-page.fileman-center
+      //- TOOLBAR -----------------------------------------------------
+      q-toolbar.fileman-toolbar
+        template(v-if='state.isUploading')
+          .fileman-progressbar
+            div(:style='`width: ` + state.uploadPercentage + `%`') {{state.uploadPercentage}}%
+          q-btn.acrylic-btn.q-ml-sm(
+            flat
+            dense
+            no-caps
+            color='negative'
+            :aria-label='t(`common.actions.cancel`)'
+            icon='las la-square'
+            @click='uploadCancel'
+            v-if='state.uploadPercentage < 100'
+            )
+        template(v-else)
+          q-space
+          q-btn.q-mr-sm(
+            flat
+            dense
+            no-caps
+            color='grey'
+            :aria-label='t(`fileman.viewOptions`)'
+            icon='las la-th-list'
+            @click=''
+            )
+            q-tooltip(anchor='bottom middle', self='top middle') {{t(`fileman.viewOptions`)}}
+            q-menu(
+              auto-close
+              transition-show='jump-down'
+              transition-hide='jump-up'
+              anchor='bottom right'
+              self='top right'
+              )
+              q-card.q-pa-sm
+                .text-center
+                  small.text-grey {{t(`fileman.viewOptions`)}}
+                q-list(dense)
+                  q-separator.q-my-sm
+                  q-item(clickable)
+                    q-item-section(side)
+                      q-icon(name='las la-circle', color='grey', size='xs')
+                    q-item-section.q-pr-sm Compact List
+                  q-item(clickable)
+                    q-item-section(side)
+                      q-icon(name='las la-check-circle', color='positive', size='xs')
+                    q-item-section.q-pr-sm Show Folders
+          q-btn.q-mr-sm(
+            flat
+            dense
+            no-caps
+            color='grey'
+            :aria-label='t(`common.actions.refresh`)'
+            icon='las la-redo-alt'
+            @click=''
+            )
+            q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.refresh`)}}
+          q-separator.q-mr-sm(inset, vertical)
+          q-btn.q-mr-sm(
+            flat
+            dense
+            no-caps
+            color='blue'
+            :label='t(`common.actions.new`)'
+            :aria-label='t(`common.actions.new`)'
+            icon='las la-plus-circle'
+            @click=''
+            )
+            new-menu(
+              :hide-asset-btn='true'
+              :show-new-folder='true'
+              @new-folder='() => newFolder(state.currentFolderId)'
+              )
+          q-btn(
+            flat
+            dense
+            no-caps
+            color='positive'
+            :label='t(`common.actions.upload`)'
+            :aria-label='t(`common.actions.upload`)'
+            icon='las la-cloud-upload-alt'
+            @click='uploadFile'
+            )
       q-list.fileman-filelist
-        q-item(clickable)
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-folder.svg', size='xl')
-          q-item-section
-            q-item-label Beep Boop
-            q-item-label(caption) 19 Items
-          q-item-section(side)
-            .text-caption 1
-        q-item(clickable)
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-folder.svg', size='xl')
-          q-item-section
-            q-item-label Beep Boop
-            q-item-label(caption) 19 Items
-          q-item-section(side)
-            .text-caption 1
-        q-item(clickable)
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/color-document.svg', size='xl')
-          q-item-section
-            q-item-label Beep Boop
-            q-item-label(caption) Markdown
-          q-item-section(side)
-            .text-caption 1
-        q-item(clickable)
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/color-pdf.svg', size='xl')
-          q-item-section
-            q-item-label Beep Boop
-            q-item-label(caption) 4 pages
-          q-item-section(side)
-            .text-caption 2022/01/01
-
+        q-item(
+          v-for='item of files'
+          :key='item.id'
+          clickable
+          active-class='active'
+          :active='item.id === state.currentFileId'
+          @click.native='state.currentFileId = item.id'
+          @dblclick.native='openItem(item)'
+          )
+          q-item-section.fileman-filelist-icon(avatar)
+            q-icon(:name='item.icon', size='xl')
+          q-item-section.fileman-filelist-label
+            q-item-label {{item.title}}
+            q-item-label(caption) {{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`')
+                  q-item-section(side)
+                    q-icon(name='las la-eye', color='primary')
+                  q-item-section View
+                q-item(clickable, v-if='item.type !== `folder`')
+                  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)
+                  q-item-section(side)
+                    q-icon(name='las la-trash-alt', color='negative')
+                  q-item-section.text-negative Delete
   q-footer
-    q-bar.bg-blue-grey-1
+    q-bar.fileman-path
       small.text-caption.text-grey-7 / foo / bar
+
+  input(
+    type='file'
+    ref='fileIpt'
+    multiple
+    @change='uploadNewFiles'
+    style='display: none'
+    )
 </template>
 
 <script setup>
 import { useI18n } from 'vue-i18n'
-import { reactive } from 'vue'
+import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
+import { filesize } from 'filesize'
+import { useQuasar } from 'quasar'
+import { DateTime } from 'luxon'
+import { cloneDeep, find } from 'lodash-es'
+import gql from 'graphql-tag'
 
 import NewMenu from './PageNewMenu.vue'
 import Tree from './TreeNav.vue'
 
+import fileTypes from '../helpers/fileTypes'
+
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 
+import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
+
+// QUASAR
+
+const $q = useQuasar()
+
 // STORES
 
 const pageStore = usePageStore()
@@ -149,31 +250,135 @@ const { t } = useI18n()
 const state = reactive({
   loading: 0,
   search: '',
-  currentFolderId: 'boop',
-  treeNodes: {
-    beep: {
-      text: 'Beep',
-      children: ['foo', 'bar']
-    },
-    foo: {
-      text: 'Foo'
-    },
-    bar: {
-      text: 'Bar',
-      children: ['boop']
+  currentFolderId: '',
+  currentFileId: '',
+  treeNodes: {},
+  treeRoots: [],
+  isUploading: false,
+  shouldCancelUpload: false,
+  uploadPercentage: 0,
+  fileList: [
+    {
+      id: '1',
+      type: 'folder',
+      title: 'Beep Boop',
+      children: 19
     },
-    boop: {
-      text: 'Boop'
+    {
+      id: '2',
+      type: 'folder',
+      title: 'Second Folder',
+      children: 0
     },
-    bop: {
-      text: 'Bop',
-      children: ['bap']
+    {
+      id: '3',
+      type: 'page',
+      title: 'Some Page',
+      pageType: 'markdown',
+      updatedAt: '2022-11-24T18:27:00Z'
     },
-    bap: {
-      text: 'Bap'
+    {
+      id: '4',
+      type: 'file',
+      title: 'Important Document',
+      fileType: 'pdf',
+      fileSize: 19000
+    }
+  ]
+})
+
+// REFS
+
+const fileIpt = ref(null)
+
+// COMPUTED
+
+const files = computed(() => {
+  return state.fileList.map(f => {
+    switch (f.type) {
+      case 'folder': {
+        f.icon = fileTypes.folder.icon
+        f.caption = t('fileman.folderChildrenCount', f.children, { count: f.children })
+        break
+      }
+      case 'page': {
+        f.icon = fileTypes.page.icon
+        f.caption = t(`fileman.${f.pageType}PageType`)
+        break
+      }
+      case 'file': {
+        f.icon = fileTypes[f.fileType]?.icon ?? ''
+        f.side = filesize(f.fileSize)
+        if (fileTypes[f.fileType]) {
+          f.caption = t(`fileman.${f.fileType}FileType`)
+        } else {
+          f.caption = t('fileman.unknownFileType', { type: f.fileType.toUpperCase() })
+        }
+        break
+      }
     }
-  },
-  treeRoots: ['beep', 'bop']
+    return f
+  })
+})
+
+const currentFileDetails = computed(() => {
+  if (state.currentFileId) {
+    const item = find(state.fileList, ['id', state.currentFileId])
+    if (item.type === 'folder') {
+      return null
+    }
+
+    const items = [
+      {
+        label: t('fileman.detailsTitle'),
+        value: item.title
+      }
+    ]
+    switch (item.type) {
+      case 'page': {
+        items.push({
+          label: t('fileman.detailsPageType'),
+          value: t(`fileman.${item.pageType}PageType`)
+        })
+        items.push({
+          label: t('fileman.detailsPageEditor'),
+          value: item.pageType
+        })
+        items.push({
+          label: t('fileman.detailsPageUpdated'),
+          value: DateTime.fromISO(item.updatedAt).toFormat('yyyy-MM-dd \'at\' h:mm ZZZZ')
+        })
+        items.push({
+          label: t('fileman.detailsPageCreated'),
+          value: DateTime.fromISO(item.updatedAt).toFormat('yyyy-MM-dd \'at\' h:mm ZZZZ')
+        })
+        break
+      }
+      case 'file': {
+        items.push({
+          label: t('fileman.detailsAssetType'),
+          value: fileTypes[item.fileType] ? t(`fileman.${item.fileType}FileType`) : t('fileman.unknownFileType', { type: item.fileType.toUpperCase() })
+        })
+        items.push({
+          label: t('fileman.detailsAssetSize'),
+          value: filesize(item.fileSize)
+        })
+        break
+      }
+    }
+    return {
+      thumbnail: '',
+      items
+    }
+  } else {
+    return null
+  }
+})
+
+// WATCHERS
+
+watch(() => state.currentFolderId, (newValue) => {
+  state.currentFileId = null
 })
 
 // METHODS
@@ -182,21 +387,355 @@ function close () {
   siteStore.overlay = null
 }
 
-function openFolder (node, noder) {
-  console.info(node, noder)
+async function treeLazyLoad (nodeId, { done, fail }) {
+  await loadTree(nodeId, ['folder'])
+  done()
+}
+
+async function loadTree (parentId, types) {
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query loadTree (
+          $siteId: UUID!
+          $parentId: UUID
+          $types: [TreeItemType]
+        ) {
+          tree (
+            siteId: $siteId
+            parentId: $parentId
+            types: $types
+          ) {
+            __typename
+            ... on TreeItemFolder {
+              id
+              folderPath
+              fileName
+              title
+              childrenCount
+            }
+            ... on TreeItemPage {
+              id
+              folderPath
+              fileName
+              title
+              createdAt
+              updatedAt
+              pageEditor
+            }
+            ... on TreeItemAsset {
+              id
+              folderPath
+              fileName
+              title
+              createdAt
+              updatedAt
+              fileSize
+            }
+          }
+        }
+      `,
+      variables: {
+        siteId: siteStore.id,
+        parentId,
+        types
+      },
+      fetchPolicy: 'network-only'
+    })
+    const items = cloneDeep(resp?.data?.tree)
+    if (items?.length > 0) {
+      const newTreeRoots = []
+      for (const item of items) {
+        switch (item.__typename) {
+          case 'TreeItemFolder': {
+            state.treeNodes[item.id] = {
+              text: item.title,
+              children: []
+            }
+            if (!item.folderPath) {
+              newTreeRoots.push(item.id)
+            } else {
+              state.treeNodes[parentId].children.push(item.id)
+            }
+            break
+          }
+        }
+      }
+      if (newTreeRoots.length > 0) {
+        state.treeRoots = newTreeRoots
+      }
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to load folder tree.',
+      caption: err.message
+    })
+  }
 }
 
+function treeContextAction (nodeId, action) {
+  console.info(nodeId, action)
+  switch (action) {
+    case 'newFolder': {
+      newFolder(nodeId)
+      break
+    }
+  }
+}
+
+function newFolder (parentId) {
+  $q.dialog({
+    component: FolderCreateDialog,
+    componentProps: {
+      parentId
+    }
+  }).onOk(() => {
+    loadTree(parentId)
+  })
+}
+
+// -> Upload Methods
+
+function uploadFile () {
+  fileIpt.value.click()
+}
+
+async function uploadNewFiles () {
+  if (!fileIpt.value.files?.length) {
+    return
+  }
+
+  console.info(fileIpt.value.files)
+
+  state.isUploading = true
+  state.uploadPercentage = 0
+
+  state.loading++
+
+  nextTick(() => {
+    setTimeout(async () => {
+      try {
+        const totalFiles = fileIpt.value.files.length
+        let idx = 0
+        for (const fileToUpload of fileIpt.value.files) {
+          idx++
+          state.uploadPercentage = totalFiles > 1 ? Math.round(idx / totalFiles * 100) : 90
+          const resp = await APOLLO_CLIENT.mutate({
+            mutation: gql`
+              mutation uploadAssets (
+                $siteId: UUID!
+                $files: [Upload!]!
+              ) {
+                uploadAssets (
+                  siteId: $siteId
+                  files: $files
+                ) {
+                  operation {
+                    succeeded
+                    message
+                  }
+                }
+              }
+            `,
+            variables: {
+              siteId: siteStore.id,
+              files: [fileToUpload]
+            }
+          })
+          if (!resp?.data?.uploadAssets?.operation?.succeeded) {
+            throw new Error(resp?.data?.uploadAssets?.operation?.message || 'An unexpected error occured.')
+          }
+        }
+        state.uploadPercentage = 100
+        $q.notify({
+          type: 'positive',
+          message: t('fileman.uploadSuccess')
+        })
+      } catch (err) {
+        $q.notify({
+          type: 'negative',
+          message: 'Failed to upload file.',
+          caption: err.message
+        })
+      }
+      state.loading--
+      fileIpt.value.value = null
+      setTimeout(() => {
+        state.isUploading = false
+        state.uploadPercentage = 0
+      }, 1500)
+    }, 400)
+  })
+}
+
+function uploadCancel () {
+  state.isUploading = false
+  state.uploadPercentage = 0
+}
+
+function openItem (item) {
+  console.info(item.id)
+}
+
+// MOUNTED
+
+onMounted(() => {
+  loadTree()
+})
+
 </script>
 
 <style lang="scss">
 .fileman {
+  &-left {
+    @at-root .body--light & {
+      background-color: $blue-grey-1;
+    }
+    @at-root .body--dark & {
+      background-color: $dark-4;
+    }
+  }
+
+  &-center {
+    @at-root .body--light & {
+      background-color: #FFF;
+    }
+    @at-root .body--dark & {
+      background-color: $dark-6;
+    }
+  }
+
+  &-right {
+    @at-root .body--light & {
+      background-color: $grey-1;
+    }
+    @at-root .body--dark & {
+      background-color: $dark-5;
+    }
+  }
+
+  &-toolbar {
+    @at-root .body--light & {
+      background-color: $grey-1;
+    }
+    @at-root .body--dark & {
+      background-color: $dark-5;
+    }
+  }
+
+  &-path {
+    @at-root .body--light & {
+      background-color: $blue-grey-1 !important;
+    }
+    @at-root .body--dark & {
+      background-color: $dark-4 !important;
+    }
+  }
+
   &-filelist {
     padding: 8px 12px;
 
     > .q-item {
       padding: 8px 6px;
       border-radius: 8px;
+
+      &.active {
+        background-color: var(--q-primary);
+        color: #FFF;
+
+        .fileman-filelist-label .q-item__label--caption {
+          color: rgba(255,255,255,.7);
+        }
+
+        .fileman-filelist-side .text-caption {
+          color: rgba(255,255,255,.7);
+        }
+      }
+    }
+  }
+  &-details-row {
+    display: flex;
+    flex-direction: column;
+    padding: 5px 0;
+
+    label {
+      font-size: .7rem;
+      font-weight: 500;
+
+      @at-root .body--light & {
+        color: $grey-6;
+      }
+      @at-root .body--dark & {
+        color: $blue-grey-4;
+      }
+    }
+    span {
+      font-size: .85rem;
+
+      @at-root .body--light & {
+        color: $grey-8;
+      }
+      @at-root .body--dark & {
+        color: $blue-grey-2;
+      }
     }
+
+    & + .fileman-details-row {
+      margin-top: 5px;
+    }
+  }
+
+  &-progressbar {
+    width: 100%;
+    flex: 1;
+    height: 12px;
+    border-radius: 3px;
+
+    @at-root .body--light & {
+      background-color: $blue-grey-2;
+    }
+    @at-root .body--dark & {
+      background-color: $dark-4 !important;
+    }
+
+    > div {
+      height: 12px;
+      background-color: $positive;
+      border-radius: 3px 0 0 3px;
+      background-image: linear-gradient(
+        -45deg,
+        rgba(255, 255, 255, 0.3) 25%,
+        transparent 25%,
+        transparent 50%,
+        rgba(255, 255, 255, 0.3) 50%,
+        rgba(255, 255, 255, 0.3) 75%,
+        transparent 75%,
+        transparent
+      );
+      background-size: 50px 50px;
+      background-position: 0 0;
+      animation: fileman-progress 2s linear infinite;
+      box-shadow: 0 0 5px 0 $positive;
+      font-size: 9px;
+      letter-spacing: 2px;
+      font-weight: 700;
+      color: #FFF;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      overflow: hidden;
+      transition: all 1s ease;
+    }
+  }
+}
+
+@keyframes fileman-progress {
+  0% {
+    background-position: 0 0;
+  }
+  100% {
+    background-position: -50px -50px;
   }
 }
 </style>

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

@@ -0,0 +1,185 @@
+<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-plus-plus.svg', left, size='sm')
+      span {{t(`fileman.folderCreate`)}}
+    q-form.q-py-sm(ref='newFolderForm', @submit='create')
+      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'
+            )
+      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'
+            )
+    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.create`)'
+        color='primary'
+        padding='xs md'
+        @click='create'
+        :loading='state.loading > 0'
+        )
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { reactive, ref, watch } from 'vue'
+import slugify from 'slugify'
+
+import { useSiteStore } from 'src/stores/site'
+
+// PROPS
+
+const props = defineProps({
+  parentId: {
+    type: String,
+    default: null
+  }
+})
+
+// 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 newFolderForm = 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 create () {
+  state.loading++
+  try {
+    const isFormValid = await newFolderForm.value.validate(true)
+    if (!isFormValid) {
+      throw new Error(t('fileman.createFolderInvalidData'))
+    }
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation createFolder (
+          $siteId: UUID!
+          $parentId: UUID
+          $pathName: String!
+          $title: String!
+          ) {
+          createFolder (
+            siteId: $siteId
+            parentId: $parentId
+            pathName: $pathName
+            title: $title
+            ) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        siteId: siteStore.id,
+        parentId: props.parentId,
+        pathName: state.path,
+        title: state.title
+      }
+    })
+    if (resp?.data?.createFolder?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('fileman.createFolderSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.createFolder?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.loading--
+}
+</script>

+ 2 - 2
ux/src/components/HeaderNav.vue

@@ -83,7 +83,7 @@ q-header.bg-header.text-white.site-header(
         icon='las la-folder-open'
         color='positive'
         aria-label='File Manager'
-        @click='toggleFileManager'
+        @click='openFileManager'
         )
         q-tooltip File Manager
       q-btn.q-ml-md(
@@ -129,7 +129,7 @@ const state = reactive({
 
 // METHODS
 
-function toggleFileManager () {
+function openFileManager () {
   siteStore.overlay = 'FileManager'
 }
 </script>

+ 31 - 0
ux/src/components/MainOverlayDialog.vue

@@ -0,0 +1,31 @@
+<template lang="pug">
+q-dialog.main-overlay(
+  v-model='siteStore.overlayIsShown'
+  persistent
+  full-width
+  full-height
+  no-shake
+  transition-show='jump-up'
+  transition-hide='jump-down'
+  )
+  component(:is='overlays[siteStore.overlay]')
+</template>
+
+<script setup>
+import { defineAsyncComponent } from 'vue'
+
+import { useSiteStore } from '../stores/site'
+
+import LoadingGeneric from './LoadingGeneric.vue'
+
+const overlays = {
+  FileManager: defineAsyncComponent({
+    loader: () => import('./FileManager.vue'),
+    loadingComponent: LoadingGeneric
+  })
+}
+
+// STORES
+
+const siteStore = useSiteStore()
+</script>

+ 17 - 0
ux/src/components/PageNewMenu.vue

@@ -28,6 +28,11 @@ q-menu.translucent-menu(
       q-item(clickable, @click='openFileManager')
         blueprint-icon(icon='add-image')
         q-item-section.q-pr-sm Upload Media Asset
+    template(v-if='props.showNewFolder')
+      q-separator.q-my-sm(inset)
+      q-item(clickable, @click='newFolder')
+        blueprint-icon(icon='add-folder')
+        q-item-section.q-pr-sm New Folder
 </template>
 
 <script setup>
@@ -43,9 +48,17 @@ const props = defineProps({
   hideAssetBtn: {
     type: Boolean,
     default: false
+  },
+  showNewFolder: {
+    type: Boolean,
+    default: false
   }
 })
 
+// EMITS
+
+const emit = defineEmits(['newFolder'])
+
 // QUASAR
 
 const $q = useQuasar()
@@ -69,4 +82,8 @@ function create (editor) {
 function openFileManager () {
   siteStore.overlay = 'FileManager'
 }
+
+function newFolder () {
+  emit('newFolder')
+}
 </script>

+ 19 - 4
ux/src/components/TreeLevel.vue

@@ -4,7 +4,7 @@ ul.treeview-level
   li.treeview-node(v-if='!props.parentId')
     .treeview-label(@click='setRoot', :class='{ "active": !selection }')
       q-icon(name='img:/_assets/icons/fluent-ftp.svg', size='sm')
-      em.text-purple root
+      .treeview-label-text(:class='$q.dark.isActive ? `text-purple-4` : `text-purple`') root
       q-menu(
         touch-position
         context-menu
@@ -14,10 +14,15 @@ ul.treeview-level
         )
         q-card.q-pa-sm
           q-list(dense, style='min-width: 150px;')
-            q-item(clickable)
+            q-item(clickable, @click='createRootFolder')
               q-item-section(side)
                 q-icon(name='las la-plus-circle', color='primary')
               q-item-section New Folder
+      q-icon(
+        v-if='!selection'
+        name='las la-angle-right'
+        :color='$q.dark.isActive ? `purple-4` : `purple`'
+        )
   //- NORMAL NODES
   tree-node(
     v-for='node of level'
@@ -30,6 +35,7 @@ ul.treeview-level
 
 <script setup>
 import { computed, inject } from 'vue'
+import { useQuasar } from 'quasar'
 
 import TreeNode from './TreeNode.vue'
 
@@ -46,18 +52,23 @@ const props = defineProps({
   }
 })
 
+// QUASAR
+
+const $q = useQuasar()
+
 // INJECT
 
-const roots = inject('roots', [])
+const roots = inject('roots')
 const nodes = inject('nodes')
 const selection = inject('selection')
+const emitContextAction = inject('emitContextAction')
 
 // COMPUTED
 
 const level = computed(() => {
   const items = []
   if (!props.parentId) {
-    for (const root of roots) {
+    for (const root of roots.value) {
       items.push({
         id: root,
         ...nodes[root]
@@ -80,4 +91,8 @@ function setRoot () {
   selection.value = null
 }
 
+function createRootFolder () {
+  emitContextAction(null, 'newFolder')
+}
+
 </script>

+ 40 - 4
ux/src/components/TreeNav.vue

@@ -7,7 +7,7 @@
 </template>
 
 <script setup>
-import { computed, onMounted, provide, reactive } from 'vue'
+import { computed, onMounted, provide, reactive, toRef } from 'vue'
 import { findKey } from 'lodash-es'
 
 import TreeLevel from './TreeLevel.vue'
@@ -26,16 +26,21 @@ const props = defineProps({
   selected: {
     type: String,
     default: null
+  },
+  useLazyLoad: {
+    type: Boolean,
+    default: false
   }
 })
 
 // EMITS
 
-const emit = defineEmits(['update:selected'])
+const emit = defineEmits(['update:selected', 'lazyLoad', 'contextAction'])
 
 // DATA
 
 const state = reactive({
+  loaded: {},
   opened: {}
 })
 
@@ -52,12 +57,27 @@ const selection = computed({
 
 // METHODS
 
+function emitLazyLoad (nodeId, clb) {
+  if (props.useLazyLoad) {
+    emit('lazyLoad', nodeId, clb)
+  } else {
+    clb.done()
+  }
+}
+
+function emitContextAction (nodeId, action) {
+  emit('contextAction', nodeId, action)
+}
+
 // PROVIDE
 
-provide('roots', props.roots)
+provide('roots', toRef(props, 'roots'))
 provide('nodes', props.nodes)
+provide('loaded', state.loaded)
 provide('opened', state.opened)
 provide('selection', selection)
+provide('emitLazyLoad', emitLazyLoad)
+provide('emitContextAction', emitContextAction)
 
 // MOUNTED
 
@@ -102,6 +122,13 @@ onMounted(() => {
   &-node {
     display: block;
     border-left: 2px solid rgba(0,0,0,.05);
+
+    @at-root .body--light & {
+      border-left: 2px solid rgba(0,0,0,.05);
+    }
+    @at-root .body--dark & {
+      border-left: 2px solid rgba(255,255,255,.1);
+    }
   }
 
   &-label {
@@ -113,12 +140,21 @@ onMounted(() => {
     transition: background-color .4s ease;
 
     &:hover, &:focus, &.active {
-      background-color: rgba(0,0,0,.05);
+      @at-root .body--light & {
+        background-color: rgba(0,0,0,.05);
+      }
+      @at-root .body--dark & {
+        background-color: rgba(255,255,255,.1);
+      }
     }
 
     > .q-icon {
       margin-right: 5px;
     }
+
+    &-text {
+      flex: 1 0;
+    }
   }
 
   // Animations

+ 55 - 9
ux/src/components/TreeNode.vue

@@ -1,9 +1,22 @@
 <template lang="pug">
 li.treeview-node
   //- NODE
-  .treeview-label(@click='toggleNode', :class='{ "active": isActive }')
-    q-icon(:name='icon', size='sm')
-    span {{node.text}}
+  .treeview-label(@click='openNode', :class='{ "active": isActive }')
+    q-icon(
+      :name='icon'
+      size='sm'
+      @click.stop='hasChildren ? toggleNode() : openNode()'
+      )
+    .treeview-label-text {{node.text}}
+    q-spinner.q-mr-xs(
+      color='primary'
+      v-if='state.isLoading'
+      )
+    q-icon(
+      v-if='isActive'
+      name='las la-angle-right'
+      :color='$q.dark.isActive ? `yellow-9` : `brown-4`'
+      )
     //- RIGHT-CLICK MENU
     q-menu(
       touch-position
@@ -16,10 +29,14 @@ li.treeview-node
       )
       q-card.q-pa-sm
         q-list(dense, style='min-width: 150px;')
-          q-item(clickable)
+          q-item(clickable, @click='contextAction(`newFolder`)')
             q-item-section(side)
               q-icon(name='las la-plus-circle', color='primary')
             q-item-section New Folder
+          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')
@@ -43,6 +60,7 @@ li.treeview-node
 
 <script setup>
 import { computed, inject, reactive } from 'vue'
+import { useQuasar } from 'quasar'
 
 import TreeLevel from './TreeLevel.vue'
 
@@ -63,15 +81,23 @@ const props = defineProps({
   }
 })
 
+// QUASAR
+
+const $q = useQuasar()
+
 // INJECT
 
+const loaded = inject('loaded')
 const opened = inject('opened')
 const selection = inject('selection')
+const emitLazyLoad = inject('emitLazyLoad')
+const emitContextAction = inject('emitContextAction')
 
 // DATA
 
 const state = reactive({
-  isContextMenuShown: false
+  isContextMenuShown: false,
+  isLoading: false
 })
 
 // COMPUTED
@@ -80,7 +106,7 @@ const icon = computed(() => {
   if (props.node.icon) {
     return props.node.icon
   }
-  return hasChildren.value && isOpened.value ? 'img:/_assets/icons/fluent-opened-folder.svg' : 'img:/_assets/icons/fluent-folder.svg'
+  return isOpened.value ? 'img:/_assets/icons/fluent-opened-folder.svg' : 'img:/_assets/icons/fluent-folder.svg'
 })
 
 const hasChildren = computed(() => {
@@ -95,13 +121,33 @@ const isActive = computed(() => {
 
 // METHODS
 
-function toggleNode () {
-  selection.value = props.node.id
+async function toggleNode () {
+  opened[props.node.id] = !(opened[props.node.id] === true)
+  if (opened[props.node.id] && !loaded[props.node.id]) {
+    state.isLoading = true
+    await Promise.race([
+      new Promise((resolve, reject) => {
+        emitLazyLoad(props.node.id, { done: resolve, fail: reject })
+      }),
+      new Promise((resolve, reject) => {
+        setTimeout(() => reject(new Error('Async tree loading timeout')), 30000)
+      })
+    ])
+    loaded[props.node.id] = true
+    state.isLoading = false
+  }
+}
 
+function openNode () {
+  selection.value = props.node.id
   if (selection.value !== props.node.id && opened[props.node.id]) {
     return
   }
-  opened[props.node.id] = !(opened[props.node.id] === true)
+  toggleNode()
+}
+
+function contextAction (action) {
+  emitContextAction(props.node.id, action)
 }
 
 </script>

+ 11 - 0
ux/src/helpers/fileTypes.js

@@ -0,0 +1,11 @@
+export default {
+  folder: {
+    icon: 'img:/_assets/icons/fluent-folder.svg'
+  },
+  page: {
+    icon: 'img:/_assets/icons/color-document.svg'
+  },
+  pdf: {
+    icon: 'img:/_assets/icons/color-pdf.svg'
+  }
+}

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

@@ -291,8 +291,12 @@
   "admin.groups.users": "Users",
   "admin.groups.usersCount": "0 user | 1 user | {count} users",
   "admin.groups.usersNone": "This group doesn't have any user yet.",
+  "admin.icons.mandatory": "Used by the system and cannot be disabled.",
+  "admin.icons.reference": "Reference",
   "admin.icons.subtitle": "Configure the icon packs available for use",
   "admin.icons.title": "Icons",
+  "admin.icons.warnHint": "Only activate the icon packs you actually use.",
+  "admin.icons.warnLabel": "Enabling additional icon packs can significantly increase page load times!",
   "admin.instances.activeConnections": "Active Connections",
   "admin.instances.activeListeners": "Active Listeners",
   "admin.instances.firstSeen": "First Seen",
@@ -1189,6 +1193,7 @@
   "common.field.lastUpdated": "Last Updated",
   "common.field.name": "Name",
   "common.field.task": "Task",
+  "common.field.title": "Title",
   "common.footerCopyright": "© {year} {company}. All rights reserved.",
   "common.footerGeneric": "Powered by {link}, an open source project.",
   "common.footerLicense": "Content is available under the {license}, by {company}.",
@@ -1470,7 +1475,30 @@
   "editor.unsaved.body": "You have unsaved changes. Are you sure you want to leave the editor and discard any modifications you made since the last save?",
   "editor.unsaved.title": "Discard Unsaved Changes?",
   "editor.unsavedWarning": "You have unsaved edits. Are you sure you want to leave the editor?",
+  "fileman.createFolderInvalidData": "One or more fields are invalid.",
+  "fileman.createFolderSuccess": "Folder created successfully.",
+  "fileman.detailsAssetSize": "File Size",
+  "fileman.detailsAssetType": "Type",
+  "fileman.detailsPageCreated": "Created",
+  "fileman.detailsPageEditor": "Editor",
+  "fileman.detailsPageType": "Type",
+  "fileman.detailsPageUpdated": "Last Updated",
+  "fileman.detailsTitle": "Title",
+  "fileman.folderChildrenCount": "Empty folder | 1 child | {count} children",
+  "fileman.folderCreate": "New Folder",
+  "fileman.folderFileName": "Path Name",
+  "fileman.folderFileNameHint": "URL friendly version of the folder name. Must consist of lowercase alphanumerical or hypen characters only.",
+  "fileman.folderFileNameInvalid": "Invalid Characters in Folder Path Name. Lowercase alphanumerical and hyphen characters only.",
+  "fileman.folderFileNameMissing": "Missing Folder Path Name",
+  "fileman.folderTitle": "Title",
+  "fileman.folderTitleInvalidChars": "Invalid Characters in Folder Name",
+  "fileman.folderTitleMissing": "Missing Folder Title",
+  "fileman.markdownPageType": "Markdown Page",
+  "fileman.pdfFileType": "PDF Document",
   "fileman.title": "File Manager",
+  "fileman.unknownFileType": "{type} file",
+  "fileman.uploadSuccess": "File(s) uploaded successfully.",
+  "fileman.viewOptions": "View Options",
   "history.restore.confirmButton": "Restore",
   "history.restore.confirmText": "Are you sure you want to restore this page content as it was on {date}? This version will be copied on top of the current history. As such, newer versions will still be preserved.",
   "history.restore.confirmTitle": "Restore page version?",
@@ -1558,9 +1586,5 @@
   "welcome.admin": "Administration Area",
   "welcome.createHome": "Create the homepage",
   "welcome.subtitle": "Let's get started...",
-  "welcome.title": "Welcome to Wiki.js!",
-  "admin.icons.warnLabel": "Enabling additional icon packs can significantly increase page load times!",
-  "admin.icons.warnHint": "Only activate the icon packs you actually use.",
-  "admin.icons.reference": "Reference",
-  "admin.icons.mandatory": "Used by the system and cannot be disabled."
+  "welcome.title": "Welcome to Wiki.js!"
 }

+ 12 - 21
ux/src/layouts/MainLayout.vue

@@ -27,6 +27,7 @@ q-layout(view='hHh Lpr lff')
         label='Browse'
         aria-label='Browse'
         size='sm'
+        @click='openFileManager'
         )
     q-scroll-area.sidebar-nav(
       :thumb-style='thumbStyle'
@@ -76,22 +77,13 @@ q-layout(view='hHh Lpr lff')
         round
         size='md'
       )
-  q-dialog.main-overlay(
-    v-model='siteStore.overlayIsShown'
-    persistent
-    full-width
-    full-height
-    no-shake
-    transition-show='jump-up'
-    transition-hide='jump-down'
-    )
-    component(:is='overlays[siteStore.overlay]')
+  main-overlay-dialog
   footer-nav
 </template>
 
 <script setup>
-import { useMeta, useQuasar, setCssVar } from 'quasar'
-import { defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
+import { useMeta, useQuasar } from 'quasar'
+import { onMounted, reactive, ref, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 
@@ -99,16 +91,9 @@ import { useSiteStore } from '../stores/site'
 
 // COMPONENTS
 
-import HeaderNav from '../components/HeaderNav.vue'
 import FooterNav from 'src/components/FooterNav.vue'
-import LoadingGeneric from 'src/components/LoadingGeneric.vue'
-
-const overlays = {
-  FileManager: defineAsyncComponent({
-    loader: () => import('../components/FileManager.vue'),
-    loadingComponent: LoadingGeneric
-  })
-}
+import HeaderNav from 'src/components/HeaderNav.vue'
+import MainOverlayDialog from 'src/components/MainOverlayDialog.vue'
 
 // QUASAR
 
@@ -151,6 +136,12 @@ const barStyle = {
   opacity: 0.1
 }
 
+// METHODS
+
+function openFileManager () {
+  siteStore.overlay = 'FileManager'
+}
+
 </script>
 
 <style lang="scss">

+ 2 - 0
ux/src/layouts/ProfileLayout.vue

@@ -39,6 +39,7 @@ q-layout(view='hHh Lpr lff')
             q-item-section
               q-item-label.text-negative {{ t('common.header.logout') }}
       router-view
+  main-overlay-dialog
   footer-nav
 </template>
 
@@ -52,6 +53,7 @@ import { useUserStore } from 'src/stores/user'
 
 import HeaderNav from 'src/components/HeaderNav.vue'
 import FooterNav from 'src/components/FooterNav.vue'
+import MainOverlayDialog from 'src/components/MainOverlayDialog.vue'
 
 // QUASAR
 

+ 8 - 0
ux/yarn.lock

@@ -6624,6 +6624,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"slugify@npm:1.6.5":
+  version: 1.6.5
+  resolution: "slugify@npm:1.6.5"
+  checksum: a955a1b600201030f4c1daa9bb74a17d4402a0693fc40978bbd17e44e64fd72dad3bac4037422aa8aed55b5170edd57f3f4cd8f59ba331f5cf0f10f1a7795609
+  languageName: node
+  linkType: hard
+
 "smart-buffer@npm:^4.2.0":
   version: 4.2.0
   resolution: "smart-buffer@npm:4.2.0"
@@ -7181,6 +7188,7 @@ __metadata:
     pinia: 2.0.23
     pug: 3.0.2
     quasar: 2.10.1
+    slugify: 1.6.5
     socket.io-client: 4.5.3
     tippy.js: 6.3.7
     uuid: 9.0.0