浏览代码

feat: image upload / display

Nick 6 年之前
父节点
当前提交
35bc745826

+ 10 - 4
client/components/editor/editor-markdown.vue

@@ -1,9 +1,11 @@
 <template lang='pug'>
 <template lang='pug'>
   .editor-markdown
   .editor-markdown
     v-toolbar.editor-markdown-toolbar(dense, color='primary', dark, flat, style='overflow-x: hidden;')
     v-toolbar.editor-markdown-toolbar(dense, color='primary', dark, flat, style='overflow-x: hidden;')
-      v-btn.animated.fadeInLeft(v-if='isModalShown', flat, @click='closeAllModal')
-        v-icon(left) close
-        span Back to Editor
+      template(v-if='isModalShown')
+        v-spacer
+        v-btn.animated.fadeInRight(flat, @click='closeAllModal')
+          v-icon(left) remove_circle_outline
+          span Back to Editor
       template(v-else)
       template(v-else)
         v-tooltip(bottom, color='primary')
         v-tooltip(bottom, color='primary')
           v-btn.animated.fadeIn(icon, slot='activator', @click='toggleMarkup({ start: `**` })').mx-0
           v-btn.animated.fadeIn(icon, slot='activator', @click='toggleMarkup({ start: `**` })').mx-0
@@ -469,8 +471,12 @@ export default {
     this.$root.$on('editorInsert', opts => {
     this.$root.$on('editorInsert', opts => {
       switch (opts.kind) {
       switch (opts.kind) {
         case 'IMAGE':
         case 'IMAGE':
+          let img = `![${opts.text}](${opts.path})`
+          if (opts.align && opts.align !== '') {
+            img += `{.align-${opts.align}}`
+          }
           this.insertAtCursor({
           this.insertAtCursor({
-            content: `![${opts.text}](${opts.path})`
+            content: img
           })
           })
           break
           break
         case 'BINARY':
         case 'BINARY':

+ 121 - 25
client/components/editor/editor-modal-media.vue

@@ -8,9 +8,8 @@
               .d-flex
               .d-flex
                 v-toolbar.radius-7(color='teal lighten-5', dense, flat, height='44')
                 v-toolbar.radius-7(color='teal lighten-5', dense, flat, height='44')
                   .body-2.teal--text Assets
                   .body-2.teal--text Assets
-                v-btn.ml-3.my-0.radius-7(outline, large, color='teal', disabled, :icon='$vuetify.breakpoint.xsOnly')
-                  v-icon(:left='$vuetify.breakpoint.mdAndUp') keyboard_arrow_up
-                  span.hidden-sm-and-down Parent Folder
+                v-btn.ml-3.my-0.radius-7(outline, large, color='teal', icon, @click='refresh')
+                  v-icon cached
                 v-dialog(v-model='newFolderDialog', max-width='550')
                 v-dialog(v-model='newFolderDialog', max-width='550')
                   v-btn.my-0.mr-0.radius-7(outline, large, color='teal', :icon='$vuetify.breakpoint.xsOnly', slot='activator')
                   v-btn.my-0.mr-0.radius-7(outline, large, color='teal', :icon='$vuetify.breakpoint.xsOnly', slot='activator')
                     v-icon(:left='$vuetify.breakpoint.mdAndUp') add
                     v-icon(:left='$vuetify.breakpoint.mdAndUp') add
@@ -28,13 +27,20 @@
                         @keyup.enter='createFolder'
                         @keyup.enter='createFolder'
                         @keyup.esc='newFolderDialog = false'
                         @keyup.esc='newFolderDialog = false'
                         ref='folderNameIpt'
                         ref='folderNameIpt'
-                        hint='Lowercase. No spaces allowed.'
-                        persistent-hint
                         )
                         )
+                      .caption.grey--text.text--darken-1.pl-5 Must follow the asset folder #[a(href='https://docs-beta.requarks.io/guide/assets#naming-restrictions', target='_blank') naming rules].
                     v-card-chin
                     v-card-chin
                       v-spacer
                       v-spacer
                       v-btn(flat, @click='newFolderDialog = false') Cancel
                       v-btn(flat, @click='newFolderDialog = false') Cancel
-                      v-btn(color='primary', @click='createFolder', :disabled='!isFolderNameValid') Create
+                      v-btn(color='primary', @click='createFolder', :disabled='!isFolderNameValid', :loading='newFolderLoading') Create
+              template(v-if='folders.length > 0 || currentFolderId > 0')
+                .pt-2
+                  v-btn.is-icon.mx-1(color='grey darken-2', outline, :dark='currentFolderId > 0', @click='upFolder()', :disabled='currentFolderId === 0')
+                    v-icon keyboard_arrow_up
+                  v-btn.btn-normalcase.mx-1(v-for='folder of folders', :key='folder.id', depressed,  color='grey darken-2', dark, @click='downFolder(folder)')
+                    v-icon(left) folder
+                    span {{ folder.name }}
+                v-divider.mt-2
               v-data-table(
               v-data-table(
                 :items='assets'
                 :items='assets'
                 :headers='headers'
                 :headers='headers'
@@ -63,34 +69,55 @@
                       v-menu(offset-x)
                       v-menu(offset-x)
                         v-btn.ma-0(icon, slot='activator')
                         v-btn.ma-0(icon, slot='activator')
                           v-icon(color='grey darken-2') more_horiz
                           v-icon(color='grey darken-2') more_horiz
-                        v-list.py-0
+                        v-list.py-0(style='border-top: 5px solid #444;')
                           v-list-tile(@click='')
                           v-list-tile(@click='')
                             v-list-tile-avatar
                             v-list-tile-avatar
                               v-icon(color='teal') short_text
                               v-icon(color='teal') short_text
                             v-list-tile-content Properties
                             v-list-tile-content Properties
                           v-divider
                           v-divider
                           template(v-if='props.item.kind === `IMAGE`')
                           template(v-if='props.item.kind === `IMAGE`')
+                            v-list-tile(@click='')
+                              v-list-tile-avatar
+                                v-icon(color='green') image_search
+                              v-list-tile-content Preview
+                            v-divider
                             v-list-tile(@click='')
                             v-list-tile(@click='')
                               v-list-tile-avatar
                               v-list-tile-avatar
                                 v-icon(color='indigo') crop_rotate
                                 v-icon(color='indigo') crop_rotate
                               v-list-tile-content Edit
                               v-list-tile-content Edit
                             v-divider
                             v-divider
+                            v-list-tile(@click='')
+                              v-list-tile-avatar
+                                v-icon(color='purple') offline_bolt
+                              v-list-tile-content Optimize
+                            v-divider
                           v-list-tile(@click='')
                           v-list-tile(@click='')
                             v-list-tile-avatar
                             v-list-tile-avatar
-                              v-icon(color='blue') keyboard
-                            v-list-tile-content Rename / Move
+                              v-icon(color='orange') keyboard
+                            v-list-tile-content Rename
+                          v-divider
+                          v-list-tile(@click='')
+                            v-list-tile-avatar
+                              v-icon(color='blue') forward
+                            v-list-tile-content Move
                           v-divider
                           v-divider
                           v-list-tile(@click='')
                           v-list-tile(@click='')
                             v-list-tile-avatar
                             v-list-tile-avatar
                               v-icon(color='red') delete
                               v-icon(color='red') delete
                             v-list-tile-content Delete
                             v-list-tile-content Delete
                 template(slot='no-data')
                 template(slot='no-data')
-                  v-alert.ma-3(icon='warning', :value='true', outline) No assets to display.
+                  v-alert.mt-3.radius-7(icon='folder_open', :value='true', outline, color='teal') This asset folder is empty.
               .text-xs-center.py-2(v-if='this.pageTotal > 1')
               .text-xs-center.py-2(v-if='this.pageTotal > 1')
                 v-pagination(v-model='pagination.page', :length='pageTotal')
                 v-pagination(v-model='pagination.page', :length='pageTotal')
               .d-flex.mt-3
               .d-flex.mt-3
                 v-toolbar.radius-7(flat, color='grey lighten-4', dense, height='44')
                 v-toolbar.radius-7(flat, color='grey lighten-4', dense, height='44')
-                  .body-2 / #[em root]
+                  template(v-if='folderTree.length > 0')
+                    .body-2
+                      span.mr-1 /
+                      template(v-for='folder of folderTree')
+                        span(:key='folder.id') {{folder.name}}
+                        span.mx-1 /
+                  .body-2(v-else) / #[em root]
                   template(v-if='$vuetify.breakpoint.smAndUp')
                   template(v-if='$vuetify.breakpoint.smAndUp')
                     v-spacer
                     v-spacer
                     .body-1.grey--text.text--darken-1 {{assets.length}} files
                     .body-1.grey--text.text--darken-1 {{assets.length}} files
@@ -166,6 +193,8 @@ import vueFilePond from 'vue-filepond'
 import 'filepond/dist/filepond.min.css'
 import 'filepond/dist/filepond.min.css'
 
 
 import listAssetQuery from 'gql/editor/editor-media-query-list.gql'
 import listAssetQuery from 'gql/editor/editor-media-query-list.gql'
+import listFolderAssetQuery from 'gql/editor/editor-media-query-folder-list.gql'
+import createAssetFolderMutation from 'gql/editor/editor-media-mutation-folder-create.gql'
 
 
 const FilePond = vueFilePond()
 const FilePond = vueFilePond()
 const localeSegmentRegex = /^[A-Z]{2}(-[A-Z]{2})?$/i
 const localeSegmentRegex = /^[A-Z]{2}(-[A-Z]{2})?$/i
@@ -183,21 +212,27 @@ export default {
   },
   },
   data() {
   data() {
     return {
     return {
+      folders: [],
+      folderTree: [],
       files: [],
       files: [],
       assets: [],
       assets: [],
       pagination: {},
       pagination: {},
       remoteImageUrl: '',
       remoteImageUrl: '',
       imageAlignments: [
       imageAlignments: [
         { text: 'None', value: '' },
         { text: 'None', value: '' },
+        { text: 'Left', value: 'left' },
         { text: 'Centered', value: 'center' },
         { text: 'Centered', value: 'center' },
         { text: 'Right', value: 'right' },
         { text: 'Right', value: 'right' },
         { text: 'Absolute Top Right', value: 'abstopright' }
         { text: 'Absolute Top Right', value: 'abstopright' }
       ],
       ],
       imageAlignment: '',
       imageAlignment: '',
+      currentFolderId: 0,
       currentFileId: null,
       currentFileId: null,
+      previousFolderId: 0,
       loading: false,
       loading: false,
       newFolderDialog: false,
       newFolderDialog: false,
-      newFolderName: ''
+      newFolderName: '',
+      newFolderLoading: false
     }
     }
   },
   },
   computed: {
   computed: {
@@ -219,7 +254,7 @@ export default {
         { text: 'Filename', value: 'filename' },
         { text: 'Filename', value: 'filename' },
         this.$vuetify.breakpoint.lgAndUp && { text: 'Type', value: 'ext', width: 50 },
         this.$vuetify.breakpoint.lgAndUp && { text: 'Type', value: 'ext', width: 50 },
         this.$vuetify.breakpoint.mdAndUp && { text: 'File Size', value: 'fileSize', width: 110 },
         this.$vuetify.breakpoint.mdAndUp && { text: 'File Size', value: 'fileSize', width: 110 },
-        this.$vuetify.breakpoint.mdAndUp && { text: 'Added', value: 'createdAt', width: 150 },
+        this.$vuetify.breakpoint.mdAndUp && { text: 'Added', value: 'createdAt', width: 175 },
         this.$vuetify.breakpoint.smAndUp && { text: 'Actions', value: '', width: 40, sortable: false, align:'right' }
         this.$vuetify.breakpoint.smAndUp && { text: 'Actions', value: '', width: 40, sortable: false, align:'right' }
       ])
       ])
     },
     },
@@ -242,19 +277,17 @@ export default {
         throw new TypeError('Expected a number')
         throw new TypeError('Expected a number')
       }
       }
 
 
-      var exponent
-      var unit
-      var neg = num < 0
-      var units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+      let exponent
+      let unit
+      let neg = num < 0
+      let units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
 
 
       if (neg) {
       if (neg) {
         num = -num
         num = -num
       }
       }
-
       if (num < 1) {
       if (num < 1) {
         return (neg ? '-' : '') + num + ' B'
         return (neg ? '-' : '') + num + ' B'
       }
       }
-
       exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), units.length - 1)
       exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), units.length - 1)
       num = (num / Math.pow(1000, exponent)).toFixed(2) * 1
       num = (num / Math.pow(1000, exponent)).toFixed(2) * 1
       unit = units[exponent]
       unit = units[exponent]
