Преглед изворни кода

feat: file manager tree view component

Nicolas Giard пре 2 година
родитељ
комит
0cbeec37d6

+ 16 - 0
server/db/migrations/3.0.0.js

@@ -434,6 +434,22 @@ exports.up = async knex => {
         guestUserId: userGuestId
       }
     },
+    {
+      key: 'icons',
+      value: {
+        fa: {
+          isActive: true,
+          config: {
+            version: 6,
+            license: 'free',
+            token: ''
+          }
+        },
+        la: {
+          isActive: true
+        }
+      }
+    },
     {
       key: 'mail',
       value: {

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
ux/public/_assets/icons/fluent-folder-tree.svg


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
ux/public/_assets/icons/fluent-ftp.svg


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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="xGIh33lbYX9pWIYWeZsuka" x1="24" x2="24" y1="6.955" y2="23.167" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#eba600"/><stop offset="1" stop-color="#c28200"/></linearGradient><path fill="url(#xGIh33lbYX9pWIYWeZsuka)" d="M24.414,10.414l-2.536-2.536C21.316,7.316,20.553,7,19.757,7H5C3.895,7,3,7.895,3,9v30	c0,1.105,0.895,2,2,2h38c1.105,0,2-0.895,2-2V13c0-1.105-0.895-2-2-2H25.828C25.298,11,24.789,10.789,24.414,10.414z"/><linearGradient id="xGIh33lbYX9pWIYWeZsukb" x1="24.066" x2="24.066" y1="19.228" y2="33.821" gradientTransform="matrix(-1 0 0 1 48 0)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffd869"/><stop offset="1" stop-color="#fec52b"/></linearGradient><path fill="url(#xGIh33lbYX9pWIYWeZsukb)" d="M24,23l3.854-3.854C27.947,19.053,28.074,19,28.207,19H44.81c1.176,0,2.098,1.01,1.992,2.181	l-1.636,18C45.072,40.211,44.208,41,43.174,41H4.79c-1.019,0-1.875-0.766-1.988-1.779L1.062,23.555C1.029,23.259,1.261,23,1.559,23	H24z"/></svg>

+ 70 - 93
ux/src/components/FileManager.vue

@@ -1,5 +1,5 @@
 <template lang="pug">
-q-layout(view='hHh lpR fFf', container)
+q-layout(view='hHh lpR lFr', container)
   q-header.card-header
     q-toolbar(dark)
       q-icon(name='img:/_assets/icons/fluent-folder.svg', left, size='md')
@@ -25,58 +25,11 @@ q-layout(view='hHh lpR fFf', container)
             )
     q-toolbar(dark)
       q-space
-      q-btn.q-mr-sm(
-        flat
-        dense
-        color='blue-4'
-        :aria-label='t(`common.actions.upload`)'
-        icon='las la-plus-circle'
-        @click=''
-        )
-        q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.upload`)}}
-      q-btn(
-        flat
-        dense
-        color='positive'
-        :aria-label='t(`common.actions.upload`)'
-        icon='las la-cloud-upload-alt'
-        @click=''
-        )
-        q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.upload`)}}
-      q-separator.q-mx-sm(vertical, dark, inset)
-      q-btn.q-mr-sm(
-        flat
-        dense
-        color='blue-grey-4'
-        :aria-label='t(`common.actions.upload`)'
-        icon='las la-check-square'
-        @click=''
-        )
-        q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.upload`)}}
-      q-btn.q-mr-sm(
-        flat
-        dense
-        color='blue-grey-4'
-        :aria-label='t(`common.actions.upload`)'
-        icon='las la-list'
-        @click=''
-        )
-        q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.upload`)}}
-      q-btn(
-        flat
-        dense
-        color='blue-grey-4'
-        :aria-label='t(`common.actions.refresh`)'
-        icon='las la-redo-alt'
-        @click=''
-        :loading='state.loading > 0'
-        )
-        q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.refresh`)}}
-      q-separator.q-mx-sm(vertical, dark, inset)
       q-btn(
         flat
         dense
         color='white'
+        :label='t(`common.actions.close`)'
         :aria-label='t(`common.actions.close`)'
         icon='las la-times'
         @click='close'
@@ -84,16 +37,10 @@ q-layout(view='hHh lpR fFf', container)
         q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.close`)}}
   q-drawer.bg-blue-grey-1(:model-value='true', :width='350')
     .q-px-md.q-pb-sm
-      q-tree.fileman-toc(
-        :nodes='state.tree'
-        icon='las la-caret-right'
-        node-key='key'
-        dense
-        accordion
-        no-connectors
-        v-model:expanded='state.treeExpanded'
-        v-model:selected='state.treeSelected'
-        @click='openFolder'
+      tree(
+        :nodes='state.treeNodes'
+        :roots='state.treeRoots'
+        v-model:selected='state.currentFolderId'
       )
   q-drawer.bg-grey-1(:model-value='true', :width='350', side='right')
     .q-pa-md
@@ -105,8 +52,40 @@ q-layout(view='hHh lpR fFf', container)
       )
   q-page-container
     q-page.bg-white
