FileManager.vue 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252
  1. <template lang="pug">
  2. q-layout.fileman(view='hHh lpR lFr', container)
  3. q-header.card-header
  4. q-toolbar(dark)
  5. q-icon(name='img:/_assets/icons/fluent-folder.svg', left, size='md')
  6. span {{t(`fileman.title`)}}
  7. q-toolbar(dark)
  8. q-btn.q-mr-sm.acrylic-btn(
  9. flat
  10. color='white'
  11. :label='commonStore.locale'
  12. :aria-label='commonStore.locale'
  13. style='height: 40px;'
  14. )
  15. locale-selector-menu
  16. q-input(
  17. dark
  18. v-model='state.search'
  19. standout='bg-white text-dark'
  20. dense
  21. ref='searchField'
  22. style='width: 100%;'
  23. :label='t(`fileman.searchFolder`)'
  24. :debounce='500'
  25. )
  26. template(v-slot:prepend)
  27. q-icon(name='las la-search')
  28. template(v-slot:append)
  29. q-icon.cursor-pointer(
  30. name='las la-times'
  31. @click='state.search=``'
  32. v-if='state.search.length > 0'
  33. :color='$q.dark.isActive ? `blue` : `grey-4`'
  34. )
  35. q-toolbar(dark)
  36. q-space
  37. q-btn(
  38. flat
  39. dense
  40. no-caps
  41. color='red-3'
  42. :aria-label='t(`common.actions.close`)'
  43. icon='las la-times'
  44. @click='close'
  45. )
  46. q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.close`)}}
  47. q-drawer.fileman-left(:model-value='true', :width='350')
  48. q-scroll-area(
  49. :thumb-style='thumbStyle'
  50. :bar-style='barStyle'
  51. style='height: 100%;'
  52. )
  53. .q-px-md.q-pb-sm
  54. tree(
  55. ref='treeComp'
  56. :nodes='state.treeNodes'
  57. :roots='state.treeRoots'
  58. v-model:selected='state.currentFolderId'
  59. @lazy-load='treeLazyLoad'
  60. :use-lazy-load='true'
  61. @context-action='treeContextAction'
  62. :display-mode='state.displayMode'
  63. )
  64. q-drawer.fileman-right(:model-value='$q.screen.gt.md', :width='350', side='right')
  65. q-scroll-area(
  66. :thumb-style='thumbStyle'
  67. :bar-style='barStyle'
  68. style='height: 100%;'
  69. )
  70. .q-pa-md
  71. template(v-if='currentFileDetails')
  72. q-img.rounded-borders.q-mb-md(
  73. :src='currentFileDetails.thumbnail'
  74. width='100%'
  75. fit='cover'
  76. :ratio='16/10'
  77. no-spinner
  78. )
  79. .fileman-details-row(
  80. v-for='item of currentFileDetails.items'
  81. )
  82. label {{item.label}}
  83. span {{item.value}}
  84. template(v-if='insertMode')
  85. q-separator.q-my-md
  86. q-btn.full-width(
  87. @click='insertItem()'
  88. :label='t(`common.actions.insert`)'
  89. color='primary'
  90. icon='las la-plus-circle'
  91. push
  92. padding='sm'
  93. )
  94. q-page-container
  95. q-page.fileman-center.column
  96. //- TOOLBAR -----------------------------------------------------
  97. q-toolbar.fileman-toolbar
  98. template(v-if='state.isUploading')
  99. .fileman-progressbar
  100. div(:style='`width: ` + state.uploadPercentage + `%`') {{state.uploadPercentage}}%
  101. q-btn.acrylic-btn.q-ml-sm(
  102. flat
  103. dense
  104. no-caps
  105. color='negative'
  106. :aria-label='t(`common.actions.cancel`)'
  107. icon='las la-square'
  108. @click='uploadCancel'
  109. v-if='state.uploadPercentage < 100'
  110. )
  111. template(v-else)
  112. q-space
  113. q-btn.q-mr-sm(
  114. flat
  115. dense
  116. no-caps
  117. color='grey'
  118. :aria-label='t(`fileman.viewOptions`)'
  119. icon='las la-th-list'
  120. @click=''
  121. )
  122. q-tooltip(anchor='bottom middle', self='top middle') {{t(`fileman.viewOptions`)}}
  123. q-menu(
  124. transition-show='jump-down'
  125. transition-hide='jump-up'
  126. anchor='bottom right'
  127. self='top right'
  128. )
  129. q-card.q-pa-sm
  130. .text-center
  131. small.text-grey {{t(`fileman.viewOptions`)}}
  132. q-list(dense)
  133. q-separator.q-my-sm
  134. q-item(clickable)
  135. q-item-section(side)
  136. q-icon(name='las la-list', color='grey', size='xs')
  137. q-item-section.q-pr-sm Browse using...
  138. q-item-section(side)
  139. q-icon(name='las la-angle-right', color='grey', size='xs')
  140. q-menu(
  141. anchor='top end'
  142. self='top start'
  143. )
  144. q-list.q-pa-sm(dense)
  145. q-item(clickable, @click='state.displayMode = `path`')
  146. q-item-section(side)
  147. q-icon(
  148. :name='state.displayMode === `path` ? `las la-check-circle` : `las la-circle`'
  149. :color='state.displayMode === `path` ? `positive` : `grey`'
  150. size='xs'
  151. )
  152. q-item-section.q-pr-sm Browse Using Paths
  153. q-item(clickable, @click='state.displayMode = `title`')
  154. q-item-section(side)
  155. q-icon(
  156. :name='state.displayMode === `title` ? `las la-check-circle` : `las la-circle`'
  157. :color='state.displayMode === `title` ? `positive` : `grey`'
  158. size='xs'
  159. )
  160. q-item-section.q-pr-sm Browse Using Titles
  161. q-item(clickable, @click='state.isCompact = !state.isCompact')
  162. q-item-section(side)
  163. q-icon(
  164. :name='state.isCompact ? `las la-check-square` : `las la-stop`'
  165. :color='state.isCompact ? `positive` : `grey`'
  166. size='xs'
  167. )
  168. q-item-section.q-pr-sm Compact List
  169. q-item(clickable, @click='state.shouldShowFolders = !state.shouldShowFolders')
  170. q-item-section(side)
  171. q-icon(
  172. :name='state.shouldShowFolders ? `las la-check-square` : `las la-stop`'
  173. :color='state.shouldShowFolders ? `positive` : `grey`'
  174. size='xs'
  175. )
  176. q-item-section.q-pr-sm Show Folders
  177. q-btn.q-mr-sm(
  178. flat
  179. dense
  180. no-caps
  181. color='grey'
  182. :aria-label='t(`common.actions.refresh`)'
  183. icon='las la-redo-alt'
  184. @click='reloadFolder(state.currentFolderId)'
  185. )
  186. q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.refresh`)}}
  187. q-separator.q-mr-sm(inset, vertical)
  188. q-btn.q-mr-sm(
  189. flat
  190. dense
  191. no-caps
  192. color='blue'
  193. :label='t(`common.actions.new`)'
  194. :aria-label='t(`common.actions.new`)'
  195. icon='las la-plus-circle'
  196. @click=''
  197. )
  198. new-menu(
  199. :hide-asset-btn='true'
  200. :show-new-folder='true'
  201. @new-folder='() => newFolder(state.currentFolderId)'
  202. @new-page='() => close()'
  203. :base-path='folderPath'
  204. )
  205. q-btn(
  206. flat
  207. dense
  208. no-caps
  209. color='positive'
  210. :label='t(`common.actions.upload`)'
  211. :aria-label='t(`common.actions.upload`)'
  212. icon='las la-cloud-upload-alt'
  213. @click='uploadFile'
  214. )
  215. .row(style='flex: 1 1 100%;')
  216. .col
  217. q-scroll-area(
  218. :thumb-style='thumbStyle'
  219. :bar-style='barStyle'
  220. style='height: 100%;'
  221. )
  222. .fileman-loadinglist(v-if='state.fileListLoading')
  223. q-spinner.q-mr-sm(color='primary', size='64px', :thickness='1')
  224. span.text-primary Fetching folder contents...
  225. .fileman-emptylist(v-else-if='files.length < 1')
  226. img(src='/_assets/icons/carbon-copy-empty-box.svg')
  227. span This folder is empty.
  228. q-list.fileman-filelist(
  229. v-else
  230. :class='state.isCompact && `is-compact`'
  231. )
  232. q-item(
  233. v-for='item of files'
  234. :key='item.id'
  235. clickable
  236. active-class='active'
  237. :active='item.id === state.currentFileId'
  238. @click.native='selectItem(item)'
  239. @dblclick.native='doubleClickItem(item)'
  240. )
  241. q-item-section.fileman-filelist-icon(avatar)
  242. q-icon(:name='item.icon', :size='state.isCompact ? `md` : `xl`')
  243. q-item-section.fileman-filelist-label
  244. q-item-label {{usePathTitle ? item.fileName : item.title}}
  245. q-item-label(caption, v-if='!state.isCompact') {{item.caption}}
  246. q-item-section.fileman-filelist-side(side, v-if='item.side')
  247. .text-caption {{item.side}}
  248. //- RIGHT-CLICK MENU
  249. q-menu.translucent-menu(
  250. touch-position
  251. context-menu
  252. auto-close
  253. transition-show='jump-down'
  254. transition-hide='jump-up'
  255. )
  256. q-card.q-pa-sm
  257. q-list(dense, style='min-width: 150px;')
  258. q-item(clickable, v-if='insertMode && item.type !== `folder`', @click='insertItem(item)')
  259. q-item-section(side)
  260. q-icon(name='las la-plus-circle', color='primary')
  261. q-item-section {{ t(`common.actions.insert`) }}
  262. q-item(clickable, v-if='item.type === `page`', @click='editItem(item)')
  263. q-item-section(side)
  264. q-icon(name='las la-edit', color='orange')
  265. q-item-section {{ t(`common.actions.edit`) }}
  266. q-item(clickable, v-if='item.type === `page`', @click='rerenderPage(item)')
  267. q-item-section(side)
  268. q-icon(name='las la-magic', color='orange')
  269. q-item-section {{ t(`common.actions.rerender`) }}
  270. q-item(clickable, v-if='item.type !== `folder`', @click='openItem(item)')
  271. q-item-section(side)
  272. q-icon(name='las la-eye', color='primary')
  273. q-item-section {{ t(`common.actions.view`) }}
  274. template(v-if='item.type === `asset` && item.imageEdit')
  275. q-item(clickable)
  276. q-item-section(side)
  277. q-icon(name='las la-edit', color='orange')
  278. q-item-section Edit Image...
  279. q-item(clickable)
  280. q-item-section(side)
  281. q-icon(name='las la-crop', color='orange')
  282. q-item-section Resize Image...
  283. q-item(clickable, v-if='item.type !== `folder`', @click='copyItemURL(item)')
  284. q-item-section(side)
  285. q-icon(name='las la-clipboard', color='primary')
  286. q-item-section {{ t(`common.actions.copyURL`) }}
  287. q-item(clickable, v-if='item.type !== `folder`', @click='downloadItem(item)')
  288. q-item-section(side)
  289. q-icon(name='las la-download', color='primary')
  290. q-item-section {{ t(`common.actions.download`) }}
  291. q-item(clickable)
  292. q-item-section(side)
  293. q-icon(name='las la-copy', color='teal')
  294. q-item-section Duplicate...
  295. q-item(clickable, @click='renameItem(item)')
  296. q-item-section(side)
  297. q-icon(name='las la-redo', color='teal')
  298. q-item-section Rename...
  299. q-item(clickable)
  300. q-item-section(side)
  301. q-icon(name='las la-arrow-right', color='teal')
  302. q-item-section Move to...
  303. q-item(clickable, @click='delItem(item)')
  304. q-item-section(side)
  305. q-icon(name='las la-trash-alt', color='negative')
  306. q-item-section.text-negative {{ t(`common.actions.delete`) }}
  307. q-footer
  308. q-bar.fileman-path
  309. small.text-caption.text-grey-7 {{folderPath}}
  310. input(
  311. type='file'
  312. ref='fileIpt'
  313. multiple
  314. @change='uploadNewFiles'
  315. style='display: none'
  316. )
  317. </template>
  318. <script setup>
  319. import { useI18n } from 'vue-i18n'
  320. import { computed, defineAsyncComponent, nextTick, onMounted, reactive, ref, toRaw, watch } from 'vue'
  321. import { filesize } from 'filesize'
  322. import { useQuasar } from 'quasar'
  323. import { DateTime } from 'luxon'
  324. import { cloneDeep, dropRight, find, findKey, initial, last, nth } from 'lodash-es'
  325. import { useRoute, useRouter } from 'vue-router'
  326. import gql from 'graphql-tag'
  327. import Fuse from 'fuse.js/dist/fuse.basic.esm'
  328. import NewMenu from './PageNewMenu.vue'
  329. import Tree from './TreeNav.vue'
  330. import fileTypes from '../helpers/fileTypes'
  331. import { useCommonStore } from 'src/stores/common'
  332. import { usePageStore } from 'src/stores/page'
  333. import { useSiteStore } from 'src/stores/site'
  334. import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
  335. import FolderDeleteDialog from 'src/components/FolderDeleteDialog.vue'
  336. import FolderRenameDialog from 'src/components/FolderRenameDialog.vue'
  337. import LocaleSelectorMenu from 'src/components/LocaleSelectorMenu.vue'
  338. // QUASAR
  339. const $q = useQuasar()
  340. // STORES
  341. const commonStore = useCommonStore()
  342. const pageStore = usePageStore()
  343. const siteStore = useSiteStore()
  344. // ROUTER
  345. const router = useRouter()
  346. const route = useRoute()
  347. // I18N
  348. const { t } = useI18n()
  349. // DATA
  350. const state = reactive({
  351. loading: 0,
  352. isFetching: false,
  353. search: '',
  354. currentFolderId: null,
  355. currentFileId: null,
  356. treeNodes: {},
  357. treeRoots: [],
  358. displayMode: 'title',
  359. isCompact: false,
  360. shouldShowFolders: true,
  361. isUploading: false,
  362. shouldCancelUpload: false,
  363. uploadPercentage: 0,
  364. fileList: [],
  365. fileListLoading: false
  366. })
  367. const thumbStyle = {
  368. right: '2px',
  369. borderRadius: '5px',
  370. backgroundColor: '#000',
  371. width: '5px',
  372. opacity: 0.15
  373. }
  374. const barStyle = {
  375. backgroundColor: '#FAFAFA',
  376. width: '9px',
  377. opacity: 1
  378. }
  379. // REFS
  380. const fileIpt = ref(null)
  381. const treeComp = ref(null)
  382. // COMPUTED
  383. const insertMode = computed(() => siteStore.overlayOpts?.insertMode ?? false)
  384. const folderPath = computed(() => {
  385. if (!state.currentFolderId) {
  386. return '/'
  387. } else {
  388. const folderNode = state.treeNodes[state.currentFolderId] ?? {}
  389. return folderNode.folderPath ? `/${folderNode.folderPath}/${folderNode.fileName}/` : `/${folderNode.fileName}/`
  390. }
  391. })
  392. const usePathTitle = computed(() => state.displayMode === 'path')
  393. const filteredFiles = computed(() => {
  394. if (state.search) {
  395. const fuse = new Fuse(state.fileList, {
  396. keys: [
  397. 'title',
  398. 'fileName'
  399. ]
  400. })
  401. return fuse.search(state.search).map(n => n.item)
  402. } else {
  403. return state.fileList
  404. }
  405. })
  406. const files = computed(() => {
  407. return filteredFiles.value.filter(f => {
  408. // -> Show Folders Filter
  409. if (f.type === 'folder' && !state.shouldShowFolders) {
  410. return false
  411. }
  412. return true
  413. }).map(f => {
  414. switch (f.type) {
  415. case 'folder': {
  416. f.icon = fileTypes.folder.icon
  417. f.caption = t('fileman.folderChildrenCount', { count: f.children }, f.children)
  418. break
  419. }
  420. case 'page': {
  421. f.icon = fileTypes.page.icon
  422. f.caption = t(`fileman.${f.pageType}PageType`)
  423. break
  424. }
  425. case 'asset': {
  426. f.icon = fileTypes[f.fileExt]?.icon ?? ''
  427. f.side = filesize(f.fileSize, { round: 0 })
  428. f.imageEdit = fileTypes[f.fileExt]?.imageEdit
  429. if (fileTypes[f.fileExt]) {
  430. f.caption = t(`fileman.${f.fileExt}FileType`)
  431. } else {
  432. f.caption = t('fileman.unknownFileType', { type: f.fileExt.toUpperCase() })
  433. }
  434. break
  435. }
  436. }
  437. return f
  438. })
  439. })
  440. const currentFileDetails = computed(() => {
  441. if (state.currentFileId) {
  442. const item = find(state.fileList, ['id', state.currentFileId])
  443. if (item.type === 'folder') {
  444. return null
  445. }
  446. const items = [
  447. {
  448. label: t('fileman.detailsTitle'),
  449. value: item.title
  450. }
  451. ]
  452. let thumbnail = ''
  453. switch (item.type) {
  454. case 'page': {
  455. thumbnail = '/_assets/illustrations/fileman-page.svg'
  456. items.push({
  457. label: t('fileman.detailsPageType'),
  458. value: t(`fileman.${item.pageType}PageType`)
  459. })
  460. items.push({
  461. label: t('fileman.detailsPageEditor'),
  462. value: item.pageType
  463. })
  464. items.push({
  465. label: t('fileman.detailsPageUpdated'),
  466. value: DateTime.fromISO(item.updatedAt).toFormat('yyyy-MM-dd \'at\' h:mm ZZZZ')
  467. })
  468. items.push({
  469. label: t('fileman.detailsPageCreated'),
  470. value: DateTime.fromISO(item.updatedAt).toFormat('yyyy-MM-dd \'at\' h:mm ZZZZ')
  471. })
  472. break
  473. }
  474. case 'asset': {
  475. thumbnail = `/_thumb/${item.id}.webp`
  476. items.push({
  477. label: t('fileman.detailsAssetType'),
  478. value: fileTypes[item.fileExt] ? t(`fileman.${item.fileExt}FileType`) : t('fileman.unknownFileType', { type: item.fileExt.toUpperCase() })
  479. })
  480. items.push({
  481. label: t('fileman.detailsAssetSize'),
  482. value: filesize(item.fileSize)
  483. })
  484. break
  485. }
  486. }
  487. return {
  488. thumbnail,
  489. items
  490. }
  491. } else {
  492. return null
  493. }
  494. })
  495. // WATCHERS
  496. watch(() => state.currentFolderId, async (newValue) => {
  497. await loadTree({ parentId: newValue })
  498. })
  499. // METHODS
  500. function close () {
  501. siteStore.overlay = null
  502. }
  503. function insertItem (item) {
  504. if (!item) {
  505. item = find(state.fileList, ['id', state.currentFileId])
  506. }
  507. EVENT_BUS.emit('insertAsset', toRaw(item))
  508. close()
  509. }
  510. async function treeLazyLoad (nodeId, isCurrent, { done, fail }) {
  511. await loadTree({ parentId: nodeId, types: isCurrent ? null : ['folder'] })
  512. done()
  513. }
  514. async function loadTree ({ parentId = null, parentPath = null, types, initLoad = false }) {
  515. if (state.isFetching) { return }
  516. state.isFetching = true
  517. if (!parentId) {
  518. parentId = null
  519. }
  520. if (parentId === state.currentFolderId) {
  521. state.fileListLoading = true
  522. state.currentFileId = null
  523. state.fileList = []
  524. }
  525. try {
  526. const resp = await APOLLO_CLIENT.query({
  527. query: gql`
  528. query loadTree (
  529. $siteId: UUID!
  530. $parentId: UUID
  531. $parentPath: String
  532. $types: [TreeItemType]
  533. $includeAncestors: Boolean
  534. $includeRootFolders: Boolean
  535. ) {
  536. tree (
  537. siteId: $siteId
  538. parentId: $parentId
  539. parentPath: $parentPath
  540. types: $types
  541. includeAncestors: $includeAncestors
  542. includeRootFolders: $includeRootFolders
  543. ) {
  544. __typename
  545. id
  546. folderPath
  547. fileName
  548. title
  549. ... on TreeItemFolder {
  550. childrenCount
  551. isAncestor
  552. }
  553. ... on TreeItemPage {
  554. createdAt
  555. updatedAt
  556. editor
  557. }
  558. ... on TreeItemAsset {
  559. createdAt
  560. updatedAt
  561. fileSize
  562. fileExt
  563. mimeType
  564. }
  565. }
  566. }
  567. `,
  568. variables: {
  569. siteId: siteStore.id,
  570. parentId,
  571. parentPath,
  572. types,
  573. includeAncestors: initLoad,
  574. includeRootFolders: initLoad
  575. },
  576. fetchPolicy: 'network-only'
  577. })
  578. const items = cloneDeep(resp?.data?.tree)
  579. if (items?.length > 0) {
  580. const newTreeRoots = []
  581. for (const item of items) {
  582. switch (item.__typename) {
  583. case 'TreeItemFolder': {
  584. // -> Tree Nodes
  585. state.treeNodes[item.id] = {
  586. folderPath: item.folderPath,
  587. fileName: item.fileName,
  588. title: item.title,
  589. children: state.treeNodes[item.id]?.children ?? []
  590. }
  591. // -> Set Ancestors / Tree Roots
  592. if (item.folderPath) {
  593. let folderParentId = parentId
  594. if (!folderParentId) {
  595. const parentFolderParts = item.folderPath.split('/')
  596. const parentFolder = find(items, { folderPath: parentFolderParts.length > 1 ? initial(parentFolderParts).join('/') : '', fileName: last(parentFolderParts) })
  597. folderParentId = parentFolder.id
  598. }
  599. if (item.id !== folderParentId && !state.treeNodes[folderParentId]?.children?.includes(item.id)) {
  600. state.treeNodes[folderParentId]?.children?.push(item.id)
  601. }
  602. } else {
  603. newTreeRoots.push(item.id)
  604. }
  605. // -> File List
  606. if (parentId === state.currentFolderId && !item.isAncestor) {
  607. state.fileList.push({
  608. id: item.id,
  609. type: 'folder',
  610. title: item.title,
  611. fileName: item.fileName,
  612. children: item.childrenCount || 0
  613. })
  614. }
  615. break
  616. }
  617. case 'TreeItemAsset': {
  618. if (parentId === state.currentFolderId) {
  619. state.fileList.push({
  620. id: item.id,
  621. type: 'asset',
  622. title: item.title,
  623. fileExt: item.fileExt,
  624. fileSize: item.fileSize,
  625. mimeType: item.mimeType,
  626. folderPath: item.folderPath,
  627. fileName: item.fileName
  628. })
  629. }
  630. break
  631. }
  632. case 'TreeItemPage': {
  633. if (parentId === state.currentFolderId) {
  634. state.fileList.push({
  635. id: item.id,
  636. type: 'page',
  637. title: item.title,
  638. pageType: 'markdown',
  639. updatedAt: '2022-11-24T18:27:00Z',
  640. folderPath: item.folderPath,
  641. fileName: item.fileName
  642. })
  643. }
  644. break
  645. }
  646. }
  647. }
  648. if (newTreeRoots.length > 0) {
  649. state.treeRoots = newTreeRoots
  650. }
  651. }
  652. } catch (err) {
  653. $q.notify({
  654. type: 'negative',
  655. message: 'Failed to load folder tree.',
  656. caption: err.message
  657. })
  658. }
  659. if (parentId === state.currentFolderId) {
  660. nextTick(() => {
  661. state.fileListLoading = false
  662. })
  663. }
  664. if (parentId) {
  665. treeComp.value.setLoaded(parentId)
  666. }
  667. state.isFetching = false
  668. }
  669. function treeContextAction (nodeId, action) {
  670. switch (action) {
  671. case 'newFolder': {
  672. newFolder(nodeId)
  673. break
  674. }
  675. case 'rename': {
  676. renameFolder(nodeId)
  677. break
  678. }
  679. case 'del': {
  680. delFolder(nodeId)
  681. break
  682. }
  683. }
  684. }
  685. // --------------------------------------
  686. // FOLDER METHODS
  687. // --------------------------------------
  688. function newFolder (parentId) {
  689. $q.dialog({
  690. component: FolderCreateDialog,
  691. componentProps: {
  692. parentId
  693. }
  694. }).onOk(() => {
  695. loadTree({ parentId })
  696. })
  697. }
  698. function renameFolder (folderId) {
  699. $q.dialog({
  700. component: FolderRenameDialog,
  701. componentProps: {
  702. folderId
  703. }
  704. }).onOk(async () => {
  705. treeComp.value.resetLoaded()
  706. // // -> Delete current folder and children from cache
  707. // const fPath = [state.treeNodes[folderId].folderPath, state.treeNodes[folderId].fileName].filter(p => !!p).join('/')
  708. // delete state.treeNodes[folderId]
  709. // for (const [nodeId, node] of Object.entries(state.treeNodes)) {
  710. // if (node.folderPath.startsWith(fPath)) {
  711. // delete state.treeNodes[nodeId]
  712. // }
  713. // }
  714. // -> Reload tree
  715. await loadTree({ parentId: folderId, types: ['folder'], initLoad: true }) // Update tree
  716. // -> Reload current view (in case current folder is included)
  717. await loadTree({ parentId: state.currentFolderId })
  718. })
  719. }
  720. function delFolder (folderId, mustReload = false) {
  721. $q.dialog({
  722. component: FolderDeleteDialog,
  723. componentProps: {
  724. folderId,
  725. folderName: state.treeNodes[folderId].title
  726. }
  727. }).onOk(() => {
  728. for (const nodeId in state.treeNodes) {
  729. if (state.treeNodes[nodeId].children.includes(folderId)) {
  730. state.treeNodes[nodeId].children = state.treeNodes[nodeId].children.filter(c => c !== folderId)
  731. }
  732. }
  733. delete state.treeNodes[folderId]
  734. if (state.treeRoots.includes(folderId)) {
  735. state.treeRoots = state.treeRoots.filter(n => n !== folderId)
  736. }
  737. if (mustReload) {
  738. loadTree({ parentId: state.currentFolderId })
  739. }
  740. })
  741. }
  742. function reloadFolder (folderId) {
  743. loadTree({ parentId: folderId })
  744. treeComp.value.resetLoaded()
  745. }
  746. // PAGE METHODS
  747. // --------------------------------------
  748. function rerenderPage (item) {
  749. $q.dialog({
  750. component: defineAsyncComponent(() => import('src/components/RerenderPageDialog.vue')),
  751. componentProps: {
  752. id: item.id
  753. }
  754. })
  755. }
  756. function delPage (pageId, pageName) {
  757. $q.dialog({
  758. component: defineAsyncComponent(() => import('src/components/PageDeleteDialog.vue')),
  759. componentProps: {
  760. pageId,
  761. pageName
  762. }
  763. }).onOk(() => {
  764. loadTree(state.currentFolderId, null)
  765. })
  766. }
  767. // --------------------------------------
  768. // UPLOAD METHODS
  769. // --------------------------------------
  770. function uploadFile () {
  771. fileIpt.value.click()
  772. }
  773. async function uploadNewFiles () {
  774. if (!fileIpt.value.files?.length) {
  775. return
  776. }
  777. state.isUploading = true
  778. state.uploadPercentage = 0
  779. state.loading++
  780. nextTick(() => {
  781. setTimeout(async () => {
  782. try {
  783. const totalFiles = fileIpt.value.files.length
  784. let idx = 0
  785. for (const fileToUpload of fileIpt.value.files) {
  786. idx++
  787. state.uploadPercentage = totalFiles > 1 ? Math.round(idx / totalFiles * 100) : 90
  788. const resp = await APOLLO_CLIENT.mutate({
  789. context: {
  790. uploadMode: true
  791. },
  792. mutation: gql`
  793. mutation uploadAssets (
  794. $folderId: UUID
  795. $locale: String
  796. $siteId: UUID
  797. $files: [Upload!]!
  798. ) {
  799. uploadAssets (
  800. folderId: $folderId
  801. locale: $locale
  802. siteId: $siteId
  803. files: $files
  804. ) {
  805. operation {
  806. succeeded
  807. message
  808. }
  809. }
  810. }
  811. `,
  812. variables: {
  813. folderId: state.currentFolderId,
  814. siteId: siteStore.id,
  815. locale: 'en', // TODO: use current locale
  816. files: [fileToUpload]
  817. }
  818. })
  819. if (!resp?.data?.uploadAssets?.operation?.succeeded) {
  820. throw new Error(resp?.data?.uploadAssets?.operation?.message || 'An unexpected error occured.')
  821. }
  822. }
  823. state.uploadPercentage = 100
  824. loadTree({ parentId: state.currentFolderId })
  825. $q.notify({
  826. type: 'positive',
  827. message: t('fileman.uploadSuccess')
  828. })
  829. } catch (err) {
  830. $q.notify({
  831. type: 'negative',
  832. message: 'Failed to upload file.',
  833. caption: err.message
  834. })
  835. }
  836. state.loading--
  837. fileIpt.value.value = null
  838. setTimeout(() => {
  839. state.isUploading = false
  840. state.uploadPercentage = 0
  841. }, 1500)
  842. }, 400)
  843. })
  844. }
  845. function uploadCancel () {
  846. state.isUploading = false
  847. state.uploadPercentage = 0
  848. }
  849. // --------------------------------------
  850. // ITEM LIST ACTIONS
  851. // --------------------------------------
  852. function selectItem (item) {
  853. if (item.type === 'folder') {
  854. state.currentFolderId = item.id
  855. treeComp.value.setOpened(item.id)
  856. } else {
  857. state.currentFileId = item.id
  858. }
  859. }
  860. function doubleClickItem (item) {
  861. if (insertMode.value) {
  862. insertItem(item)
  863. } else {
  864. openItem(item)
  865. }
  866. }
  867. function openItem (item) {
  868. switch (item.type) {
  869. case 'folder': {
  870. return
  871. }
  872. case 'page': {
  873. const pagePath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName
  874. router.push(`/${pagePath}`)
  875. close()
  876. break
  877. }
  878. case 'asset': {
  879. // TODO: Open asset
  880. close()
  881. break
  882. }
  883. }
  884. }
  885. async function copyItemURL (item) {
  886. try {
  887. switch (item.type) {
  888. case 'page': {
  889. const pagePath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName
  890. await navigator.clipboard.writeText(`${window.location.origin}/${pagePath}`)
  891. break
  892. }
  893. case 'asset': {
  894. const assetPath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName
  895. await navigator.clipboard.writeText(`${window.location.origin}/${assetPath}`)
  896. break
  897. }
  898. default: {
  899. throw new Error('Invalid Item Type')
  900. }
  901. }
  902. $q.notify({
  903. type: 'positive',
  904. message: t('fileman.copyURLSuccess')
  905. })
  906. } catch (err) {
  907. $q.notify({
  908. type: 'negative',
  909. message: 'Failed to copy URL to clipboard.',
  910. caption: err.message
  911. })
  912. }
  913. }
  914. async function editItem (item) {
  915. router.push(item.folderPath ? `/_edit/${item.folderPath}/${item.fileName}` : `/_edit/${item.fileName}`)
  916. close()
  917. }
  918. function downloadItem (item) {
  919. }
  920. function renameItem (item) {
  921. console.info(item)
  922. switch (item.type) {
  923. case 'folder': {
  924. renameFolder(item.id)
  925. break
  926. }
  927. case 'page': {
  928. // TODO: Rename page
  929. break
  930. }
  931. case 'asset': {
  932. // TODO: Rename asset
  933. break
  934. }
  935. }
  936. }
  937. function delItem (item) {
  938. switch (item.type) {
  939. case 'folder': {
  940. delFolder(item.id, true)
  941. break
  942. }
  943. case 'page': {
  944. delPage(item.id, item.title)
  945. break
  946. }
  947. }
  948. }
  949. // MOUNTED
  950. onMounted(async () => {
  951. const pathParts = pageStore.path.split('/')
  952. const parentPath = initial(pathParts).join('/')
  953. await loadTree({
  954. parentPath,
  955. initLoad: true
  956. })
  957. // -> Open tree up to current folder
  958. const folderFolderPath = dropRight(pathParts, 2).join('/')
  959. const folderFileName = nth(pathParts, -2)
  960. for (const [id, node] of Object.entries(state.treeNodes)) {
  961. if (parentPath.startsWith(node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName)) {
  962. treeComp.value.setOpened(id)
  963. }
  964. }
  965. // -> Switch to current folder (from page path)
  966. const currentNodeId = findKey(state.treeNodes, n => n.folderPath === folderFolderPath && n.fileName === folderFileName)
  967. if (currentNodeId) {
  968. state.currentFolderId = currentNodeId
  969. }
  970. })
  971. </script>
  972. <style lang="scss">
  973. .fileman {
  974. &-left {
  975. @at-root .body--light & {
  976. background-color: $blue-grey-1;
  977. }
  978. @at-root .body--dark & {
  979. background-color: $dark-4;
  980. }
  981. }
  982. &-center {
  983. @at-root .body--light & {
  984. background-color: #FFF;
  985. }
  986. @at-root .body--dark & {
  987. background-color: $dark-6;
  988. }
  989. }
  990. &-right {
  991. @at-root .body--light & {
  992. background-color: $grey-1;
  993. }
  994. @at-root .body--dark & {
  995. background-color: $dark-5;
  996. }
  997. }
  998. &-toolbar {
  999. @at-root .body--light & {
  1000. background-color: $grey-1;
  1001. }
  1002. @at-root .body--dark & {
  1003. background-color: $dark-5;
  1004. }
  1005. }
  1006. &-path {
  1007. @at-root .body--light & {
  1008. background-color: $blue-grey-1 !important;
  1009. }
  1010. @at-root .body--dark & {
  1011. background-color: $dark-4 !important;
  1012. }
  1013. }
  1014. &-main {
  1015. height: 100%;
  1016. }
  1017. &-loadinglist {
  1018. padding: 16px;
  1019. font-style: italic;
  1020. display: flex;
  1021. flex-direction: column;
  1022. justify-content: center;
  1023. align-items: center;
  1024. > span {
  1025. margin-top: 16px;
  1026. }
  1027. }
  1028. &-emptylist {
  1029. padding: 16px;
  1030. font-style: italic;
  1031. font-size: 1.5em;
  1032. font-weight: 300;
  1033. display: flex;
  1034. flex-direction: column;
  1035. justify-content: center;
  1036. align-items: center;
  1037. > img {
  1038. opacity: .25;
  1039. width: 200px;
  1040. }
  1041. @at-root .body--light & {
  1042. color: $grey-6;
  1043. }
  1044. @at-root .body--dark & {
  1045. color: $grey-7;
  1046. > img {
  1047. filter: invert(1);
  1048. }
  1049. }
  1050. }
  1051. &-filelist {
  1052. padding: 8px 12px;
  1053. > .q-item {
  1054. padding: 4px 6px;
  1055. border-radius: 8px;
  1056. &.active {
  1057. background-color: var(--q-primary);
  1058. color: #FFF;
  1059. .fileman-filelist-label .q-item__label--caption {
  1060. color: rgba(255,255,255,.7);
  1061. }
  1062. .fileman-filelist-side .text-caption {
  1063. color: rgba(255,255,255,.7);
  1064. }
  1065. }
  1066. }
  1067. &.is-compact {
  1068. > .q-item {
  1069. padding: 0 6px;
  1070. min-height: 36px;
  1071. }
  1072. .fileman-filelist-icon {
  1073. padding-right: 6px;
  1074. min-width: 0;
  1075. }
  1076. }
  1077. }
  1078. &-details-row {
  1079. display: flex;
  1080. flex-direction: column;
  1081. padding: 5px 0;
  1082. label {
  1083. font-size: .7rem;
  1084. font-weight: 500;
  1085. @at-root .body--light & {
  1086. color: $grey-6;
  1087. }
  1088. @at-root .body--dark & {
  1089. color: $blue-grey-4;
  1090. }
  1091. }
  1092. span {
  1093. font-size: .85rem;
  1094. @at-root .body--light & {
  1095. color: $grey-8;
  1096. }
  1097. @at-root .body--dark & {
  1098. color: $blue-grey-2;
  1099. }
  1100. }
  1101. & + .fileman-details-row {
  1102. margin-top: 5px;
  1103. }
  1104. }
  1105. &-progressbar {
  1106. width: 100%;
  1107. flex: 1;
  1108. height: 12px;
  1109. border-radius: 3px;
  1110. @at-root .body--light & {
  1111. background-color: $blue-grey-2;
  1112. }
  1113. @at-root .body--dark & {
  1114. background-color: $dark-4 !important;
  1115. }
  1116. > div {
  1117. height: 12px;
  1118. background-color: $positive;
  1119. border-radius: 3px 0 0 3px;
  1120. background-image: linear-gradient(
  1121. -45deg,
  1122. rgba(255, 255, 255, 0.3) 25%,
  1123. transparent 25%,
  1124. transparent 50%,
  1125. rgba(255, 255, 255, 0.3) 50%,
  1126. rgba(255, 255, 255, 0.3) 75%,
  1127. transparent 75%,
  1128. transparent
  1129. );
  1130. background-size: 50px 50px;
  1131. background-position: 0 0;
  1132. animation: fileman-progress 2s linear infinite;
  1133. box-shadow: 0 0 5px 0 $positive;
  1134. font-size: 9px;
  1135. letter-spacing: 2px;
  1136. font-weight: 700;
  1137. color: #FFF;
  1138. display: flex;
  1139. justify-content: center;
  1140. align-items: center;
  1141. overflow: hidden;
  1142. transition: all 1s ease;
  1143. }
  1144. }
  1145. }
  1146. @keyframes fileman-progress {
  1147. 0% {
  1148. background-position: 0 0;
  1149. }
  1150. 100% {
  1151. background-position: -50px -50px;
  1152. }
  1153. }
  1154. </style>