@@ -263,12 +296,22 @@ export default {
     }
     }
   },
   },
   methods: {
   methods: {
+    async refresh() {
+      await this.$apollo.queries.assets.refetch()
+      this.$store.commit('showNotification', {
+          message: 'List of assets refreshed successfully.',
+          style: 'success',
+          icon: 'check'
+        })
+    },
     insert () {
     insert () {
       const asset = _.find(this.assets, ['id', this.currentFileId])
       const asset = _.find(this.assets, ['id', this.currentFileId])
+      const assetPath = this.folderTree.map(f => f.slug).join('/')
       this.$root.$emit('editorInsert', {
       this.$root.$emit('editorInsert', {
         kind: asset.kind,
         kind: asset.kind,
-        path: `/${asset.filename}`,
-        text: asset.filename
+        path: this.currentFolderId > 0 ? `/${assetPath}/${asset.filename}` : `/${asset.filename}`,
+        text: asset.filename,
+        align: this.imageAlignment
       })
       })
       this.activeModal = ''
       this.activeModal = ''
     },
     },
@@ -286,7 +329,7 @@ export default {
       }
       }
       for (let file of files) {
       for (let file of files) {
         file.setMetadata({
         file.setMetadata({
-          path: 'test'
+          folderId: this.currentFolderId
         })
         })
       }
       }
       await this.$refs.pond.processFiles()
       await this.$refs.pond.processFiles()
@@ -305,15 +348,68 @@ export default {
 
 
       await this.$apollo.queries.assets.refetch()
       await this.$apollo.queries.assets.refetch()
     },
     },