-      q-bar.bg-blue-grey-1
-        small.text-caption.text-grey-7 / foo / bar
+      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=''
+          )
       q-list.fileman-filelist
         q-item(clickable)
           q-item-section(avatar)
@@ -141,12 +120,18 @@ q-layout(view='hHh lpR fFf', container)
           q-item-section(side)
             .text-caption 2022/01/01
 
+  q-footer
+    q-bar.bg-blue-grey-1
+      small.text-caption.text-grey-7 / foo / bar
 </template>
 
 <script setup>
 import { useI18n } from 'vue-i18n'
 import { reactive } from 'vue'
 
+import NewMenu from './PageNewMenu.vue'
+import Tree from './TreeNav.vue'
+
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 
@@ -164,33 +149,31 @@ const { t } = useI18n()
 const state = reactive({
   loading: 0,
   search: '',
-  tree: [
-    {
-      key: 'root',
-      label: 'Root',
-      children: [
-        {
-          key: '1',
-          label: 'guides',
-          icon: 'las la-folder',
-          children: [
-            {
-              key: '3',
-              label: 'offline',
-              icon: 'las la-folder'
-            }
-          ]
-        },
-        {
-          key: '2',
-          label: 'administration',
-          icon: 'las la-folder'
-        }
-      ]
+  currentFolderId: 'boop',
+  treeNodes: {
+    beep: {
+      text: 'Beep',
+      children: ['foo', 'bar']
+    },
+    foo: {
+      text: 'Foo'
+    },
+    bar: {
+      text: 'Bar',
+      children: ['boop']
+    },
+    boop: {
+      text: 'Boop'
+    },
+    bop: {
+      text: 'Bop',
+      children: ['bap']
+    },
+    bap: {
+      text: 'Bap'
     }
-  ],
-  treeExpanded: ['root'],
-  treeSelected: []
+  },
+  treeRoots: ['beep', 'bop']
 })
 
 // METHODS
@@ -215,11 +198,5 @@ function openFolder (node, noder) {
       border-radius: 8px;
     }
   }
-
-  &-toc {
-    &.q-tree--dense .q-tree__node {
-      padding-bottom: 5px;
-    }
-  }
 }
 </style>

+ 14 - 4
ux/src/components/PageNewMenu.vue

@@ -23,10 +23,11 @@ q-menu.translucent-menu(
     q-item(clickable, @click='create(`redirect`)')
       blueprint-icon(icon='advance')
       q-item-section.q-pr-sm New Redirection
-    q-separator.q-my-sm(inset)
-    q-item(clickable, @click='openFileManager')
-      blueprint-icon(icon='add-image')
-      q-item-section.q-pr-sm Upload Media Asset
+    template(v-if='props.hideAssetBtn === false')
+      q-separator.q-my-sm(inset)
+      q-item(clickable, @click='openFileManager')
+        blueprint-icon(icon='add-image')
+        q-item-section.q-pr-sm Upload Media Asset
 </template>
 
 <script setup>
@@ -36,6 +37,15 @@ import { useQuasar } from 'quasar'
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 
+// PROPS
+
+const props = defineProps({
+  hideAssetBtn: {
+    type: Boolean,
+    default: false
+  }
+})
+
 // QUASAR
 
 const $q = useQuasar()

+ 83 - 0
ux/src/components/TreeLevel.vue

@@ -0,0 +1,83 @@
+<template lang="pug">
+ul.treeview-level
+  //- ROOT NODE
+  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
+      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)
+              q-item-section(side)
+                q-icon(name='las la-plus-circle', color='primary')
+              q-item-section New Folder
+  //- NORMAL NODES
+  tree-node(
+    v-for='node of level'
+    :key='node.id'
+    :node='node'
+    :depth='props.depth'
+    :parent-id='props.parentId'
+  )
+</template>
+
+<script setup>
+import { computed, inject } from 'vue'
+
+import TreeNode from './TreeNode.vue'
+
+// PROPS
+
+const props = defineProps({
+  depth: {
+    required: true,
+    type: Number
+  },
+  parentId: {
+    type: String,
+    default: null
+  }
+})
+
+// INJECT
+
+const roots = inject('roots', [])
+const nodes = inject('nodes')
+const selection = inject('selection')
+
+// COMPUTED
+
+const level = computed(() => {
+  const items = []
+  if (!props.parentId) {
+    for (const root of roots) {
+      items.push({
+        id: root,
+        ...nodes[root]
+      })
+    }
+  } else {
+    for (const node of nodes[props.parentId].children) {
+      items.push({
+        id: node,
+        ...nodes[node]
+      })
+    }
+  }
+  return items
+})
+
+// METHODS
+
+function setRoot () {
+  selection.value = null
+}
+
+</script>

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

@@ -1,21 +1,135 @@
 <template lang="pug">
-.treenav
-
+.treeview
+  tree-level(
+    :depth='0'
+    :parent-id='null'
+  )
 </template>
 
 <script setup>
