FileManager.vue 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287
  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(#prepend)
  27. q-icon(name='las la-search')
  28. template(#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. :key='item.id'
  82. )
  83. label {{ item.label }}
  84. span {{ item.value }}
  85. template(v-if='insertMode')
  86. q-separator.q-my-md
  87. q-btn.full-width(
  88. @click='insertItem()'
  89. :label='t(`common.actions.insert`)'
  90. color='primary'
  91. icon='las la-plus-circle'
  92. push
  93. padding='sm'
  94. )
  95. q-page-container
  96. q-page.fileman-center.column
  97. //- TOOLBAR -----------------------------------------------------
  98. q-toolbar.fileman-toolbar
  99. template(v-if='state.isUploading')
  100. .fileman-progressbar
  101. div(:style='`width: ` + state.uploadPercentage + `%`') {{ state.uploadPercentage }}%
  102. q-btn.acrylic-btn.q-ml-sm(
  103. flat
  104. dense
  105. no-caps
  106. color='negative'
  107. :aria-label='t(`common.actions.cancel`)'
  108. icon='las la-square'
  109. @click='uploadCancel'
  110. v-if='state.uploadPercentage < 100'
  111. )
  112. template(v-else)
  113. q-space
  114. q-btn.q-mr-sm(
  115. flat
  116. dense
  117. no-caps
  118. color='grey'
  119. :aria-label='t(`fileman.viewOptions`)'
  120. icon='las la-th-list'
  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. )
  197. new-menu(
  198. :hide-asset-btn='true'
  199. :show-new-folder='true'
  200. @new-folder='() => newFolder(state.currentFolderId)'
  201. @new-page='() => close()'
  202. :base-path='folderPath'
  203. )
  204. q-btn(
  205. flat
  206. dense
  207. no-caps
  208. color='positive'
  209. :label='t(`common.actions.upload`)'
  210. :aria-label='t(`common.actions.upload`)'
  211. icon='las la-cloud-upload-alt'
  212. @click='uploadFile'
  213. )
  214. .row(style='flex: 1 1 100%;')
  215. .col
  216. q-scroll-area(
  217. :thumb-style='thumbStyle'
  218. :bar-style='barStyle'
  219. style='height: 100%;'
  220. )
  221. .fileman-loadinglist(v-if='state.fileListLoading')
  222. q-spinner.q-mr-sm(color='primary', size='64px', :thickness='1')
  223. span.text-primary Fetching folder contents...
  224. .fileman-emptylist(v-else-if='files.length < 1')
  225. img(src='/_assets/icons/carbon-copy-empty-box.svg')
  226. span This folder is empty.
  227. q-list.fileman-filelist(
  228. v-else
  229. :class='state.isCompact && `is-compact`'
  230. )
  231. q-item(
  232. v-for='item of files'
  233. :key='item.id'
  234. clickable
  235. active-class='active'
  236. :active='item.id === state.currentFileId'
  237. @click='selectItem(item)'
  238. @dblclick='doubleClickItem(item)'
  239. )
  240. q-item-section.fileman-filelist-icon(avatar)
  241. q-icon(:name='item.icon', :size='state.isCompact ? `md` : `xl`')
  242. q-item-section.fileman-filelist-label
  243. q-item-label {{ usePathTitle ? item.fileName : item.title }}
  244. q-item-label(caption, v-if='!state.isCompact') {{ item.caption }}
  245. q-item-section.fileman-filelist-side(side, v-if='item.side')
  246. .text-caption {{ item.side }}
  247. //- RIGHT-CLICK MENU
  248. q-menu.translucent-menu(
  249. touch-position
  250. context-menu
  251. auto-close
  252. transition-show='jump-down'
  253. transition-hide='jump-up'
  254. )
  255. q-card.q-pa-sm
  256. q-list(dense, style='min-width: 150px;')
  257. q-item(clickable, v-if='insertMode && item.type !== `folder`', @click='insertItem(item)')
  258. q-item-section(side)
  259. q-icon(name='las la-plus-circle', color='primary')
  260. q-item-section {{ t(`common.actions.insert`) }}
  261. q-item(clickable, v-if='item.type === `page`', @click='editItem(item)')
  262. q-item-section(side)
  263. q-icon(name='las la-edit', color='orange')
  264. q-item-section {{ t(`common.actions.edit`) }}
  265. q-item(clickable, v-if='item.type === `page`', @click='rerenderPage(item)')
  266. q-item-section(side)
  267. q-icon(name='las la-magic', color='orange')
  268. q-item-section {{ t(`common.actions.rerender`) }}
  269. q-item(clickable, v-if='item.type !== `folder`', @click='openItem(item)')
  270. q-item-section(side)
  271. q-icon(name='las la-eye', color='primary')
  272. q-item-section {{ t(`common.actions.view`) }}
  273. template(v-if='item.type === `asset` && item.imageEdit')
  274. q-item(clickable)
  275. q-item-section(side)
  276. q-icon(name='las la-edit', color='orange')
  277. q-item-section Edit Image...
  278. q-item(clickable)
  279. q-item-section(side)
  280. q-icon(name='las la-crop', color='orange')
  281. q-item-section Resize Image...
  282. q-item(clickable, v-if='item.type !== `folder`', @click='copyItemURL(item)')
  283. q-item-section(side)
  284. q-icon(name='las la-clipboard', color='primary')
  285. q-item-section {{ t(`common.actions.copyURL`) }}
  286. q-item(clickable, v-if='item.type !== `folder`', @click='downloadItem(item)')
  287. q-item-section(side)
  288. q-icon(name='las la-download', color='primary')
  289. q-item-section {{ t(`common.actions.download`) }}
  290. q-item(clickable)
  291. q-item-section(side)
  292. q-icon(name='las la-copy', color='teal')
  293. q-item-section Duplicate...
  294. q-item(clickable, @click='renameItem(item)')
  295. q-item-section(side)
  296. q-icon(name='las la-redo', color='teal')
  297. q-item-section Rename...
  298. q-item(clickable)
  299. q-item-section(side)
  300. q-icon(name='las la-arrow-right', color='teal')
  301. q-item-section Move to...
  302. q-item(clickable, @click='delItem(item)')
  303. q-item-section(side)
  304. q-icon(name='las la-trash-alt', color='negative')
  305. q-item-section.text-negative {{ t(`common.actions.delete`) }}
  306. q-footer
  307. q-bar.fileman-path
  308. small.text-caption.text-grey-7 {{folderPath}}
  309. input(
  310. type='file'
  311. ref='fileIpt'
  312. multiple
  313. @change='uploadNewFiles'
  314. style='display: none'
  315. )
  316. </template>
  317. <script setup>
  318. import { useI18n } from 'vue-i18n'
  319. import { computed, defineAsyncComponent, nextTick, onMounted, reactive, ref, toRaw, watch } from 'vue'
  320. import { filesize } from 'filesize'
  321. import { useQuasar } from 'quasar'
  322. import { DateTime } from 'luxon'
  323. import { cloneDeep, dropRight, find, findKey, initial, last, nth } from 'lodash-es'
  324. import { useRoute, useRouter } from 'vue-router'
  325. import gql from 'graphql-tag'
  326. import Fuse from 'fuse.js/basic'
  327. import NewMenu from './PageNewMenu.vue'
  328. import Tree from './TreeNav.vue'
  329. import fileTypes from '../helpers/fileTypes'
  330. import { useCommonStore } from 'src/stores/common'
  331. import { usePageStore } from 'src/stores/page'
  332. import { useSiteStore } from 'src/stores/site'
  333. import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
  334. import FolderDeleteDialog from 'src/components/FolderDeleteDialog.vue'
  335. import FolderRenameDialog from 'src/components/FolderRenameDialog.vue'
  336. import AssetRenameDialog from 'src/components/AssetRenameDialog.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. // --------------------------------------
  747. // PAGE METHODS
  748. // --------------------------------------
  749. function rerenderPage (item) {
  750. $q.dialog({
  751. component: defineAsyncComponent(() => import('src/components/RerenderPageDialog.vue')),
  752. componentProps: {
  753. id: item.id
  754. }
  755. })
  756. }
  757. function delPage (pageId, pageName) {
  758. $q.dialog({
  759. component: defineAsyncComponent(() => import('src/components/PageDeleteDialog.vue')),
  760. componentProps: {
  761. pageId,
  762. pageName
  763. }
  764. }).onOk(() => {
  765. // -> Reload current view
  766. loadTree({ parentId: state.currentFolderId })
  767. })
  768. }
  769. // --------------------------------------
  770. // ASSET METHODS
  771. // --------------------------------------
  772. function renameAsset (assetId) {
  773. $q.dialog({
  774. component: AssetRenameDialog,
  775. componentProps: {
  776. assetId
  777. }
  778. }).onOk(async () => {
  779. // -> Reload current view
  780. await loadTree({ parentId: state.currentFolderId })
  781. })
  782. }
  783. function delAsset (assetId, assetName) {
  784. $q.dialog({
  785. component: defineAsyncComponent(() => import('src/components/AssetDeleteDialog.vue')),
  786. componentProps: {
  787. assetId,
  788. assetName
  789. }
  790. }).onOk(async () => {
  791. // -> Reload current view
  792. await loadTree({ parentId: state.currentFolderId })
  793. })
  794. }
  795. // --------------------------------------
  796. // UPLOAD METHODS
  797. // --------------------------------------
  798. function uploadFile () {
  799. fileIpt.value.click()
  800. }
  801. async function uploadNewFiles () {
  802. if (!fileIpt.value.files?.length) {
  803. return
  804. }
  805. state.isUploading = true
  806. state.uploadPercentage = 0
  807. state.loading++
  808. nextTick(() => {
  809. setTimeout(async () => {
  810. try {
  811. const totalFiles = fileIpt.value.files.length
  812. let idx = 0
  813. for (const fileToUpload of fileIpt.value.files) {
  814. idx++
  815. state.uploadPercentage = totalFiles > 1 ? Math.round(idx / totalFiles * 100) : 90
  816. const resp = await APOLLO_CLIENT.mutate({
  817. context: {
  818. uploadMode: true
  819. },
  820. mutation: gql`
  821. mutation uploadAssets (
  822. $folderId: UUID
  823. $locale: String
  824. $siteId: UUID
  825. $files: [Upload!]!
  826. ) {
  827. uploadAssets (
  828. folderId: $folderId
  829. locale: $locale
  830. siteId: $siteId
  831. files: $files
  832. ) {
  833. operation {
  834. succeeded
  835. message
  836. }
  837. }
  838. }
  839. `,
  840. variables: {
  841. folderId: state.currentFolderId,
  842. siteId: siteStore.id,
  843. locale: 'en', // TODO: use current locale
  844. files: [fileToUpload]
  845. }
  846. })
  847. if (!resp?.data?.uploadAssets?.operation?.succeeded) {
  848. throw new Error(resp?.data?.uploadAssets?.operation?.message || 'An unexpected error occured.')
  849. }
  850. }
  851. state.uploadPercentage = 100
  852. loadTree({ parentId: state.currentFolderId })
  853. $q.notify({
  854. type: 'positive',
  855. message: t('fileman.uploadSuccess')
  856. })
  857. } catch (err) {
  858. $q.notify({
  859. type: 'negative',
  860. message: 'Failed to upload file.',
  861. caption: err.message
  862. })
  863. }
  864. state.loading--
  865. fileIpt.value.value = null
  866. setTimeout(() => {
  867. state.isUploading = false
  868. state.uploadPercentage = 0
  869. }, 1500)
  870. }, 400)
  871. })
  872. }
  873. function uploadCancel () {
  874. state.isUploading = false
  875. state.uploadPercentage = 0
  876. }
  877. // --------------------------------------
  878. // ITEM LIST ACTIONS
  879. // --------------------------------------
  880. function selectItem (item) {
  881. if (item.type === 'folder') {
  882. state.currentFolderId = item.id
  883. treeComp.value.setOpened(item.id)
  884. } else {
  885. state.currentFileId = item.id
  886. }
  887. }
  888. function doubleClickItem (item) {
  889. if (insertMode.value) {
  890. insertItem(item)
  891. } else {
  892. openItem(item)
  893. }
  894. }
  895. function openItem (item) {
  896. switch (item.type) {
  897. case 'folder': {
  898. return
  899. }
  900. case 'page': {
  901. const pagePath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName
  902. router.push(`/${pagePath}`)
  903. close()
  904. break
  905. }
  906. case 'asset': {
  907. // TODO: Open asset
  908. close()
  909. break
  910. }
  911. }
  912. }
  913. async function copyItemURL (item) {
  914. try {
  915. switch (item.type) {
  916. case 'page': {
  917. const pagePath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName
  918. await navigator.clipboard.writeText(`${window.location.origin}/${pagePath}`)
  919. break
  920. }
  921. case 'asset': {
  922. const assetPath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName
  923. await navigator.clipboard.writeText(`${window.location.origin}/${assetPath}`)
  924. break
  925. }
  926. default: {
  927. throw new Error('Invalid Item Type')
  928. }
  929. }
  930. $q.notify({
  931. type: 'positive',
  932. message: t('fileman.copyURLSuccess')
  933. })
  934. } catch (err) {
  935. $q.notify({
  936. type: 'negative',
  937. message: 'Failed to copy URL to clipboard.',
  938. caption: err.message
  939. })
  940. }
  941. }
  942. async function editItem (item) {
  943. router.push(item.folderPath ? `/_edit/${item.folderPath}/${item.fileName}` : `/_edit/${item.fileName}`)
  944. close()
  945. }
  946. function downloadItem (item) {
  947. }
  948. function renameItem (item) {
  949. console.info(item)
  950. switch (item.type) {
  951. case 'folder': {
  952. renameFolder(item.id)
  953. break
  954. }
  955. case 'page': {
  956. // TODO: Rename page
  957. break
  958. }
  959. case 'asset': {
  960. renameAsset(item.id)
  961. break
  962. }
  963. }
  964. }
  965. function delItem (item) {
  966. switch (item.type) {
  967. case 'asset': {
  968. delAsset(item.id, item.title)
  969. break
  970. }
  971. case 'folder': {
  972. delFolder(item.id, true)
  973. break
  974. }
  975. case 'page': {
  976. delPage(item.id, item.title)
  977. break
  978. }
  979. }
  980. }
  981. // MOUNTED
  982. onMounted(async () => {
  983. const pathParts = pageStore.path.split('/')
  984. const parentPath = initial(pathParts).join('/')
  985. await loadTree({
  986. parentPath,
  987. initLoad: true
  988. })
  989. // -> Open tree up to current folder
  990. const folderFolderPath = dropRight(pathParts, 2).join('/')
  991. const folderFileName = nth(pathParts, -2)
  992. for (const [id, node] of Object.entries(state.treeNodes)) {
  993. if (parentPath.startsWith(node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName)) {
  994. treeComp.value.setOpened(id)
  995. }
  996. }
  997. // -> Switch to current folder (from page path)
  998. const currentNodeId = findKey(state.treeNodes, n => n.folderPath === folderFolderPath && n.fileName === folderFileName)
  999. if (currentNodeId) {
  1000. state.currentFolderId = currentNodeId
  1001. }
  1002. })
  1003. </script>
  1004. <style lang="scss">
  1005. .fileman {
  1006. &-left {
  1007. @at-root .body--light & {
  1008. background-color: $blue-grey-1;
  1009. }
  1010. @at-root .body--dark & {
  1011. background-color: $dark-4;
  1012. }
  1013. }
  1014. &-center {
  1015. @at-root .body--light & {
  1016. background-color: #FFF;
  1017. }
  1018. @at-root .body--dark & {
  1019. background-color: $dark-6;
  1020. }
  1021. }
  1022. &-right {
  1023. @at-root .body--light & {
  1024. background-color: $grey-1;
  1025. }
  1026. @at-root .body--dark & {
  1027. background-color: $dark-5;
  1028. }
  1029. }
  1030. &-toolbar {
  1031. @at-root .body--light & {
  1032. background-color: $grey-1;
  1033. }
  1034. @at-root .body--dark & {
  1035. background-color: $dark-5;
  1036. }
  1037. }
  1038. &-path {
  1039. @at-root .body--light & {
  1040. background-color: $blue-grey-1 !important;
  1041. }
  1042. @at-root .body--dark & {
  1043. background-color: $dark-4 !important;
  1044. }
  1045. }
  1046. &-main {
  1047. height: 100%;
  1048. }
  1049. &-loadinglist {
  1050. padding: 16px;
  1051. font-style: italic;
  1052. display: flex;
  1053. flex-direction: column;
  1054. justify-content: center;
  1055. align-items: center;
  1056. > span {
  1057. margin-top: 16px;
  1058. }
  1059. }
  1060. &-emptylist {
  1061. padding: 16px;
  1062. font-style: italic;
  1063. font-size: 1.5em;
  1064. font-weight: 300;
  1065. display: flex;
  1066. flex-direction: column;
  1067. justify-content: center;
  1068. align-items: center;
  1069. > img {
  1070. opacity: .25;
  1071. width: 200px;
  1072. }
  1073. @at-root .body--light & {
  1074. color: $grey-6;
  1075. }
  1076. @at-root .body--dark & {
  1077. color: $grey-7;
  1078. > img {
  1079. filter: invert(1);
  1080. }
  1081. }
  1082. }
  1083. &-filelist {
  1084. padding: 8px 12px;
  1085. > .q-item {
  1086. padding: 4px 6px;
  1087. border-radius: 8px;
  1088. &.active {
  1089. background-color: var(--q-primary);
  1090. color: #FFF;
  1091. .fileman-filelist-label .q-item__label--caption {
  1092. color: rgba(255,255,255,.7);
  1093. }
  1094. .fileman-filelist-side .text-caption {
  1095. color: rgba(255,255,255,.7);
  1096. }
  1097. }
  1098. }
  1099. &.is-compact {
  1100. > .q-item {
  1101. padding: 0 6px;
  1102. min-height: 36px;
  1103. }
  1104. .fileman-filelist-icon {
  1105. padding-right: 6px;
  1106. min-width: 0;
  1107. }
  1108. }
  1109. }
  1110. &-details-row {
  1111. display: flex;
  1112. flex-direction: column;
  1113. padding: 5px 0;
  1114. label {
  1115. font-size: .7rem;
  1116. font-weight: 500;
  1117. @at-root .body--light & {
  1118. color: $grey-6;
  1119. }
  1120. @at-root .body--dark & {
  1121. color: $blue-grey-4;
  1122. }
  1123. }
  1124. span {
  1125. font-size: .85rem;
  1126. @at-root .body--light & {
  1127. color: $grey-8;
  1128. }
  1129. @at-root .body--dark & {
  1130. color: $blue-grey-2;
  1131. }
  1132. }
  1133. & + .fileman-details-row {
  1134. margin-top: 5px;
  1135. }
  1136. }
  1137. &-progressbar {
  1138. width: 100%;
  1139. flex: 1;
  1140. height: 12px;
  1141. border-radius: 3px;
  1142. @at-root .body--light & {
  1143. background-color: $blue-grey-2;
  1144. }
  1145. @at-root .body--dark & {
  1146. background-color: $dark-4 !important;
  1147. }
  1148. > div {
  1149. height: 12px;
  1150. background-color: $positive;
  1151. border-radius: 3px 0 0 3px;
  1152. background-image: linear-gradient(
  1153. -45deg,
  1154. rgba(255, 255, 255, 0.3) 25%,
  1155. transparent 25%,
  1156. transparent 50%,
  1157. rgba(255, 255, 255, 0.3) 50%,
  1158. rgba(255, 255, 255, 0.3) 75%,
  1159. transparent 75%,
  1160. transparent
  1161. );
  1162. background-size: 50px 50px;
  1163. background-position: 0 0;
  1164. animation: fileman-progress 2s linear infinite;
  1165. box-shadow: 0 0 5px 0 $positive;
  1166. font-size: 9px;
  1167. letter-spacing: 2px;
  1168. font-weight: 700;
  1169. color: #FFF;
  1170. display: flex;
  1171. justify-content: center;
  1172. align-items: center;
  1173. overflow: hidden;
  1174. transition: all 1s ease;
  1175. }
  1176. }
  1177. }
  1178. @keyframes fileman-progress {
  1179. 0% {
  1180. background-position: 0 0;
  1181. }
  1182. 100% {
  1183. background-position: -50px -50px;
  1184. }
  1185. }
  1186. </style>