Browse Source

feat: upload pending assets

NGPixel 1 year ago
parent
commit
1fb130988a

+ 1 - 0
server/locales/en.json

@@ -1530,6 +1530,7 @@
   "editor.pageRel.title": "Add Page Relation",
   "editor.pageRel.titleEdit": "Edit Page Relation",
   "editor.pageScripts.title": "Page Scripts",
+  "editor.pendingAssetsUploading": "Uploading assets...",
   "editor.props.alias": "Alias",
   "editor.props.allowComments": "Allow Comments",
   "editor.props.allowCommentsHint": "Enable commenting abilities on this page.",

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="wZGpzcUawlversdxtSVKMa" x1="22.255" x2="28.545" y1="18.269" y2="43.563" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#wZGpzcUawlversdxtSVKMa)" d="M31.789,24.789l-6.728-6.728c-0.586-0.586-1.536-0.586-2.121,0l-6.728,6.728	C15.764,25.236,16.081,26,16.713,26H21v16c0,0.552,0.448,1,1,1h4c0.552,0,1-0.448,1-1V26h4.287	C31.919,26,32.236,25.236,31.789,24.789z"/><linearGradient id="wZGpzcUawlversdxtSVKMb" x1="42" x2="42" y1="4.513" y2="16.282" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#wZGpzcUawlversdxtSVKMb)" d="M43.828,9.828L39,5v10c0,0.552,0.448,1,1,1h4c0.552,0,1-0.448,1-1v-2.343	C45,11.596,44.579,10.579,43.828,9.828z"/><linearGradient id="wZGpzcUawlversdxtSVKMc" x1="6" x2="6" y1="4.513" y2="16.282" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#wZGpzcUawlversdxtSVKMc)" d="M9,5L4.172,9.828C3.421,10.579,3,11.596,3,12.657V15c0,0.552,0.448,1,1,1h4	c0.552,0,1-0.448,1-1V5z"/><linearGradient id="wZGpzcUawlversdxtSVKMd" x1="9" x2="39" y1="8" y2="8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0362b0"/><stop offset=".112" stop-color="#036abd"/><stop offset=".258" stop-color="#036fc5"/><stop offset=".5" stop-color="#0370c8"/><stop offset=".742" stop-color="#036fc5"/><stop offset=".888" stop-color="#036abd"/><stop offset="1" stop-color="#0362b0"/></linearGradient><rect width="30" height="6" x="9" y="5" fill="url(#wZGpzcUawlversdxtSVKMd)"/></svg>

File diff suppressed because it is too large
+ 0 - 0
ux/public/_assets/illustrations/undraw_upload.svg


+ 43 - 8
ux/src/components/EditorMarkdown.vue

@@ -28,6 +28,8 @@
                 q-item-label From File Manager...
             q-item(
               clickable
+              @click='getAssetFromClipboard'
+              v-close-popup
               )
               q-item-section(side)
                 q-icon(name='las la-clipboard', color='brown')
@@ -251,11 +253,10 @@
 import { reactive, ref, shallowRef, nextTick, onMounted, watch, onBeforeUnmount } from 'vue'
 import { useMeta, useQuasar, setCssVar } from 'quasar'
 import { useI18n } from 'vue-i18n'
-import { get, flatten, last, times, startsWith, debounce } from 'lodash-es'
+import { find, get, last, times, startsWith, debounce } from 'lodash-es'
 import { DateTime } from 'luxon'
 import * as monaco from 'monaco-editor'
 import { Position, Range } from 'monaco-editor'
-import { v4 as uuid } from 'uuid'
 
 import { useEditorStore } from 'src/stores/editor'
 import { usePageStore } from 'src/stores/page'
@@ -477,6 +478,43 @@ function openEditorSettings () {
   siteStore.$patch({ overlay: 'EditorMarkdownConfig' })
 }
 
