Explorar o código

feat: asset rename + asset delete dialogs + linting fixes

NGPixel hai 1 ano
pai
achega
291fe26272
Modificáronse 42 ficheiros con 597 adicións e 152 borrados
  1. 1 1
      .vscode/settings.json
  2. 74 65
      server/graph/resolvers/asset.mjs
  3. 3 3
      server/graph/schemas/asset.graphql
  4. 9 0
      server/locales/en.json
  5. 3 3
      server/models/assets.mjs
  6. 11 23
      server/models/pages.mjs
  7. 1 0
      server/package.json
  8. 61 0
      server/pnpm-lock.yaml
  9. 31 0
      server/templates/demo/home.md
  10. 11 1
      ux/.eslintrc.js
  11. 1 1
      ux/jsconfig.json
  12. 2 1
      ux/package.json
  13. 23 0
      ux/pnpm-lock.yaml
  14. 1 0
      ux/public/_assets/icons/ultraviolet-image.svg
  15. 2 2
      ux/src/components/ApiKeyCopyDialog.vue
  16. 2 2
      ux/src/components/ApiKeyCreateDialog.vue
  17. 109 0
      ux/src/components/AssetDeleteDialog.vue
  18. 165 0
      ux/src/components/AssetRenameDialog.vue
  19. 51 18
      ux/src/components/FileManager.vue
  20. 2 0
      ux/src/components/FooterNav.vue
  21. 1 0
      ux/src/components/HeaderSearch.vue
  22. 4 2
      ux/src/components/IconPickerDialog.vue
  23. 1 0
      ux/src/components/LocaleSelectorMenu.vue
  24. 1 0
      ux/src/components/NavSidebar.vue
  25. 4 1
      ux/src/components/PageActionsCol.vue
  26. 2 1
      ux/src/components/PageTags.vue
  27. 1 1
      ux/src/components/SocialSharingMenu.vue
  28. 6 6
      ux/src/components/TreeBrowserDialog.vue
  29. 1 0
      ux/src/layouts/AdminLayout.vue
  30. 0 1
      ux/src/layouts/MainLayout.vue
  31. 1 1
      ux/src/pages/AdminExtensions.vue
  32. 0 1
      ux/src/pages/AdminFlags.vue
  33. 1 1
      ux/src/pages/AdminLocale.vue
  34. 0 1
      ux/src/pages/AdminLogin.vue
  35. 2 2
      ux/src/pages/AdminMetrics.vue
  36. 0 1
      ux/src/pages/AdminSystem.vue
  37. 1 1
      ux/src/pages/AdminTheme.vue
  38. 0 1
      ux/src/pages/AdminUsers.vue
  39. 0 6
      ux/src/pages/AdminUtilities.vue
  40. 1 1
      ux/src/pages/Index.vue
  41. 6 3
      ux/src/pages/Search.vue
  42. 1 1
      ux/src/stores/site.js

+ 1 - 1
.vscode/settings.json