+    downFolder(folder) {
+      this.folderTree.push(folder)
+      this.currentFolderId = folder.id
+      this.currentFileId = null
+    },
+    upFolder() {
+      this.folderTree.pop()
+      const parentFolder = _.last(this.folderTree)
+      this.currentFolderId = parentFolder ? parentFolder.id : 0
+      this.currentFileId = null
+    },
     async createFolder() {
     async createFolder() {
-
+      this.$store.commit(`loadingStart`, 'editor-media-createfolder')
+      this.newFolderLoading = true
+      try {
+        const resp = await this.$apollo.mutate({
+          mutation: createAssetFolderMutation,
+          variables: {
+            parentFolderId: this.currentFolderId,
+            slug: this.newFolderName
+          }
+        })
+        if (_.get(resp, 'data.assets.createFolder.responseResult.succeeded', false)) {
+          await this.$apollo.queries.folders.refetch()
+          this.$store.commit('showNotification', {
+            message: 'Asset folder created successfully.',
+            style: 'success',
+            icon: 'check'
+          })
+          this.newFolderDialog = false
+          this.newFolderName = ''
+        } else {
+          this.$store.commit('pushGraphError', new Error(_.get(resp, 'data.assets.createFolder.responseResult.message')))
+        }
+      } catch (err) {
+        this.$store.commit('pushGraphError', err)
+      }
+      this.newFolderLoading = false
+      this.$store.commit(`loadingStop`, 'editor-media-createfolder')
     }
     }
   },
   },
   apollo: {
   apollo: {
+    folders: {
+      query: listFolderAssetQuery,
+      variables() {
+        return {
+          parentFolderId: this.currentFolderId
+        }
+      },
+      fetchPolicy: 'network-only',
+      update: (data) => data.assets.folders,
+      watchLoading (isLoading) {
+        this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'editor-media-folders-list-refresh')
+      }
+    },
     assets: {
     assets: {
       query: listAssetQuery,
       query: listAssetQuery,
-      variables: {
-        kind: 'ALL'
+      variables() {
+        return {
+          folderId: this.currentFolderId,
+          kind: 'ALL'
+        }
       },
       },
       throttle: 1000,
       throttle: 1000,
       fetchPolicy: 'network-only',
       fetchPolicy: 'network-only',

+ 22 - 1
client/components/editor/markdown/help.vue

@@ -97,7 +97,17 @@
                         li Unordered List Item 1
                         li Unordered List Item 1
                         li Unordered List Item 2
                         li Unordered List Item 2
                         li Unordered List Item 3
                         li Unordered List Item 3
-
+              .body-2.mt-3 Images
+              v-layout(row)
+                v-flex(xs6)
+                  v-card.editor-markdown-help-source(flat)
+                    v-card-text
+                      div ![Caption Text](/path/to/image.jpg)
+                v-icon chevron_right
+                v-flex(xs6)
+                  v-card.editor-markdown-help-result(flat)
+                    v-card-text
+                      img(src='https://via.placeholder.com/150x50.png')
         v-flex(xs12, lg6, xl4)
         v-flex(xs12, lg6, xl4)
           v-card.radius-7.animated.fadeInUp.wait-p1s(light)
           v-card.radius-7.animated.fadeInUp.wait-p1s(light)
             v-card-text
             v-card-text
@@ -105,6 +115,17 @@
                 v-toolbar.radius-7(color='teal lighten-5', dense, flat, height='44')
                 v-toolbar.radius-7(color='teal lighten-5', dense, flat, height='44')
                   v-icon.mr-3(color='teal') info
                   v-icon.mr-3(color='teal') info
                   .body-2.teal--text Markdown Reference (continued)
                   .body-2.teal--text Markdown Reference (continued)
+              .body-2.mt-3 Links
+              v-layout(row)
+                v-flex(xs6)
+                  v-card.editor-markdown-help-source(flat)
+                    v-card-text
+                      div [Link Text](https://wiki.js.org)
+                v-icon chevron_right
+                v-flex(xs6)
+                  v-card.editor-markdown-help-result(flat)
+                    v-card-text
+                      .caption: a(href='https://wiki.js.org', target='_blank') Link Text
               .body-2.mt-3 Superscript
               .body-2.mt-3 Superscript
               v-layout(row)
               v-layout(row)
                 v-flex(xs6)
                 v-flex(xs6)

+ 12 - 0
client/graph/editor/editor-media-mutation-folder-create.gql

@@ -0,0 +1,12 @@
+mutation ($parentFolderId: Int!, $slug: String!) {
+  assets {
+    createFolder(parentFolderId:$parentFolderId, slug: $slug) {
+      responseResult {
+        succeeded
+        errorCode
+        slug
+        message
+      }
+    }
+  }
+}

+ 9 - 0
client/graph/editor/editor-media-query-folder-list.gql

@@ -0,0 +1,9 @@
+query ($parentFolderId: Int!) {
+  assets {
+    folders(parentFolderId:$parentFolderId) {
+      id
+      name
+      slug
+    }
+  }
+}

+ 2 - 2
client/graph/editor/editor-media-query-list.gql

@@ -1,6 +1,6 @@
-query ($root: String, $kind: AssetKind!) {
+query ($folderId: Int!, $kind: AssetKind!) {
   assets {
   assets {
-    list(root:$root, kind: $kind) {
+    list(folderId:$folderId, kind: $kind) {
       id
       id
       filename
       filename
       ext
       ext

+ 4 - 0
client/scss/components/v-btn.scss

@@ -52,3 +52,7 @@
     transform: scale(.7) rotateX(-180deg);
     transform: scale(.7) rotateX(-180deg);
   }
   }
 }
 }
+
+.btn-normalcase {
+  text-transform: none;
+}

+ 28 - 1
client/themes/default/scss/app.scss

@@ -3,6 +3,7 @@
 .contents {
 .contents {
   color: mc('grey', '800');
   color: mc('grey', '800');
   padding-bottom: 50px;
   padding-bottom: 50px;
+  position: relative;
 
 
   @at-root .theme--dark & {
   @at-root .theme--dark & {
     color: mc('grey', '300');
     color: mc('grey', '300');
@@ -309,7 +310,8 @@
         }
         }
 
 
         &::before {
         &::before {
-          color: mc('grey', '400');
+          content: '';
+          display: none;
         }
         }
 
 
         @at-root .theme--dark & {
         @at-root .theme--dark & {
@@ -457,4 +459,29 @@
     }
     }
   }
   }
 
 
+  // ---------------------------------
+  // IMAGES
+  // ---------------------------------
+
+  img {
+    &.align-left {
+      float: left;
+      margin: 0 1rem 1rem 0;
+    }
+    &.align-right {
+      float: right;
+      margin: 0 0 1rem 1rem;
+    }
+    &.align-center {
+      display: block;
+      max-width: 100%;
+      margin: auto;
+    }
+    &.align-abstopright {
+      position: absolute;
+      top: -90px;
+      right: 1rem;
+    }
+  }
+
 }
 }

+ 27 - 4
server/controllers/upload.js

@@ -41,11 +41,17 @@ router.post('/u', multer({
     })
     })
   }
   }
 
 
-  let folderPath = ''
+  // Get folder Id
+  let folderId = null
   try {
   try {
     const folderRaw = _.get(req, 'body.mediaUpload', false)
     const folderRaw = _.get(req, 'body.mediaUpload', false)
     if (folderRaw) {
     if (folderRaw) {
-      folderPath = _.get(JSON.parse(folderRaw), 'path', false)
+      folderId = _.get(JSON.parse(folderRaw), 'folderId', null)
+      if (folderId === 0) {
+        folderId = null
+      }
+    } else {
+      throw new Error('Missing File Metadata')
     }
     }
   } catch (err) {
   } catch (err) {
     return res.status(400).json({
     return res.status(400).json({
@@ -54,17 +60,34 @@ router.post('/u', multer({
     })
     })
   }
   }
 
 
-  if (!WIKI.auth.checkAccess(req.user, ['write:assets'], { path: `${folderPath}/${fileMeta.originalname}`})) {
+  // Build folder hierarchy
+  let hierarchy = []
+  if (folderId) {
+    try {
+      hierarchy = await WIKI.models.assetFolders.getHierarchy(folderId)
+    } catch (err) {
+      return res.status(400).json({
+        succeeded: false,
+        message: 'Failed to fetch folder hierarchy.'
+      })
+    }
+  }
+
+  // Check if user can upload at path
+  const assetPath = (folderId) ? opts.hierarchy.map(h => h.slug).join('/') + `/${fileMeta.originalname}` : fileMeta.originalname
+  if (!WIKI.auth.checkAccess(req.user, ['write:assets'], { path: assetPath })) {
     return res.status(403).json({
     return res.status(403).json({
       succeeded: false,
       succeeded: false,
       message: 'You are not authorized to upload files to this folder.'
       message: 'You are not authorized to upload files to this folder.'
     })
     })
   }
   }
 
 
+  // Process upload file
   await WIKI.models.assets.upload({
   await WIKI.models.assets.upload({
     ...fileMeta,
     ...fileMeta,
     originalname: sanitize(fileMeta.originalname).toLowerCase(),
     originalname: sanitize(fileMeta.originalname).toLowerCase(),
-    folder: folderPath,
+    folderId: folderId,
+    hierarchy,
     userId: req.user.id
     userId: req.user.id
   })
   })
   res.send('ok')
   res.send('ok')

+ 34 - 3
server/graph/resolvers/asset.js

@@ -1,8 +1,8 @@
+const sanitize = require('sanitize-filename')
+const graphHelper = require('../../helpers/graph')
 
 
 /* global WIKI */
 /* global WIKI */
 
 
-const gql = require('graphql')
-
 module.exports = {
 module.exports = {
   Query: {
   Query: {
     async assets() { return {} }
     async assets() { return {} }
@@ -13,7 +13,7 @@ module.exports = {
   AssetQuery: {
   AssetQuery: {
     async list(obj, args, context) {
     async list(obj, args, context) {
       let cond = {
       let cond = {
-        folderId: null
+        folderId: args.folderId === 0 ? null : args.folderId
       }
       }
       if (args.kind !== 'ALL') {
       if (args.kind !== 'ALL') {
         cond.kind = args.kind.toLowerCase()
         cond.kind = args.kind.toLowerCase()
@@ -23,9 +23,40 @@ module.exports = {
         ...a,
         ...a,
         kind: a.kind.toUpperCase()
         kind: a.kind.toUpperCase()
       }))
       }))
+    },
+    async folders(obj, args, context) {
+      const result = await WIKI.models.assetFolders.query().where({
+        parentId: args.parentFolderId === 0 ? null : args.parentFolderId
+      })
+      // TODO: Filter by page rules
+      return result
     }
     }
   },
   },
   AssetMutation: {
   AssetMutation: {
+    async createFolder(obj, args, context) {
+      try {
+        const folderSlug = sanitize(args.slug).toLowerCase()
+        const parentFolderId = args.parentFolderId === 0 ? null : args.parentFolderId
+        const result =  await WIKI.models.assetFolders.query().where({
+          parentId: parentFolderId,
+          slug: folderSlug
+        }).first()
+        if (!result) {
+          await WIKI.models.assetFolders.query().insert({
+            slug: folderSlug,
+            name: folderSlug,
+            parentId: parentFolderId
+          })
+          return {
+            responseResult: graphHelper.generateSuccess('Asset Folder has been created successfully.')
+          }
+        } else {
+          throw new WIKI.Error.AssetFolderExists()
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    }
     // deleteFile(obj, args) {
     // deleteFile(obj, args) {
     //   return WIKI.models.File.destroy({
     //   return WIKI.models.File.destroy({
     //     where: {
     //     where: {

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

@@ -16,9 +16,13 @@ extend type Mutation {
 
 
 type AssetQuery {
 type AssetQuery {
   list(
   list(
-    root: String
-    kind: AssetKind
+    folderId: Int!
+    kind: AssetKind!
   ): [AssetItem] @auth(requires: ["manage:system", "read:assets"])
   ): [AssetItem] @auth(requires: ["manage:system", "read:assets"])
+
+  folders(
+    parentFolderId: Int!
+  ): [AssetFolder] @auth(requires: ["manage:system", "read:assets"])
 }
 }
 
 
 # -----------------------------------------------
 # -----------------------------------------------
@@ -26,9 +30,11 @@ type AssetQuery {
 # -----------------------------------------------
 # -----------------------------------------------
 
 
 type AssetMutation {
 type AssetMutation {
-  upload(
-    data: Upload!
-  ): DefaultResponse
+  createFolder(
+    parentFolderId: Int!
+    slug: String!
+    name: String
+  ): DefaultResponse @auth(requires: ["manage:system", "write:assets"])
 }
 }
 
 
 # -----------------------------------------------
 # -----------------------------------------------
@@ -51,6 +57,8 @@ type AssetItem {
 
 
 type AssetFolder {
 type AssetFolder {
   id: Int!
   id: Int!
+  slug: String!
+  name: String
 }
 }
 
 
 enum AssetKind {
 enum AssetKind {

+ 4 - 0
server/helpers/error.js

@@ -1,6 +1,10 @@
 const CustomError = require('custom-error-instance')
 const CustomError = require('custom-error-instance')
 
 
 module.exports = {
 module.exports = {
+  AssetFolderExists: CustomError('AssetFolderExists', {
+    message: 'An asset folder with the same name already exists.',
+    code: 2001
+  }),
   AuthAccountBanned: CustomError('AuthAccountBanned', {
   AuthAccountBanned: CustomError('AuthAccountBanned', {
     message: 'Your account has been disabled.',
     message: 'Your account has been disabled.',
     code: 1016
     code: 1016

+ 8 - 0
server/models/assetFolders.js

@@ -32,4 +32,12 @@ module.exports = class AssetFolder extends Model {
       }
       }
     }
     }
   }
   }
+
+  static async getHierarchy(folderId) {
+    return WIKI.models.knex.withRecursive('ancestors', qb => {
+      qb.select('id', 'name', 'slug', 'parentId').from('assetFolders').where('id', folderId).union(sqb => {
+        sqb.select('a.id', 'a.name', 'a.slug', 'a.parentId').from('assetFolders AS a').join('ancestors', 'ancestors.parentId', 'a.id')
+      })
+    }).select('*').from('ancestors')
+  }
 }
 }

+ 3 - 2
server/models/assets.js

@@ -67,7 +67,8 @@ module.exports = class Asset extends Model {
 
 
   static async upload(opts) {
   static async upload(opts) {
     const fileInfo = path.parse(opts.originalname)
     const fileInfo = path.parse(opts.originalname)
-    const fileHash = assetHelper.generateHash(`${opts.folder}/${opts.originalname}`)
+    const folderPath = opts.hierarchy.map(h => h.slug).join('/')
+    const fileHash = opts.folderId ? assetHelper.generateHash(`${folderPath}/${opts.originalname}`) : assetHelper.generateHash(opts.originalname)
 
 
     // Create asset entry
     // Create asset entry
     const asset = await WIKI.models.assets.query().insert({
     const asset = await WIKI.models.assets.query().insert({
@@ -77,7 +78,7 @@ module.exports = class Asset extends Model {
       kind: _.startsWith(opts.mimetype, 'image/') ? 'image' : 'binary',
       kind: _.startsWith(opts.mimetype, 'image/') ? 'image' : 'binary',
       mime: opts.mimetype,
       mime: opts.mimetype,
       fileSize: opts.size,
       fileSize: opts.size,
-      folderId: null,
+      folderId: opts.folderId,
       authorId: opts.userId
       authorId: opts.userId
     })
     })