+async function getAssetFromClipboard () {
+  try {
+    const permission = await navigator.permissions.query({
+      name: 'clipboard-read'
+    })
+    if (permission.state === 'denied') {
+      throw new Error('Not allowed to read clipboard.')
+    }
+    const clipboardContents = await navigator.clipboard.read()
+    let hasValidItem = false
+    for (const item of clipboardContents) {
+      const imageType = find(item.types, t => t.startsWith('image/'))
+      if (imageType) {
+        hasValidItem = true
+        const blob = await item.getType(imageType)
+        const blobUrl = editorStore.addPendingAsset(blob)
+        insertAtCursor({
+          content: `![](${blobUrl})`
+        })
+      }
+    }
+    if (!hasValidItem) {
+      throw new Error('No supported content found in the Clipboard.')
+    }
+  } catch (err) {
+    return $q.notify({
+      type: 'negative',
+      message: 'Unable to copy from Clipboard',
+      caption: err.message
+    })
+  }
+}
+
+function reloadEditorContent () {
+  editor.getModel().setValue(pageStore.content)
+}
+
 // MOUNTED
 
 onMounted(async () => {
@@ -624,12 +662,7 @@ onMounted(async () => {
   editor.getContainerDomNode().addEventListener('drop', ev => {
     ev.preventDefault()
     for (const file of ev.dataTransfer.files) {
-      const blobUrl = URL.createObjectURL(file)
-      editorStore.pendingAssets.push({
-        id: uuid(),
-        file,
-        blobUrl
-      })
+      const blobUrl = editorStore.addPendingAsset(file)
       if (file.type.startsWith('image')) {
         insertAtCursor({
           content: `![${file.name}](${blobUrl})`
@@ -652,6 +685,7 @@ onMounted(async () => {
 
   EVENT_BUS.on('insertAsset', insertAssetClb)
   EVENT_BUS.on('openEditorSettings', openEditorSettings)
+  EVENT_BUS.on('reloadEditorContent', reloadEditorContent)
 
   // this.$root.$on('editorInsert', opts => {
   //   switch (opts.kind) {
@@ -689,6 +723,7 @@ onMounted(async () => {
 onBeforeUnmount(() => {
   EVENT_BUS.off('insertAsset', insertAssetClb)
   EVENT_BUS.off('openEditorSettings', openEditorSettings)
+  EVENT_BUS.off('reloadEditorContent', reloadEditorContent)
   if (editor) {
     editor.dispose()
   }

+ 1 - 1
ux/src/components/PageActionsCol.vue

@@ -51,7 +51,7 @@
             q-item(v-for='item of editorStore.pendingAssets')
               q-item-section(side)
                 q-icon(name='las la-file-image')
-              q-item-section {{ item.file.name }}
+              q-item-section {{ item.fileName }}
               q-item-section(side)
                 q-btn.acrylic-btn(
                   color='negative'

+ 15 - 0
ux/src/components/PageHeader.vue

@@ -289,6 +289,7 @@ async function saveChanges (closeAfter = false) {
 }
 
 async function saveChangesCommit (closeAfter = false) {
+  await processPendingAssets()
   $q.loading.show()
   try {
     await pageStore.pageSave()
@@ -315,6 +316,7 @@ async function saveChangesCommit (closeAfter = false) {
 async function createPage () {
   // Handle home page creation flow
   if (pageStore.path === 'home') {
+    await processPendingAssets()
     $q.loading.show()
     try {
       await pageStore.pageSave()
@@ -347,6 +349,8 @@ async function createPage () {
       itemFileName: pageStore.path
     }
   }).onOk(async ({ path, title }) => {
+    await processPendingAssets()
+
     $q.loading.show()
     try {
       pageStore.$patch({
@@ -372,6 +376,17 @@ async function createPage () {
   })
 }
 
+async function processPendingAssets () {
+  if (editorStore.pendingAssets?.length > 0) {
+    return new Promise((resolve, reject) => {
+      $q.dialog({
+        component: defineAsyncComponent(() => import('../components/UploadPendingAssetsDialog.vue')),
+        persistent: true
+      }).onOk(resolve).onCancel(reject)
+    })
+  }
+}
+
 async function editPage () {
   $q.loading.show()
   await pageStore.pageEdit()

+ 115 - 0
ux/src/components/UploadPendingAssetsDialog.vue

@@ -0,0 +1,115 @@
+<template lang="pug">
+q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
+  q-card(style='min-width: 350px; max-width: 450px;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-upload.svg', left, size='sm')
+      span {{t(`editor.pendingAssetsUploading`)}}
+    q-card-section
+      .q-pa-md.text-center
+        img(src='/_assets/illustrations/undraw_upload.svg', style='width: 150px;')
+      q-linear-progress(
+        indeterminate
+        size='lg'
+        rounded
+        )
+      .q-mt-sm.text-center.text-caption {{ state.current }} / {{ state.total }}
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { computed, onMounted, reactive } from 'vue'
+
+import { useEditorStore } from 'src/stores/editor'
+import { useSiteStore } from 'src/stores/site'
+import { usePageStore } from 'src/stores/page'
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// STORES
+
+const editorStore = useEditorStore()
+const pageStore = usePageStore()
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  current: 1,
+  total: 1
+})
+
+// MOUNTED
+
+onMounted(async () => {
+  state.total = editorStore.pendingAssets.length ?? 0
+  state.current = 0
+
+  await new Promise(resolve => setTimeout(resolve, 500))
+
+  try {
+    for (const item of editorStore.pendingAssets) {
+      state.current++
+      const resp = await APOLLO_CLIENT.mutate({
+        context: {
+          uploadMode: true
+        },
+        mutation: gql`
+          mutation uploadAssets (
+            $folderId: UUID
+            $locale: String
+            $siteId: UUID
+            $files: [Upload!]!
+          ) {
+            uploadAssets (
+              folderId: $folderId
+              locale: $locale
+              siteId: $siteId
+              files: $files
+            ) {
+              operation {
+                succeeded
+                message
+              }
+            }
+          }
+        `,
+        variables: {
+          folderId: null, // TODO: Upload to page specific folder
+          siteId: siteStore.id,
+          locale: 'en', // TODO: use current locale
+          files: [item.file]
+        }
+      })
+      if (!resp?.data?.uploadAssets?.operation?.succeeded) {
+        throw new Error(resp?.data?.uploadAssets?.operation?.message || 'An unexpected error occured.')
+      }
+      pageStore.content = pageStore.content.replaceAll(item.blobUrl, `/${item.fileName}`)
+      URL.revokeObjectURL(item.blobUrl)
+    }
+    editorStore.pendingAssets = []
+    EVENT_BUS.emit('reloadEditorContent')
+    onDialogOK()
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+    onDialogCancel()
+  }
+})
+</script>

+ 33 - 0
ux/src/stores/editor.js

@@ -1,9 +1,19 @@
 import { defineStore } from 'pinia'
 import gql from 'graphql-tag'
 import { clone } from 'lodash-es'
+import { v4 as uuid } from 'uuid'
 
 import { useSiteStore } from './site'
 
+const imgMimeExt = {
+  'image/jpeg': 'jpg',
+  'image/gif': 'gif',
+  'image/png': 'png',
+  'image/webp': 'webp',
+  'image/svg+xml': 'svg',
+  'image/tiff': 'tif'
+}
+
 export const useEditorStore = defineStore('editor', {
   state: () => ({
     isActive: false,
@@ -33,6 +43,29 @@ export const useEditorStore = defineStore('editor', {
     }
   },
   actions: {
+    addPendingAsset (data) {
+      const blobUrl = URL.createObjectURL(data)
+      if (data instanceof File) {
+        this.pendingAssets.push({
+          id: uuid(),
+          kind: 'file',
+          file: data,
+          fileName: data.name,
+          blobUrl
+        })
+      } else {
+        const fileId = uuid()
+        const fileName = `${fileId}.${imgMimeExt[data.type] || 'dat'}`
+        this.pendingAssets.push({
+          id: fileId,
+          kind: 'blob',
+          file: new File(data, fileName, { type: data.type }),
+          fileName,
+          blobUrl
+        })
+      }
+      return blobUrl
+    },
     async fetchConfigs () {
       const siteStore = useSiteStore()
       try {

Some files were not shown because too many files changed in this diff