@@ -8,7 +8,7 @@
     "vue"
   ],
   "editor.codeActionsOnSave": {
-    "source.fixAll.eslint": true
+    "source.fixAll.eslint": "explicit"
   },
   "i18n-ally.localesPaths": [
     "server/locales",

+ 74 - 65
server/graph/resolvers/asset.mjs

@@ -1,6 +1,7 @@
 import _ from 'lodash-es'
 import sanitize from 'sanitize-filename'
 import { generateError, generateSuccess } from '../../helpers/graph.mjs'
+import { decodeFolderPath, decodeTreePath, generateHash } from '../../helpers/common.mjs'
 import path from 'node:path'
 import fs from 'fs-extra'
 import { v4 as uuid } from 'uuid'
@@ -9,7 +10,12 @@ import { pipeline } from 'node:stream/promises'
 export default {
   Query: {
     async assetById(obj, args, context) {
-      return null
+      const asset = await WIKI.db.assets.query().findById(args.id)
+      if (asset) {
+        return asset
+      } else {
+        throw new Error('ERR_ASSET_NOT_FOUND')
+      }
     }
   },
   Mutation: {
@@ -18,75 +24,75 @@ export default {
      */
     async renameAsset(obj, args, context) {
       try {
-        const filename = sanitize(args.filename).toLowerCase()
+        const filename = sanitize(args.fileName).toLowerCase()
 
         const asset = await WIKI.db.assets.query().findById(args.id)
-        if (asset) {
+        const treeItem = await WIKI.db.tree.query().findById(args.id)
+        if (asset && treeItem) {
           // Check for extension mismatch
-          if (!_.endsWith(filename, asset.ext)) {
-            throw new WIKI.Error.AssetRenameInvalidExt()
+          if (!_.endsWith(filename, asset.fileExt)) {
+            throw new Error('ERR_ASSET_EXT_MISMATCH')
           }
 
           // Check for non-dot files changing to dotfile
-          if (asset.ext.length > 0 && filename.length - asset.ext.length < 1) {
-            throw new WIKI.Error.AssetRenameInvalid()
+          if (asset.fileExt.length > 0 && filename.length - asset.fileExt.length < 1) {
+            throw new Error('ERR_ASSET_INVALID_DOTFILE')
           }
 
           // Check for collision
-          const assetCollision = await WIKI.db.assets.query().where({
-            filename,
-            folderId: asset.folderId
+          const assetCollision = await WIKI.db.tree.query().where({
+            folderPath: treeItem.folderPath,
+            fileName: filename
           }).first()
           if (assetCollision) {
-            throw new WIKI.Error.AssetRenameCollision()
-          }
-
-          // Get asset folder path
-          let hierarchy = []
-          if (asset.folderId) {
-            hierarchy = await WIKI.db.assetFolders.getHierarchy(asset.folderId)
+            throw new Error('ERR_ASSET_ALREADY_EXISTS')
           }
 
           // Check source asset permissions
-          const assetSourcePath = (asset.folderId) ? hierarchy.map(h => h.slug).join('/') + `/${asset.filename}` : asset.filename
+          const assetSourcePath = (treeItem.folderPath) ? decodeTreePath(decodeFolderPath(treeItem.folderPath)) + `/${treeItem.fileName}` : treeItem.fileName
           if (!WIKI.auth.checkAccess(context.req.user, ['manage:assets'], { path: assetSourcePath })) {
-            throw new WIKI.Error.AssetRenameForbidden()
+            throw new Error('ERR_FORBIDDEN')
           }
 
           // Check target asset permissions
-          const assetTargetPath = (asset.folderId) ? hierarchy.map(h => h.slug).join('/') + `/${filename}` : filename
+          const assetTargetPath = (treeItem.folderPath) ? decodeTreePath(decodeFolderPath(treeItem.folderPath)) + `/${filename}` : filename
           if (!WIKI.auth.checkAccess(context.req.user, ['write:assets'], { path: assetTargetPath })) {
-            throw new WIKI.Error.AssetRenameTargetForbidden()
+            throw new Error('ERR_TARGET_FORBIDDEN')
           }
 
           // Update filename + hash
-          const fileHash = '' // assetHelper.generateHash(assetTargetPath)
+          const itemHash = generateHash(assetTargetPath)
           await WIKI.db.assets.query().patch({
-            filename: filename,
-            hash: fileHash
-          }).findById(args.id)
-
-          // Delete old asset cache
-          await asset.deleteAssetCache()
-
-          // Rename in Storage
-          await WIKI.db.storage.assetEvent({
-            event: 'renamed',
-            asset: {
-              ...asset,
-              path: assetSourcePath,
-              destinationPath: assetTargetPath,
-              moveAuthorId: context.req.user.id,
-              moveAuthorName: context.req.user.name,
-              moveAuthorEmail: context.req.user.email
-            }
-          })
+            fileName: filename
+          }).findById(asset.id)
+
+          await WIKI.db.tree.query().patch({
+            fileName: filename,
+            title: filename,
+            hash: itemHash
+          }).findById(treeItem.id)
+
+          // TODO: Delete old asset cache
+          WIKI.events.outbound.emit('purgeItemCache', itemHash)
+
+          // TODO: Rename in Storage
+          // await WIKI.db.storage.assetEvent({
+          //   event: 'renamed',
+          //   asset: {
+          //     ...asset,
+          //     path: assetSourcePath,
+          //     destinationPath: assetTargetPath,
+          //     moveAuthorId: context.req.user.id,
+          //     moveAuthorName: context.req.user.name,
+          //     moveAuthorEmail: context.req.user.email
+          //   }
+          // })
 
           return {
-            responseResult: generateSuccess('Asset has been renamed successfully.')
+            operation: generateSuccess('Asset has been renamed successfully.')
           }
         } else {
-          throw new WIKI.Error.AssetInvalid()
+          throw new Error('ERR_INVALID_ASSET')
         }
       } catch (err) {
         return generateError(err)
@@ -97,35 +103,38 @@ export default {
      */
     async deleteAsset(obj, args, context) {
       try {
-        const asset = await WIKI.db.assets.query().findById(args.id)
-        if (asset) {
+        const treeItem = await WIKI.db.tree.query().findById(args.id)
+        if (treeItem) {
           // Check permissions
-          const assetPath = await asset.getAssetPath()
+          const assetPath = (treeItem.folderPath) ? decodeTreePath(decodeFolderPath(treeItem.folderPath)) + `/${treeItem.fileName}` : treeItem.fileName
           if (!WIKI.auth.checkAccess(context.req.user, ['manage:assets'], { path: assetPath })) {
-            throw new WIKI.Error.AssetDeleteForbidden()
+            throw new Error('ERR_FORBIDDEN')
           }
 
-          await WIKI.db.knex('assetData').where('id', args.id).del()
-          await WIKI.db.assets.query().deleteById(args.id)
-          await asset.deleteAssetCache()
-
-          // Delete from Storage
-          await WIKI.db.storage.assetEvent({
-            event: 'deleted',
-            asset: {
-              ...asset,
-              path: assetPath,
-              authorId: context.req.user.id,
-              authorName: context.req.user.name,
-              authorEmail: context.req.user.email
-            }
-          })
+          // Delete from DB
+          await WIKI.db.assets.query().deleteById(treeItem.id)
+          await WIKI.db.tree.query().deleteById(treeItem.id)
+
+          // TODO: Delete asset cache
+          WIKI.events.outbound.emit('purgeItemCache', treeItem.hash)
+
+          // TODO: Delete from Storage
+          // await WIKI.db.storage.assetEvent({
+          //   event: 'deleted',
+          //   asset: {
+          //     ...asset,
+          //     path: assetPath,
+          //     authorId: context.req.user.id,
+          //     authorName: context.req.user.name,
+          //     authorEmail: context.req.user.email
+          //   }
+          // })
 
           return {
-            responseResult: generateSuccess('Asset has been deleted successfully.')
+            operation: generateSuccess('Asset has been deleted successfully.')
           }
         } else {
-          throw new WIKI.Error.AssetInvalid()
+          throw new Error('ERR_INVALID_ASSET')
         }
       } catch (err) {
         return generateError(err)
@@ -373,7 +382,7 @@ export default {
       try {
         await WIKI.db.assets.flushTempUploads()
         return {
-          responseResult: generateSuccess('Temporary Uploads have been flushed successfully.')
+          operation: generateSuccess('Temporary Uploads have been flushed successfully.')
         }
       } catch (err) {
         return generateError(err)

+ 3 - 3
server/graph/schemas/asset.graphql

@@ -5,13 +5,13 @@
 extend type Query {
   assetById(
     id: UUID!
-  ): [AssetItem]
+  ): AssetItem
 }
 
 extend type Mutation {
   renameAsset(
     id: UUID!
-    filename: String!
+    fileName: String!
   ): DefaultResponse
 
   deleteAsset(
@@ -39,7 +39,7 @@ extend type Mutation {
 
 type AssetItem {
   id: UUID
-  filename: String
+  fileName: String
   ext: String
   kind: AssetKind
   mime: String

+ 9 - 0
server/locales/en.json

@@ -1643,6 +1643,13 @@
   "fileman.aiFileType": "Adobe Illustrator Document",
   "fileman.aifFileType": "AIF Audio File",
   "fileman.apkFileType": "Android Package",
+  "fileman.assetDelete": "Delete Asset",
+  "fileman.assetDeleteConfirm": "Are you sure you want to delete {name}?",
+  "fileman.assetDeleteId": "Asset ID {id}",
+  "fileman.assetDeleteSuccess": "Asset deleted successfully.",
+  "fileman.assetFileName": "Asset Name",
+  "fileman.assetFileNameHint": "Filename of the asset, including the file extension.",
+  "fileman.assetRename": "Rename Asset",
   "fileman.aviFileType": "AVI Video File",
   "fileman.binFileType": "Binary File",
   "fileman.bz2FileType": "BZIP2 Archive",
@@ -1698,6 +1705,8 @@
   "fileman.pptxFileType": "Microsoft Powerpoint Presentation",
   "fileman.psdFileType": "Adobe Photoshop Document",
   "fileman.rarFileType": "RAR Archive",
+  "fileman.renameAssetInvalid": "Asset name is invalid.",
+  "fileman.renameAssetSuccess": "Asset renamed successfully",
   "fileman.renameFolderInvalidData": "One or more fields are invalid.",
   "fileman.renameFolderSuccess": "Folder renamed successfully.",
   "fileman.searchFolder": "Search folder...",

+ 3 - 3
server/models/assets.mjs

@@ -47,13 +47,13 @@ export class Asset extends Model {
   async $beforeUpdate(opt, context) {
     await super.$beforeUpdate(opt, context)
 
-    this.updatedAt = moment.utc().toISOString()
+    this.updatedAt = new Date().toISOString()
   }
   async $beforeInsert(context) {
     await super.$beforeInsert(context)
 
-    this.createdAt = moment.utc().toISOString()
-    this.updatedAt = moment.utc().toISOString()
+    this.createdAt = new Date().toISOString()
+    this.updatedAt = new Date().toISOString()
   }
 
   async getAssetPath() {

+ 11 - 23
server/models/pages.mjs

@@ -13,19 +13,16 @@ import CleanCSS from 'clean-css'
 import TurndownService from 'turndown'
 import { gfm as turndownPluginGfm } from '@joplin/turndown-plugin-gfm'
 import cheerio from 'cheerio'
+import matter from 'gray-matter'
 
-import { Locale } from './locales.mjs'
 import { PageLink } from './pageLinks.mjs'
-import { Tag } from './tags.mjs'
 import { User } from './users.mjs'
 
 const pageRegex = /^[a-zA-Z0-9-_/]*$/
 const aliasRegex = /^[a-zA-Z0-9-_]*$/
 
 const frontmatterRegex = {
-  html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/,
-  legacy: /^(<!-- TITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)?(<!-- SUBTITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)*([\w\W]*)*/i,
-  markdown: /^(-{3}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{3})?(?:\n|\r)*([\w\W]*)*/
+  html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/
 }
 
 /**
@@ -178,30 +175,20 @@ export class Page extends Model {
    * @returns {Object} Parsed Page Metadata with Raw Content
    */
   static parseMetadata (raw, contentType) {
-    let result
     try {
       switch (contentType) {
-        case 'markdown':
-          result = frontmatterRegex.markdown.exec(raw)
-          if (result[2]) {
+        case 'markdown': {
+          const result = matter(raw)
+          if (!result?.isEmpty) {
             return {
-              ...yaml.safeLoad(result[2]),
-              content: result[3]
-            }
-          } else {
-            // Attempt legacy v1 format
-            result = frontmatterRegex.legacy.exec(raw)
-            if (result[2]) {
-              return {
-                title: result[2],
-                description: result[4],
-                content: result[5]
-              }
+              content: result.content,
+              ...result.data
             }
           }
           break
-        case 'html':
-          result = frontmatterRegex.html.exec(raw)
+        }
+        case 'html': {
+          const result = frontmatterRegex.html.exec(raw)
           if (result[2]) {
             return {
               ...yaml.safeLoad(result[2]),
@@ -209,6 +196,7 @@ export class Page extends Model {
             }
           }
           break
+        }
       }
     } catch (err) {
       WIKI.logger.warn('Failed to parse page metadata. Invalid syntax.')

+ 1 - 0
server/package.json

@@ -85,6 +85,7 @@
     "graphql-rate-limit-directive": "2.0.4",
     "graphql-tools": "9.0.0",
     "graphql-upload": "16.0.2",
+    "gray-matter": "4.0.3",
     "he": "1.2.0",
     "highlight.js": "11.9.0",
     "image-size": "1.0.2",

+ 61 - 0
server/pnpm-lock.yaml

@@ -152,6 +152,9 @@ dependencies:
   graphql-upload:
     specifier: 16.0.2
     version: 16.0.2(graphql@16.8.1)
+  gray-matter:
+    specifier: 4.0.3
+    version: 4.0.3
   he:
     specifier: 1.2.0
     version: 1.2.0
@@ -2114,6 +2117,12 @@ packages:
     resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
     dev: false
 
+  /argparse@1.0.10:
+    resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
+    dependencies:
+      sprintf-js: 1.0.3
+    dev: false
+
   /argparse@2.0.1:
     resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
 
@@ -3852,6 +3861,13 @@ packages:
       - supports-color
     dev: false
 
+  /extend-shallow@2.0.1:
+    resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
+    engines: {node: '>=0.10.0'}
+    dependencies:
+      is-extendable: 0.1.1
+    dev: false
+
   /extend@3.0.2:
     resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
     dev: false
@@ -4290,6 +4306,16 @@ packages:
     engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
     dev: false
 
+  /gray-matter@4.0.3:
+    resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
+    engines: {node: '>=6.0'}
+    dependencies:
+      js-yaml: 3.14.1
+      kind-of: 6.0.3
+      section-matter: 1.0.0
+      strip-bom-string: 1.0.0
+    dev: false
+
   /has-bigints@1.0.2:
     resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
     dev: true
@@ -4625,6 +4651,11 @@ packages:
       has-tostringtag: 1.0.0
     dev: true
 
+  /is-extendable@0.1.1:
+    resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
+    engines: {node: '>=0.10.0'}
+    dev: false
+
   /is-extglob@2.1.1:
     resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
     engines: {node: '>=0.10.0'}
@@ -4762,6 +4793,14 @@ packages:
     dev: false
     optional: true
 
+  /js-yaml@3.14.1:
+    resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
+    hasBin: true
+    dependencies:
+      argparse: 1.0.10
+      esprima: 4.0.1
+    dev: false
+
   /js-yaml@4.1.0:
     resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
     hasBin: true
@@ -4895,6 +4934,11 @@ packages:
       json-buffer: 3.0.1
     dev: true
 
+  /kind-of@6.0.3:
+    resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
+    engines: {node: '>=0.10.0'}
+    dev: false
+
   /klaw@4.1.0:
     resolution: {integrity: sha512-1zGZ9MF9H22UnkpVeuaGKOjfA2t6WrfdrJmGjy16ykcjnKQDmHVX+KI477rpbGevz/5FD4MC3xf1oxylBgcaQw==}
     engines: {node: '>=14.14.0'}
@@ -6591,6 +6635,14 @@ packages:
       apg-lib: 3.2.0
     dev: false
 
+  /section-matter@1.0.0:
+    resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
+    engines: {node: '>=4'}
+    dependencies:
+      extend-shallow: 2.0.1
+      kind-of: 6.0.3
+    dev: false
+
   /semver@6.3.1:
     resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
     hasBin: true
@@ -6821,6 +6873,10 @@ packages:
     engines: {node: '>= 10.x'}
     dev: false
 
+  /sprintf-js@1.0.3:
+    resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
+    dev: false
+
   /statuses@2.0.1:
     resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
     engines: {node: '>= 0.8'}
@@ -6890,6 +6946,11 @@ packages:
     dependencies:
       ansi-regex: 5.0.1
 
+  /strip-bom-string@1.0.0:
+    resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
+    engines: {node: '>=0.10.0'}
+    dev: false
+
   /strip-bom@3.0.0:
     resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
     engines: {node: '>=4'}

+ 31 - 0
server/templates/demo/home.md

@@ -0,0 +1,31 @@
+---
+title: Home
+description: Welcome to your wiki!
+published: true
+---
+
+Feel free to modify this page (or delete it!).
+
+## Next Steps
+
+- Configure your wiki in the [Administration Area](/_admin).
+- [Modify your profile](/_profile) to set preferences or change your password.
+- Create new pages by clicking the <kbd>+</kbd> button in the upper right corner.
+- Edit the navigation by clicking the <kbd>Edit Nav</kbd> button in the lower left corner.
+
+## Read the documentation
+
+How do permissions work? How can I make my wiki publicly accessible?
+
+It's all [in the docs](https://beta.js.wiki/docs/admin/groups)!
+
+## Example Blocks
+
+Did you know that you can insert dynamic [blocks](https://beta.js.wiki/docs/editor/blocks)?
+
+For example, here are the 5 most recently updated pages on your wiki:
+
+::block-index{orderBy="updatedAt" orderByDirection="desc" limit="5"}
+::
+
+This list will automatically update as you create / edit pages.

+ 11 - 1
ux/.eslintrc.js

@@ -26,6 +26,8 @@ module.exports = {
     'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
     // 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
 
+    'plugin:vue-pug/vue3-strongly-recommended',
+
     'standard'
   ],
 
@@ -72,6 +74,14 @@ module.exports = {
     'vue/multi-word-component-names': 'off',
 
     // allow debugger during development only
-    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
+    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
+
+    // disable bogus rules
+    'vue/valid-template-root': 'off',
+    'vue/no-parsing-error': 'off',
+    'vue-pug/no-parsing-error': 'off',
+    'vue/valid-v-for': 'off',
+    'vue/html-quotes': ['warn', 'single'],
+    'vue/max-attributes-per-line': 'off'
   }
 }

+ 1 - 1
ux/jsconfig.json

@@ -40,7 +40,7 @@
   "vueCompilerOptions": {
     "target": 3,
     "plugins": [
-      "@volar/vue-language-plugin-pug"
+      "@vue/language-plugin-pug"
     ]
   }
 }

+ 2 - 1
ux/package.json

@@ -117,7 +117,8 @@
     "eslint-plugin-import": "2.29.0",
     "eslint-plugin-n": "16.3.1",
     "eslint-plugin-promise": "6.1.1",
-    "eslint-plugin-vue": "9.18.1"
+    "eslint-plugin-vue": "9.18.1",
+    "eslint-plugin-vue-pug": "0.6.1"
   },
   "engines": {
     "node": ">= 18.0",

+ 23 - 0
ux/pnpm-lock.yaml

@@ -325,6 +325,9 @@ devDependencies:
   eslint-plugin-vue:
     specifier: 9.18.1
     version: 9.18.1(eslint@8.54.0)
+  eslint-plugin-vue-pug:
+    specifier: 0.6.1
+    version: 0.6.1(eslint-plugin-vue@9.18.1)(vue-eslint-parser@9.3.2)
 
 packages:
 
@@ -2736,6 +2739,17 @@ packages:
       eslint: 8.54.0
     dev: true
 
+  /eslint-plugin-vue-pug@0.6.1(eslint-plugin-vue@9.18.1)(vue-eslint-parser@9.3.2):
+    resolution: {integrity: sha512-wOId81xH42+X9M0qVRU5o39KJ3Phd+fdgemPNAy1cD9hUROp/aSHHapOP7muDV/sHmu9zP/mU34yDfCfQWUWEQ==}
+    peerDependencies:
+      eslint-plugin-vue: ^9.8.0
+    dependencies:
+      eslint-plugin-vue: 9.18.1(eslint@8.54.0)
+      vue-eslint-parser-template-tokenizer-pug: 0.4.10(vue-eslint-parser@9.3.2)
+    transitivePeerDependencies:
+      - vue-eslint-parser
+    dev: true
+
   /eslint-plugin-vue@9.18.1(eslint@8.54.0):
     resolution: {integrity: sha512-7hZFlrEgg9NIzuVik2I9xSnJA5RsmOfueYgsUGUokEDLJ1LHtxO0Pl4duje1BriZ/jDWb+44tcIlC3yi0tdlZg==}
     engines: {node: ^14.17.0 || >=16.0.0}
@@ -5478,6 +5492,15 @@ packages:
     dependencies:
       vue: 3.3.8(typescript@5.3.2)
 
+  /vue-eslint-parser-template-tokenizer-pug@0.4.10(vue-eslint-parser@9.3.2):
+    resolution: {integrity: sha512-Npzjna9PUJzIal/o7hOo4D7dF4hqjHwTafBtLgdtja2LZuCc4UT5BU7dyYJeKb9s1SnCFBflHMg3eFA3odq6bg==}
+    peerDependencies:
+      vue-eslint-parser: ^9.0.0
+    dependencies:
+      pug-lexer: 5.0.1
+      vue-eslint-parser: 9.3.2(eslint@8.54.0)
+    dev: true
+
   /vue-eslint-parser@9.3.2(eslint@8.54.0):
     resolution: {integrity: sha512-q7tWyCVaV9f8iQyIA5Mkj/S6AoJ9KBN8IeUSf3XEmBrOtxOZnfTg5s4KClbZBCK3GtnT/+RyCLZyDHuZwTuBjg==}
     engines: {node: ^14.17.0 || >=16.0.0}

+ 1 - 0
ux/public/_assets/icons/ultraviolet-image.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#dff0fe" d="M1.5 4.5H38.5V35.5H1.5z"/><path fill="#4788c7" d="M38,5v30H2V5H38 M39,4H1v32h38V4L39,4z"/><path fill="#98ccfd" d="M27.45 19.612L20 25.437 30.247 35 38 35 38 29.112zM30 10A3 3 0 1 0 30 16 3 3 0 1 0 30 10z"/><path fill="#b6dcfe" d="M32.468 35L2 35 2 27.421 14 17.316z"/><g><path fill="#fff" d="M36,7v26H4V7H36 M38,5H2v30h36V5L38,5z"/></g></svg>

+ 2 - 2
ux/src/components/ApiKeyCopyDialog.vue

@@ -5,7 +5,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
       q-icon(name='img:/_assets/icons/fluent-key-2.svg', left, size='sm')
       span {{t(`admin.api.copyKeyTitle`)}}
     q-card-section.card-negative
-      i18n-t(tag='span', keypath='admin.api.newKeyCopyWarn')
+      i18n-t(tag='span', keypath='admin.api.newKeyCopyWarn', scope='global')
         template(#bold)
           strong {{t('admin.api.newKeyCopyWarnBold')}}
     q-form.q-py-sm
@@ -15,7 +15,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
           q-input(
             type='textarea'
             outlined
-            v-model='props.keyValue'
+            :model-value='props.keyValue'
             dense
             hide-bottom-space
             :label='t(`admin.api.key`)'

+ 2 - 2
ux/src/components/ApiKeyCreateDialog.vue

@@ -64,11 +64,11 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
             )
             template(v-slot:selected)
               .text-caption(v-if='state.keyGroups.length > 1')
-                i18n-t(keypath='admin.api.groupsSelected')
+                i18n-t(keypath='admin.api.groupsSelected', scope='global')
                   template(#count)
                     strong {{ state.keyGroups.length }}
               .text-caption(v-else-if='state.keyGroups.length === 1')
-                i18n-t(keypath='admin.api.groupSelected')
+                i18n-t(keypath='admin.api.groupSelected', scope='global')
                   template(#group)
                     strong {{ selectedGroupName }}
               span(v-else)

+ 109 - 0
ux/src/components/AssetDeleteDialog.vue

@@ -0,0 +1,109 @@
+<template lang='pug'>
+q-dialog(ref='dialogRef', @hide='onDialogHide')
+  q-card(style='min-width: 550px; max-width: 850px;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-delete-bin.svg', left, size='sm')
+      span {{ t(`fileman.assetDelete`) }}
+    q-card-section
+      .text-body2
+        i18n-t(keypath='fileman.assetDeleteConfirm')
+          template(#name)
+            strong {{assetName}}
+      .text-caption.text-grey.q-mt-sm {{ t('fileman.assetDeleteId', { id: assetId }) }}
+    q-card-actions.card-actions
+      q-space
+      q-btn.acrylic-btn(
+        flat
+        :label='t(`common.actions.cancel`)'
+        color='grey'
+        padding='xs md'
+        @click='onDialogCancel'
+        )
+      q-btn(
+        unelevated
+        :label='t(`common.actions.delete`)'
+        color='negative'
+        padding='xs md'
+        @click='confirm'
+        :loading='state.isLoading'
+        )
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { reactive } from 'vue'
+
+// PROPS
+
+const props = defineProps({
+  assetId: {
+    type: String,
+    required: true
+  },
+  assetName: {
+    type: String,
+    required: true
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  isLoading: false
+})
+
+// METHODS
+
+async function confirm () {
+  state.isLoading = true
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation deleteAsset ($id: UUID!) {
+          deleteAsset(id: $id) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        id: props.assetId
+      }
+    })
+    if (resp?.data?.deleteAsset?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('fileman.assetDeleteSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.deleteAsset?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.isLoading = false
+}
+</script>

+ 165 - 0
ux/src/components/AssetRenameDialog.vue

@@ -0,0 +1,165 @@
+<template lang='pug'>
+q-dialog(ref='dialogRef', @hide='onDialogHide')
+  q-card(style='min-width: 650px;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-rename.svg', left, size='sm')
+      span {{ t(`fileman.assetRename`) }}
+    q-form.q-py-sm(@submit='rename')
+      q-item
+        blueprint-icon.self-start(icon='image')
+        q-item-section
+          q-input(
+            autofocus
+            outlined
+            v-model='state.path'
+            dense
+            hide-bottom-space
+            :label='t(`fileman.assetFileName`)'
+            :aria-label='t(`fileman.assetFileName`)'
+            :hint='t(`fileman.assetFileNameHint`)'
+            lazy-rules='ondemand'
+            @keyup.enter='rename'
+            )
+    q-card-actions.card-actions
+      q-space
+      q-btn.acrylic-btn(
+        flat
+        :label='t(`common.actions.cancel`)'
+        color='grey'
+        padding='xs md'
+        @click='onDialogCancel'
+        )
+      q-btn(
+        unelevated
+        :label='t(`common.actions.rename`)'
+        color='primary'
+        padding='xs md'
+        @click='rename'
+        :loading='state.loading > 0'
+        )
+    q-inner-loading(:showing='state.loading > 0')
+      q-spinner(color='accent', size='lg')
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { onMounted, reactive, ref } from 'vue'
+
+// PROPS
+
+const props = defineProps({
+  assetId: {
+    type: String,
+    required: true
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  path: '',
+  loading: false
+})
+
+// METHODS
+
+async function rename () {
+  state.loading++
+  try {
+    if (state.path?.length < 2 || !state.path?.includes('.')) {
+      throw new Error(t('fileman.renameAssetInvalid'))
+    }
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation renameAsset (
+          $id: UUID!
+          $fileName: String!
+          ) {
+          renameAsset (
+            id: $id
+            fileName: $fileName
+            ) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        id: props.assetId,
+        fileName: state.path
+      }
+    })
+    if (resp?.data?.renameAsset?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('fileman.renameAssetSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.renameAsset?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.loading--
+}
+
+// MOUNTED
+
+onMounted(async () => {
+  state.loading++
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query fetchAssetForRename (
+          $id: UUID!
+          ) {
+          assetById (
+            id: $id
+            ) {
+            id
+            fileName
+          }
+        }
+      `,
+      fetchPolicy: 'network-only',
+      variables: {
+        id: props.assetId
+      }
+    })
+    if (resp?.data?.assetById?.id !== props.assetId) {
+      throw new Error('Failed to fetch asset data.')
+    }
+    state.path = resp.data.assetById.fileName
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+    onDialogCancel()
+  }
+  state.loading--
+})
+</script>

+ 51 - 18
ux/src/components/FileManager.vue

@@ -1,9 +1,9 @@
-<template lang="pug">
+<template lang='pug'>
 q-layout.fileman(view='hHh lpR lFr', container)
   q-header.card-header
     q-toolbar(dark)
       q-icon(name='img:/_assets/icons/fluent-folder.svg', left, size='md')
-      span {{t(`fileman.title`)}}
+      span {{ t(`fileman.title`) }}
     q-toolbar(dark)
       q-btn.q-mr-sm.acrylic-btn(
         flat
@@ -23,9 +23,9 @@ q-layout.fileman(view='hHh lpR lFr', container)
         :label='t(`fileman.searchFolder`)'
         :debounce='500'
         )
-        template(v-slot:prepend)
+        template(#prepend)
           q-icon(name='las la-search')
-        template(v-slot:append)
+        template(#append)
           q-icon.cursor-pointer(
             name='las la-times'
             @click='state.search=``'
@@ -78,9 +78,10 @@ q-layout.fileman(view='hHh lpR lFr', container)
           )
           .fileman-details-row(
             v-for='item of currentFileDetails.items'
+            :key='item.id'
             )
-            label {{item.label}}
-            span {{item.value}}
+            label {{ item.label }}
+            span {{ item.value }}
           template(v-if='insertMode')
             q-separator.q-my-md
             q-btn.full-width(
@@ -97,7 +98,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
       q-toolbar.fileman-toolbar
         template(v-if='state.isUploading')
           .fileman-progressbar
-            div(:style='`width: ` + state.uploadPercentage + `%`') {{state.uploadPercentage}}%
+            div(:style='`width: ` + state.uploadPercentage + `%`') {{ state.uploadPercentage }}%
           q-btn.acrylic-btn.q-ml-sm(
             flat
             dense
@@ -117,9 +118,8 @@ q-layout.fileman(view='hHh lpR lFr', container)
             color='grey'
             :aria-label='t(`fileman.viewOptions`)'
             icon='las la-th-list'
-            @click=''
             )
-            q-tooltip(anchor='bottom middle', self='top middle') {{t(`fileman.viewOptions`)}}
+            q-tooltip(anchor='bottom middle', self='top middle') {{ t(`fileman.viewOptions`) }}
             q-menu(
               transition-show='jump-down'
               transition-hide='jump-up'
@@ -128,7 +128,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
               )
               q-card.q-pa-sm
                 .text-center
-                  small.text-grey {{t(`fileman.viewOptions`)}}
+                  small.text-grey {{ t(`fileman.viewOptions`) }}
                 q-list(dense)
                   q-separator.q-my-sm
                   q-item(clickable)
@@ -183,7 +183,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
             icon='las la-redo-alt'
             @click='reloadFolder(state.currentFolderId)'
             )
-            q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.refresh`)}}
+            q-tooltip(anchor='bottom middle', self='top middle') {{ t(`common.actions.refresh`) }}
           q-separator.q-mr-sm(inset, vertical)
           q-btn.q-mr-sm(
             flat
@@ -193,7 +193,6 @@ q-layout.fileman(view='hHh lpR lFr', container)
             :label='t(`common.actions.new`)'
             :aria-label='t(`common.actions.new`)'
             icon='las la-plus-circle'
-            @click=''
             )
             new-menu(
               :hide-asset-btn='true'
@@ -236,16 +235,16 @@ q-layout.fileman(view='hHh lpR lFr', container)
                 clickable
                 active-class='active'
                 :active='item.id === state.currentFileId'
-                @click.native='selectItem(item)'
-                @dblclick.native='doubleClickItem(item)'
+                @click='selectItem(item)'
+                @dblclick='doubleClickItem(item)'
                 )
                 q-item-section.fileman-filelist-icon(avatar)
                   q-icon(:name='item.icon', :size='state.isCompact ? `md` : `xl`')
                 q-item-section.fileman-filelist-label
-                  q-item-label {{usePathTitle ? item.fileName : item.title}}
-                  q-item-label(caption, v-if='!state.isCompact') {{item.caption}}
+                  q-item-label {{ usePathTitle ? item.fileName : item.title }}
+                  q-item-label(caption, v-if='!state.isCompact') {{ item.caption }}
                 q-item-section.fileman-filelist-side(side, v-if='item.side')
-                  .text-caption {{item.side}}
+                  .text-caption {{ item.side }}
                 //- RIGHT-CLICK MENU
                 q-menu.translucent-menu(
                   touch-position
@@ -341,6 +340,7 @@ import { useSiteStore } from 'src/stores/site'
 import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
 import FolderDeleteDialog from 'src/components/FolderDeleteDialog.vue'
 import FolderRenameDialog from 'src/components/FolderRenameDialog.vue'
+import AssetRenameDialog from 'src/components/AssetRenameDialog.vue'
 import LocaleSelectorMenu from 'src/components/LocaleSelectorMenu.vue'
 
 // QUASAR
@@ -787,6 +787,7 @@ function reloadFolder (folderId) {
   treeComp.value.resetLoaded()
 }
 
+// --------------------------------------
 // PAGE METHODS
 // --------------------------------------
 
@@ -811,6 +812,34 @@ function delPage (pageId, pageName) {
   })
 }
 
+// --------------------------------------
+// ASSET METHODS
+// --------------------------------------
+
+function renameAsset (assetId) {
+  $q.dialog({
+    component: AssetRenameDialog,
+    componentProps: {
+      assetId
+    }
+  }).onOk(async () => {
+    // -> Reload current view
+    await loadTree({ parentId: state.currentFolderId })
+  })
+}
+
+function delAsset (assetId, assetName) {
+  $q.dialog({
+    component: defineAsyncComponent(() => import('src/components/AssetDeleteDialog.vue')),
+    componentProps: {
+      assetId,
+      assetName
+    }
+  }).onOk(() => {
+    loadTree(state.currentFolderId, null)
+  })
+}
+
 // --------------------------------------
 // UPLOAD METHODS
 // --------------------------------------
@@ -991,7 +1020,7 @@ function renameItem (item) {
       break
     }
     case 'asset': {
-      // TODO: Rename asset
+      renameAsset(item.id)
       break
     }
   }
@@ -999,6 +1028,10 @@ function renameItem (item) {
 
 function delItem (item) {
   switch (item.type) {
+    case 'asset': {
+      delAsset(item.id, item.title)
+      break
+    }
     case 'folder': {
       delFolder(item.id, true)
       break

+ 2 - 0
ux/src/components/FooterNav.vue

@@ -5,6 +5,7 @@ q-footer.site-footer
       v-if='hasSiteFooter'
       :keypath='isCopyright ? `common.footerCopyright` : `common.footerLicense`'
       tag='span'
+      scope='global'
       )
       template(#company)
         strong {{siteStore.company}}
@@ -15,6 +16,7 @@ q-footer.site-footer
     i18n-t(
       :keypath='props.generic ? `common.footerGeneric` : `common.footerPoweredBy`'
       tag='span'
+      scope='global'
       )
       template(#link)
         a(href='https://js.wiki', target='_blank', ref='noopener noreferrer'): strong Wiki.js

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

@@ -66,6 +66,7 @@ q-toolbar(
       .flex.q-mb-md
         q-chip(
           v-for='tag of popularTags'
+          :key='tag'
           square
           color='grey-8'
           text-color='white'

+ 4 - 2
ux/src/components/IconPickerDialog.vue

@@ -1,5 +1,6 @@
+<!-- eslint-disable -->
 <template lang="pug">
-q-card.icon-picker(flat, style='width: 400px;')
+q-card.icon-picker(flat, style='width: 400px')
   q-tabs.text-primary(
     v-model='state.currentTab'
     no-caps
@@ -43,7 +44,8 @@ q-card.icon-picker(flat, style='width: 400px;')
               )
             q-item-section
               q-item-label {{scope.opt.name}}
-              q-item-label(caption): strong(:class='scope.selected ? `text-white` : `text-primary`') {{scope.opt.subset}}
+              q-item-label(caption)
+                strong(:class='scope.selected ? `text-white` : `text-primary`') {{scope.opt.subset}}
             q-item-section(side, v-if='scope.opt.subset')
               q-chip(
                 color='primary'

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

@@ -8,6 +8,7 @@ q-menu.translucent-menu(
   q-list(padding, style='min-width: 200px;')
     q-item(
       v-for='lang of siteStore.locales.active'
+      :key='lang.code'
       clickable
       @click='commonStore.setLocale(lang.code)'
       )

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

@@ -27,6 +27,7 @@ q-scroll-area.sidebar-nav(
           q-item(
             v-for='itemChild of item.children'
             :to='itemChild.target'
+            :key='itemChild.id'
             )
             q-item-section(side)
               q-icon(:name='itemChild.icon', color='white')

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

@@ -48,7 +48,10 @@
             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(
+              v-for='item of editorStore.pendingAssets'
+              :key='item.id'
+              )
               q-item-section(side)
                 q-icon(name='las la-file-image')
               q-item-section {{ item.fileName }}

+ 2 - 1
ux/src/components/PageTags.vue

@@ -38,7 +38,8 @@
         q-item-section(side)
           q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)', size='sm')
         q-item-section
-          q-item-label(v-html='scope.opt')
+          q-item-label
+            span(v-html='scope.opt')
 </template>
 
 <script setup>

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

@@ -7,7 +7,7 @@ q-menu(
   @before-hide='menuHidden'
   )
   q-list(dense, padding)
-    q-item(clickable, @click='', ref='copyUrlButton')
+    q-item(clickable, ref='copyUrlButton')
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='las la-clipboard', size='sm')
       q-item-section.q-pr-md Copy URL

+ 6 - 6
ux/src/components/TreeBrowserDialog.vue

@@ -1,15 +1,15 @@
-<template lang="pug">
+<template lang='pug'>
 q-dialog(ref='dialogRef', @hide='onDialogHide')
   q-card.page-save-dialog(style='width: 860px; max-width: 90vw;')
     q-card-section.card-header(v-if='props.mode === `savePage`')
       q-icon(name='img:/_assets/icons/fluent-save-as.svg', left, size='sm')
-      span {{t('pageSaveDialog.title')}}
+      span {{ t('pageSaveDialog.title') }}
     q-card-section.card-header(v-else-if='props.mode === `duplicatePage`')
       q-icon(name='img:/_assets/icons/color-documents.svg', left, size='sm')
-      span {{t('pageDuplicateDialog.title')}}
+      span {{ t('pageDuplicateDialog.title') }}
     q-card-section.card-header(v-else-if='props.mode === `renamePage`')
       q-icon(name='img:/_assets/icons/fluent-rename.svg', left, size='sm')
-      span {{t('pageRenameDialog.title')}}
+      span {{ t('pageRenameDialog.title') }}
     .row.page-save-dialog-browser
       .col-4
         q-scroll-area(
@@ -36,13 +36,13 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
             clickable
             active-class='active'
             :active='item.id === state.currentFileId'
-            @click.native='selectItem(item)'
+            @click='selectItem(item)'
             )
             q-item-section(side)
               q-icon(:name='item.icon', size='sm')
             q-item-section
               q-item-label {{item.title}}
-    .page-save-dialog-path.font-robotomono {{currentFolderPath}}
+    .page-save-dialog-path.font-robotomono {{ currentFolderPath }}
     q-list.q-py-sm
       q-item
         blueprint-icon(icon='new-document')

+ 1 - 0
ux/src/layouts/AdminLayout.vue

@@ -28,6 +28,7 @@ q-layout.admin(view='hHh Lpr lff')
             q-list(separator, padding)
               q-item(
                 v-for='lang of adminStore.locales'
+                :key='lang.code'
                 clickable
                 @click='commonStore.setLocale(lang.code)'
                 )

+ 0 - 1
ux/src/layouts/MainLayout.vue

@@ -13,7 +13,6 @@ q-layout(view='hHh Lpr lff')
         icon='las la-globe'
         color='white'
         aria-label='Switch Locale'
-        @click=''
         )
         locale-selector-menu(anchor='top right' self='top left')
         q-tooltip(anchor='center right' self='center left') Switch Locale

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

@@ -32,7 +32,7 @@ q-page.admin-extensions
       q-card
         q-list(separator)
           q-item(
-            v-for='(ext, idx) of state.extensions'
+            v-for='ext of state.extensions'
             :key='`ext-` + ext.key'
             )
             blueprint-icon(icon='module')

+ 0 - 1
ux/src/pages/AdminFlags.vue

@@ -101,7 +101,6 @@ q-page.admin-flags
               icon='las la-code'
               color='primary'
               text-color='white'
-              @click=''
               disabled
             )
 

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

@@ -84,7 +84,7 @@ q-page.admin-locale
           .text-caption(:class='$q.dark.isActive ? `text-grey-4` : `text-grey-7`') Select the locales that can be used on this site.
 
         q-item(
-          v-for='(lc, idx) of state.locales'
+          v-for='lc of state.locales'
           :key='lc.code'
           :tag='lc.code !== state.selectedLocale ? `label` : null'
           )

+ 0 - 1
ux/src/pages/AdminLogin.vue

@@ -177,7 +177,6 @@ q-page.admin-login
                 q-card-section.col-auto.q-pr-none
                   q-icon(name='las la-info-circle', size='sm')
                 q-card-section.text-caption {{ t('admin.login.providersVisbleWarning') }}
-
 </template>
 
 <script setup>

+ 2 - 2
ux/src/pages/AdminMetrics.vue

@@ -54,7 +54,7 @@ q-page.admin-api
           q-card-section.col-auto.q-pr-none
             q-icon(name='las la-info-circle', size='sm')
           q-card-section
-            i18n-t(tag='span', keypath='admin.metrics.endpoint')
+            i18n-t(tag='span', keypath='admin.metrics.endpoint', scope='global')
               template(#endpoint)
                 strong.font-robotomono /metrics
             .text-caption {{ t('admin.metrics.endpointWarning') }}
@@ -66,7 +66,7 @@ q-page.admin-api
           q-card-section.col-auto.q-pr-none
             q-icon(name='las la-key', size='sm')
           q-card-section
-            i18n-t(tag='span', keypath='admin.metrics.auth')
+            i18n-t(tag='span', keypath='admin.metrics.auth', scope='global')
               template(#headerName)
                 strong.font-robotomono Authorization
               template(#tokenType)

+ 0 - 1
ux/src/pages/AdminSystem.vue

@@ -32,7 +32,6 @@ q-page.admin-system
         icon='mdi-clipboard-text-outline'
         label='Copy System Info'
         color='primary'
-        @click=''
         :disabled='state.loading > 0'
       )
   q-separator(inset)

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

@@ -65,7 +65,7 @@ q-page.admin-theme
               unchecked-icon='las la-times'
               :aria-label='t(`admin.theme.darkMode`)'
               )
-        template(v-for='(cl, idx) of colorKeys', :key='cl')
+        template(v-for='cl of colorKeys', :key='cl')
           q-separator.q-my-sm(inset)
           q-item
             blueprint-icon(icon='fill-color')

+ 0 - 1
ux/src/pages/AdminUsers.vue

@@ -39,7 +39,6 @@ q-page.admin-groups
         unelevated
         color='secondary'
         :aria-label='t(`admin.users.defaults`)'
-        @click=''
         )
         q-tooltip {{ t(`admin.users.defaults`) }}
         user-defaults-menu

+ 0 - 6
ux/src/pages/AdminUtilities.vue

@@ -44,7 +44,6 @@ q-page.admin-utilities
               flat
               icon='las la-arrow-circle-right'
               color='primary'
-              @click=''
               :label='t(`common.actions.proceed`)'
             )
         q-item
@@ -57,7 +56,6 @@ q-page.admin-utilities
               flat
               icon='las la-arrow-circle-right'
               color='primary'
-              @click=''
               :label='t(`common.actions.proceed`)'
             )
         q-item
@@ -70,7 +68,6 @@ q-page.admin-utilities
               flat
               icon='las la-arrow-circle-right'
               color='primary'
-              @click=''
               :label='t(`common.actions.proceed`)'
             )
         q-item
@@ -83,7 +80,6 @@ q-page.admin-utilities
               flat
               icon='las la-arrow-circle-right'
               color='primary'
-              @click=''
               :label='t(`common.actions.proceed`)'
             )
         q-item
@@ -108,7 +104,6 @@ q-page.admin-utilities
               flat
               icon='las la-arrow-circle-right'
               color='primary'
-              @click=''
               :label='t(`common.actions.proceed`)'
             )
         q-item
@@ -121,7 +116,6 @@ q-page.admin-utilities
               flat
               icon='las la-arrow-circle-right'
               color='primary'
-              @click=''
               :label='t(`common.actions.proceed`)'
             )
 </template>

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

@@ -390,7 +390,7 @@ function refreshTocExpanded (baseToc, lvl) {
   }
 }
 .page-header {
-  min-height: 95px;
+  height: 95px;
 
   @at-root .body--light & {
     background: linear-gradient(to bottom, $grey-2 0%, $grey-1 100%);

+ 6 - 3
ux/src/pages/Search.vue

@@ -67,7 +67,8 @@ q-layout(view='hHh Lpr lff')
                 q-item-section(side)
                   q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)', size='sm')
                 q-item-section
-                  q-item-label(v-html='scope.opt')
+                  q-item-label
+                    span(v-html='scope.opt')
           //- q-input.q-mt-sm(
           //-   outlined
           //-   dense
@@ -103,7 +104,8 @@ q-layout(view='hHh Lpr lff')
                 q-item-section(side)
                   q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)')
                 q-item-section
-                  q-item-label(v-html='scope.opt.name')
+                  q-item-label
+                    span(v-html='scope.opt.name')
           q-select.q-mt-sm(
             outlined
             v-model='state.params.filterEditor'
@@ -154,7 +156,8 @@ q-layout(view='hHh Lpr lff')
             q-item-section
               q-item-label {{ item.title }}
               q-item-label(v-if='item.description', caption) {{ item.description }}
-              q-item-label.text-highlight(v-if='item.highlight', caption, v-html='item.highlight')
+              q-item-label.text-highlight(v-if='item.highlight', caption)
+                span(v-html='item.highlight')
             q-item-section(side)
               .flex.layout-search-itemtags
                 q-chip(

+ 1 - 1
ux/src/stores/site.js

@@ -64,7 +64,7 @@ export const useSiteStore = defineStore('site', {
     },
     sideDialogShown: false,
     sideDialogComponent: '',
-    docsBase: 'https://next.js.wiki/docs',
+    docsBase: 'https://beta.js.wiki/docs',
     nav: {
       currentId: null,
       items: []