Przeglądaj źródła

feat: editor pending asset uploads (wip)

NGPixel 1 rok temu
rodzic
commit
5a8d95ee0c

+ 1 - 1
README.md

@@ -66,7 +66,7 @@ The current stable release (2.x) is available at https://js.wiki
     ```
 1. In the left-side terminal (Server), run the command:
     ```sh
-    node run start
+    npm run start
     ```
 1. Open your browser to `http://localhost:3000`
 1. Login using the default administrator user:

+ 17 - 3
server/controllers/common.mjs

@@ -1,5 +1,5 @@
 import express from 'express'
-// import pageHelper from '../helpers/page.mjs'
+import { parsePath } from '../helpers/page.mjs'
 // import CleanCSS from 'clean-css'
 import path from 'node:path'
 
@@ -526,8 +526,22 @@ export default function () {
   //   }
   // })
 
-  router.get('/*', (req, res, next) => {
-    res.sendFile(path.join(WIKI.ROOTPATH, 'assets/index.html'))
+  router.get('/*', async (req, res, next) => {
+    const site = await WIKI.db.sites.getSiteByHostname({ hostname: req.hostname })
+
+    if (!site) {
+      throw new Error('INVALID_SITE')
+    }
+
+    const stripExt = site.config.pageExtensions.some(ext => req.path.endsWith(`.${ext}`))
+    const pathArgs = parsePath(req.path, { stripExt })
+    const isPage = (stripExt || pathArgs.path.indexOf('.') === -1)
+
+    if (isPage) {
+      res.sendFile(path.join(WIKI.ROOTPATH, 'assets/index.html'))
+    } else {
+      await WIKI.db.assets.getAsset({ pathArgs, siteId: site.id }, res)
+    }
   })
 
   return router

+ 20 - 17
server/models/assets.mjs

@@ -1,5 +1,5 @@
 import { Model } from 'objection'
-import path from 'path'
+import path from 'node:path'
 import fse from 'fs-extra'
 import { startsWith } from 'lodash-es'
 import { generateHash } from '../helpers/common.mjs'
@@ -166,24 +166,24 @@ export class Asset extends Model {
       .first()
   }
 
-  static async getAsset({ path, locale, siteId }, res) {
+  static async getAsset({ pathArgs, siteId }, res) {
     try {
-      const fileInfo = '' // assetHelper.getPathInfo(assetPath)
-      const fileHash = '' // assetHelper.generateHash(assetPath)
-      const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${fileHash}.dat`)
+      const fileInfo = path.parse(pathArgs.path.toLowerCase())
+      const fileHash = generateHash(pathArgs.path)
+      const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${siteId}/${fileHash}.dat`)
 
       // Force unsafe extensions to download
-      if (WIKI.config.uploads.forceDownload && !['.png', '.apng', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg'].includes(fileInfo.ext)) {
+      if (WIKI.config.security.forceAssetDownload && !['.png', '.apng', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg'].includes(fileInfo.ext)) {
         res.set('Content-disposition', 'attachment; filename=' + encodeURIComponent(fileInfo.base))
       }
 
-      if (await WIKI.db.assets.getAssetFromCache(assetPath, cachePath, res)) {
+      if (await WIKI.db.assets.getAssetFromCache({ cachePath, extName: fileInfo.ext }, res)) {
         return
       }
-      if (await WIKI.db.assets.getAssetFromStorage(assetPath, res)) {
-        return
-      }
-      await WIKI.db.assets.getAssetFromDb(assetPath, fileHash, cachePath, res)
+      // if (await WIKI.db.assets.getAssetFromStorage(assetPath, res)) {
+      //   return
+      // }
+      await WIKI.db.assets.getAssetFromDb({ pathArgs, fileHash, cachePath, siteId }, res)
     } catch (err) {
       if (err.code === `ECONNABORTED` || err.code === `EPIPE`) {
         return
@@ -193,13 +193,13 @@ export class Asset extends Model {
     }
   }
 
-  static async getAssetFromCache(assetPath, cachePath, res) {
+  static async getAssetFromCache({ cachePath, extName }, res) {
     try {
       await fse.access(cachePath, fse.constants.R_OK)
     } catch (err) {
       return false
     }
-    res.type(path.extname(assetPath))
+    res.type(extName)
     await new Promise(resolve => res.sendFile(cachePath, { dotfiles: 'deny' }, resolve))
     return true
   }
@@ -219,11 +219,14 @@ export class Asset extends Model {
     return false
   }
 
-  static async getAssetFromDb(assetPath, fileHash, cachePath, res) {
-    const asset = await WIKI.db.assets.query().where('hash', fileHash).first()
+  static async getAssetFromDb({ pathArgs, fileHash, cachePath, siteId }, res) {
+    const asset = await WIKI.db.knex('tree').where({
+      siteId,
+      hash: fileHash
+    }).first()
     if (asset) {
-      const assetData = await WIKI.db.knex('assetData').where('id', asset.id).first()
-      res.type(asset.ext)
+      const assetData = await WIKI.db.knex('assets').where('id', asset.id).first()
+      res.type(assetData.fileExt)
       res.send(assetData.data)
       await fse.outputFile(cachePath, assetData.data)
     } else {

+ 1 - 0
ux/public/_assets/icons/color-data-pending.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 48 48" width="96px" height="96px"><path fill="#FFC107" d="M37,5H11l-5,7v28c0,1.657,1.343,3,3,3h30c1.656,0,3-1.343,3-3v-5V12L37,5z"/><path fill="#0097A7" d="M33,30c0,4.971-4.029,9-9,9c-4.971,0-9-4.029-9-9s4.029-9,9-9C28.971,21,33,25.029,33,30"/><path fill="#EEE" d="M30.631,30c0,3.664-2.969,6.632-6.631,6.632c-3.663,0-6.632-2.968-6.632-6.632c0-3.663,2.969-6.631,6.632-6.631C27.662,23.369,30.631,26.337,30.631,30"/><path d="M25 29.563L25 25 23 25 23 29.609 23 30.438 23.61 31 25.996 33.119 27.352 31.648z"/><path fill="#DB8509" d="M12.029,7l-3.571,5H18c0,3.314,2.687,6,6,6c3.313,0,6-2.686,6-6h9.542l-3.571-5H12.029z"/></svg>

+ 47 - 1
ux/src/components/EditorMarkdown.vue

@@ -15,8 +15,31 @@
         icon='mdi-image-plus-outline'
         padding='sm sm'
         flat
-        @click='insertAssets'
         )
+        q-menu(anchor='top right' self='top left')
+          q-list(separator, auto-close)
+            q-item(
+              clickable
+              @click='insertAssets'
+              )
+              q-item-section(side)
+                q-icon(name='las la-folder-open', color='positive')
+              q-item-section
+                q-item-label From File Manager...
+            q-item(
+              clickable
+              )
+              q-item-section(side)
+                q-icon(name='las la-clipboard', color='brown')
+              q-item-section
+                q-item-label From Clipboard...
+            q-item(
+              clickable
+              )
+              q-item-section(side)
+                q-icon(name='las la-cloud-download-alt', color='blue')
+              q-item-section
+                q-item-label From Remote URL...
         q-tooltip(anchor='center right' self='center left') {{ t('editor.markup.insertAssets') }}
       q-btn(
         icon='mdi-code-json'
@@ -232,6 +255,7 @@ import { get, flatten, 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'
@@ -596,6 +620,28 @@ onMounted(async () => {
     }
   }, 500))
 
+  // -> Handle asset drop
+  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
+      })
+      if (file.type.startsWith('image')) {
+        insertAtCursor({
+          content: `![${file.name}](${blobUrl})`
+        })
+      } else {
+        insertAtCursor({
+          content: `[${file.name}](${blobUrl})`
+        })
+      }
+    }
+  })
+
   // -> Post init
 
   editor.focus()

+ 76 - 0
ux/src/components/PageActionsCol.vue

@@ -19,6 +19,50 @@
       disable
       )
       q-tooltip(anchor='center left' self='center right') Page Data
+    q-btn.q-py-md(
+      v-if='editorStore.isActive'
+      flat
+      color='white'
+      :text-color='hasPendingAssets ? `white` : `deep-orange-3`'
+      aria-label='Pending Asset Uploads'
+      )
+      q-icon(name='mdi-image-sync-outline')
+        q-badge.page-actions-pending-badge(
+          v-if='hasPendingAssets'
+          color='white'
+          text-color='orange-9'
+          rounded
+          floating
+          )
+          strong {{ editorStore.pendingAssets.length * 1 }}
+      q-tooltip(anchor='center left' self='center right') Pending Asset Uploads
+      q-menu(
+        ref='menuPendingAssets'
+        anchor='top left'
+        self='top right'
+        :offset='[10, 0]'
+        )
+        q-card(style='width: 450px;')
+          q-card-section.card-header
+            q-icon(name='img:/_assets/icons/color-data-pending.svg', left, size='sm')
+            span Pending Asset Uploads
+          q-card-section(v-if='!hasPendingAssets') There are no assets pending uploads.
+          q-list(v-else, separator)
+            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(side)
+                q-btn.acrylic-btn(
+                  color='negative'
+                  round
+                  icon='las la-times'
+                  size='xs'
+                  flat
+                  @click='removePendingAsset(item)'
+                  )
+          q-card-section.card-actions
+            em.text-caption Assets that are pasted or dropped onto this page will be held here until the page is saved.
     q-separator.q-my-sm(inset)
   q-btn.q-py-md(
     flat
@@ -131,6 +175,14 @@ const route = useRoute()
 
 const { t } = useI18n()
 
+// REFS
+
+const menuPendingAssets = ref(null)
+
+// COMPUTED
+
+const hasPendingAssets = computed(() => editorStore.pendingAssets?.length > 0)
+
 // METHODS
 
 function togglePageProperties () {
@@ -188,6 +240,14 @@ function deletePage () {
     router.replace('/')
   })
 }
+
+function removePendingAsset (item) {
+  URL.revokeObjectURL(item.blobUrl)
+  editorStore.pendingAssets = editorStore.pendingAssets.filter(a => a.id !== item.id)
+  if (editorStore.pendingAssets.length < 1) {
+    menuPendingAssets.value.hide()
+  }
+}
 </script>
 
 <style lang="scss">
@@ -217,5 +277,21 @@ function deletePage () {
     color: $deep-orange-3;
     font-weight: 500;
   }
+
+  &-pending-badge {
+    animation: pageActionsBadgePulsate 2s ease infinite;
+  }
+}
+
+@keyframes pageActionsBadgePulsate {
+  0% {
+    transform: translate(0, 0);
+  }
+  50% {
+    transform: translate(3px, -3px);
+  }
+  100% {
+    transform: translate(0, 0);
+  }
 }
 </style>

+ 1 - 0
ux/src/components/PagePropertiesDialog.vue

@@ -63,6 +63,7 @@ q-card.page-properties-dialog
               color='primary'
             )
         q-input(
+          v-if='pageStore.path !== `home`'
           v-model='pageStore.alias'
           :label='t(`editor.props.alias`)'
           outlined

+ 2 - 1
ux/src/stores/editor.js

@@ -24,7 +24,8 @@ export const useEditorStore = defineStore('editor', {
     editors: {},
     configIsLoaded: false,
     reasonForChange: '',
-    ignoreRouteChange: false
+    ignoreRouteChange: false,
+    pendingAssets: []
   }),
   getters: {
     hasPendingChanges: (state) => {