+import { computed, onMounted, provide, reactive } from 'vue'
+import { findKey } from 'lodash-es'
+
+import TreeLevel from './TreeLevel.vue'
 
 // PROPS
 
-defineProps({
+const props = defineProps({
   nodes: {
+    type: Object,
+    default: () => ({})
+  },
+  roots: {
     type: Array,
     default: () => []
+  },
+  selected: {
+    type: String,
+    default: null
   }
 })
 
 // EMITS
 
-const emits = defineEmits(['selected'])
+const emit = defineEmits(['update:selected'])
+
+// DATA
+
+const state = reactive({
+  opened: {}
+})
+
+// COMPOUTED
+
+const selection = computed({
+  get () {
+    return props.selected
+  },
+  set (val) {
+    emit('update:selected', val)
+  }
+})
+
+// METHODS
+
+// PROVIDE
+
+provide('roots', props.roots)
+provide('nodes', props.nodes)
+provide('opened', state.opened)
+provide('selection', selection)
+
+// MOUNTED
+
+onMounted(() => {
+  if (props.selected) {
+    let foundRoot = false
+    let currentId = props.selected
+    while (!foundRoot) {
+      const parentId = findKey(props.nodes, n => n.children?.includes(currentId))
+      if (parentId) {
+        state.opened[parentId] = true
+        currentId = parentId
+      } else {
+        foundRoot = true
+      }
+    }
+    state.opened[props.selected] = true
+  }
+})
 
 </script>
+
+<style lang="scss">
+.treeview {
+  &-level {
+    list-style: none;
+    padding-left: 19px;
+  }
+
+  > .treeview-level {
+    padding-left: 0;
+
+    > .treeview-node {
+      border-left: none;
+
+      > .treeview-label {
+        border-radius: 5px;
+      }
+    }
+  }
+
+  &-node {
+    display: block;
+    border-left: 2px solid rgba(0,0,0,.05);
+  }
+
+  &-label {
+    padding: 4px 8px;
+    border-radius: 0 5px 5px 0;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    transition: background-color .4s ease;
+
+    &:hover, &:focus, &.active {
+      background-color: rgba(0,0,0,.05);
+    }
+
+    > .q-icon {
+      margin-right: 5px;
+    }
+  }
+
+  // Animations
+
+  &-enter-active, &-leave-active {
+    transition: all 0.2s ease;
+  }
+
+  &-enter-from, &-leave-to {
+    transform: translateY(-10px);
+    opacity: 0;
+  }
+}
+</style>

+ 107 - 0
ux/src/components/TreeNode.vue

@@ -0,0 +1,107 @@
+<template lang="pug">
+li.treeview-node
+  //- NODE
+  .treeview-label(@click='toggleNode', :class='{ "active": isActive }')
+    q-icon(:name='icon', size='sm')
+    span {{node.text}}
+    //- RIGHT-CLICK MENU
+    q-menu(
+      touch-position
+      context-menu
+      auto-close
+      transition-show='jump-down'
+      transition-hide='jump-up'
+      @before-show='state.isContextMenuShown = true'
+      @before-hide='state.isContextMenuShown = false'
+      )
+      q-card.q-pa-sm
+        q-list(dense, style='min-width: 150px;')
+          q-item(clickable)
+            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-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
+  //- SUB-LEVEL
+  transition(name='treeview')
+    tree-level(
+      v-if='hasChildren && isOpened'
+      :parent-id='props.node.id'
+      :depth='props.depth + 1'
+    )
+</template>
+
+<script setup>
+import { computed, inject, reactive } from 'vue'
+
+import TreeLevel from './TreeLevel.vue'
+
+// PROPS
+
+const props = defineProps({
+  depth: {
+    type: Number,
+    default: 0
+  },
+  node: {
+    required: true,
+    type: Object
+  },
+  parentId: {
+    type: String,
+    default: null
+  }
+})
+
+// INJECT
+
+const opened = inject('opened')
+const selection = inject('selection')
+
+// DATA
+
+const state = reactive({
+  isContextMenuShown: false
+})
+
+// COMPUTED
+
+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'
+})
+
+const hasChildren = computed(() => {
+  return props.node.children?.length > 0
+})
+const isOpened = computed(() => {
+  return opened[props.node.id]
+})
+const isActive = computed(() => {
+  return state.isContextMenuShown || selection.value === props.node.id
+})
+
+// METHODS
+
+function toggleNode () {
+  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)
+}
+
+</script>

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

@@ -44,7 +44,7 @@ q-page.admin-icons
                 .text-caption.text-red-1 {{ t('admin.icons.warnHint') }}
         q-list(separator)
           q-item(v-for='pack of combinedPacks', :key='pack.key')
-            blueprint-icon(icon='small-icons', :hueRotate='140')
+            blueprint-icon(icon='small-icons', :hueRotate='30')
             q-item-section
               q-item-label: strong {{pack.label}}
               q-item-label(caption, v-if='pack.isMandatory')

Неке датотеке нису приказане због велике количине промена