浏览代码

feat: page browser dialog + various improvements

Nicolas Giard 2 年之前
父节点
当前提交
4cdeba80ce

+ 0 - 6
client/components/common/nav-header.vue

@@ -241,12 +241,6 @@
     page-selector(mode='create', v-model='duplicateOpts.modal', :open-handler='pageDuplicateHandle', :path='duplicateOpts.path', :locale='duplicateOpts.locale')
     page-selector(mode='create', v-model='duplicateOpts.modal', :open-handler='pageDuplicateHandle', :path='duplicateOpts.path', :locale='duplicateOpts.locale')
     page-delete(v-model='deletePageModal', v-if='path && path.length')
     page-delete(v-model='deletePageModal', v-if='path && path.length')
     page-convert(v-model='convertPageModal', v-if='path && path.length')
     page-convert(v-model='convertPageModal', v-if='path && path.length')
-
-    .nav-header-dev(v-if='isDevMode')
-      v-icon mdi-alert
-      div
-        .overline DEVELOPMENT VERSION
-        .overline This code base is NOT for production use!
 </template>
 </template>
 
 
 <script>
 <script>

+ 0 - 20
client/components/editor/editor-modal-editorselect.vue

@@ -36,26 +36,6 @@
                   img(src='/_assets-legacy/svg/editor-icon-ckeditor.svg', alt='Visual Editor', style='width: 36px;')
                   img(src='/_assets-legacy/svg/editor-icon-ckeditor.svg', alt='Visual Editor', style='width: 36px;')
                   .body-2.mt-2.primary--text Visual Editor
                   .body-2.mt-2.primary--text Visual Editor
                   .caption.grey--text Rich-text WYSIWYG
                   .caption.grey--text Rich-text WYSIWYG
-
-    v-card.radius-7.mt-2(color='teal darken-3', dark)
-      v-card-text.text-center.py-4
-        .subtitle-1.white--text {{$t('editor:select.customView')}}
-        v-container(grid-list-lg, fluid)
-          v-layout(row, wrap, justify-center)
-            v-flex(xs4)
-              v-hover
-                template(v-slot:default='{ hover }')
-                  v-card.radius-7.animated.fadeInUp(
-                    hover
-                    light
-                    ripple
-                    )
-                    v-card-text.text-center(@click='fromTemplate')
-                      img(src='/_assets-legacy/svg/icon-cube.svg', alt='From Template', style='width: 42px; opacity: .5;')
-                      .body-2.mt-1.teal--text From Template
-                      .caption.grey--text Use an existing page...
-
-    page-selector(mode='select', v-model='templateDialogIsShown', :open-handler='fromTemplateHandle', :path='path', :locale='locale', must-exist)
 </template>
 </template>
 
 
 <script>
 <script>

+ 2 - 10
server/db/migrations/3.0.0.js

@@ -58,12 +58,7 @@ exports.up = async knex => {
     .createTable('assetData', table => {
     .createTable('assetData', table => {
       table.uuid('id').notNullable().primary()
       table.uuid('id').notNullable().primary()
       table.binary('data').notNullable()
       table.binary('data').notNullable()
-    })
-    // ASSET FOLDERS -----------------------
-    .createTable('assetFolders', table => {
-      table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
-      table.string('name').notNullable()
-      table.string('slug').notNullable()
+      table.binary('preview')
     })
     })
     // AUTHENTICATION ----------------------
     // AUTHENTICATION ----------------------
     .createTable('authentication', table => {
     .createTable('authentication', table => {
@@ -351,13 +346,9 @@ exports.up = async knex => {
       table.uuid('siteId').notNullable().references('id').inTable('sites')
       table.uuid('siteId').notNullable().references('id').inTable('sites')
     })
     })
     .table('assets', table => {
     .table('assets', table => {
-      table.uuid('folderId').notNullable().references('id').inTable('assetFolders').index()
       table.uuid('authorId').notNullable().references('id').inTable('users')
       table.uuid('authorId').notNullable().references('id').inTable('users')
       table.uuid('siteId').notNullable().references('id').inTable('sites').index()
       table.uuid('siteId').notNullable().references('id').inTable('sites').index()
     })
     })
-    .table('assetFolders', table => {
-      table.uuid('parentId').references('id').inTable('assetFolders').index()
-    })
     .table('commentProviders', table => {
     .table('commentProviders', table => {
       table.uuid('siteId').notNullable().references('id').inTable('sites')
       table.uuid('siteId').notNullable().references('id').inTable('sites')
     })
     })
@@ -551,6 +542,7 @@ exports.up = async knex => {
         comments: false,
         comments: false,
         contributions: false,
         contributions: false,
         profile: true,
         profile: true,
+        reasonForChange: 'required',
         search: true
         search: true
       },
       },
       logoText: true,
       logoText: true,

+ 48 - 2
server/graph/resolvers/asset.js

@@ -2,7 +2,9 @@ const _ = require('lodash')
 const sanitize = require('sanitize-filename')
 const sanitize = require('sanitize-filename')
 const graphHelper = require('../../helpers/graph')
 const graphHelper = require('../../helpers/graph')
 const assetHelper = require('../../helpers/asset')
 const assetHelper = require('../../helpers/asset')
-const { setTimeout } = require('node:timers/promises')
+const path = require('node:path')
+const fs = require('fs-extra')
+const { v4: uuid } = require('uuid')
 
 
 module.exports = {
 module.exports = {
   Query: {
   Query: {
@@ -187,10 +189,54 @@ module.exports = {
      */
      */
     async uploadAssets(obj, args, context) {
     async uploadAssets(obj, args, context) {
       try {
       try {
+        const results = await Promise.allSettled(args.files.map(async fl => {
+          const { filename, mimetype, createReadStream } = await fl
+          WIKI.logger.debug(`Processing asset upload ${filename} of type ${mimetype}...`)
+
+          
+
+          if (!WIKI.extensions.ext.sharp.isInstalled) {
+            throw new Error('This feature requires the Sharp extension but it is not installed.')
+          }
+          if (!['.png', '.jpg', 'webp', '.gif'].some(s => filename.endsWith(s))) {
+            throw new Error('Invalid File Extension. Must be svg, png, jpg, webp or gif.')
+          }
+          const destFormat = mimetype.startsWith('image/svg') ? 'svg' : 'png'
+          const destFolder = path.resolve(
+            process.cwd(),
+            WIKI.config.dataPath,
+            `assets`
+          )
+          const destPath = path.join(destFolder, `logo-${args.id}.${destFormat}`)
+          await fs.ensureDir(destFolder)
+          // -> Resize
+          await WIKI.extensions.ext.sharp.resize({
+            format: destFormat,
+            inputStream: createReadStream(),
+            outputPath: destPath,
+            height: 72
+          })
+          // -> Save logo meta to DB
+          const site = await WIKI.db.sites.query().findById(args.id)
+          if (!site.config.assets.logo) {
+            site.config.assets.logo = uuid()
+          }
+          site.config.assets.logoExt = destFormat
+          await WIKI.db.sites.query().findById(args.id).patch({ config: site.config })
+          await WIKI.db.sites.reloadCache()
+          // -> Save image data to DB
+          const imgBuffer = await fs.readFile(destPath)
+          await WIKI.db.knex('assetData').insert({
+            id: site.config.assets.logo,
+            data: imgBuffer
+          }).onConflict('id').merge()
+        }))
+        WIKI.logger.debug('Asset(s) uploaded successfully.')
         return {
         return {
-          operation: graphHelper.generateSuccess('Asset(s) uploaded successfully.')
+          operation: graphHelper.generateSuccess('Asset(s) uploaded successfully')
         }
         }
       } catch (err) {
       } catch (err) {
+        WIKI.logger.warn(err)
         return graphHelper.generateError(err)
         return graphHelper.generateError(err)
       }
       }
     },
     },

+ 2 - 2
server/graph/schemas/page.graphql

@@ -103,7 +103,7 @@ extend type Mutation {
   ): PageResponse
   ): PageResponse
 
 
   convertPage(
   convertPage(
-    id: Int!
+    id: UUID!
     editor: String!
     editor: String!
   ): DefaultResponse
   ): DefaultResponse
 
 
@@ -114,7 +114,7 @@ extend type Mutation {
   ): DefaultResponse
   ): DefaultResponse
 
 
   deletePage(
   deletePage(
-    id: Int!
+    id: UUID!
   ): DefaultResponse
   ): DefaultResponse
 
 
   deleteTag(
   deleteTag(

+ 11 - 3
server/graph/schemas/site.graphql

@@ -78,10 +78,11 @@ type SiteRobots {
 
 
 type SiteFeatures {
 type SiteFeatures {
   ratings: Boolean
   ratings: Boolean
-  ratingsMode: SitePageRatingModes
+  ratingsMode: SitePageRatingMode
   comments: Boolean
   comments: Boolean
   contributions: Boolean
   contributions: Boolean
   profile: Boolean
   profile: Boolean
+  reasonForChange: SiteReasonForChangeMode
   search: Boolean
   search: Boolean
 }
 }
 
 
@@ -129,12 +130,18 @@ enum SiteThemePosition {
   right
   right
 }
 }
 
 
-enum SitePageRatingModes {
+enum SitePageRatingMode {
   off
   off
   thumbs
   thumbs
   stars
   stars
 }
 }
 
 
+enum SiteReasonForChangeMode {
+  off
+  optional
+  required
+}
+
 type SiteCreateResponse {
 type SiteCreateResponse {
   operation: Operation
   operation: Operation
   site: Site
   site: Site
@@ -164,10 +171,11 @@ input SiteRobotsInput {
 
 
 input SiteFeaturesInput {
 input SiteFeaturesInput {
   ratings: Boolean
   ratings: Boolean
-  ratingsMode: SitePageRatingModes
+  ratingsMode: SitePageRatingMode
   comments: Boolean
   comments: Boolean
   contributions: Boolean
   contributions: Boolean
   profile: Boolean
   profile: Boolean
+  reasonForChange: SiteReasonForChangeMode
   search: Boolean
   search: Boolean
 }
 }
 
 

+ 10 - 2
server/views/base.pug

@@ -40,20 +40,28 @@ html(lang=siteConfig.lang)
 
 
     //- CSS
     //- CSS
     
     
+      
+    link(
+      type='text/css'
+      rel='stylesheet'
+      href='/_assets-legacy/css/app.36b4c9522aa279325701.css'
+    )
+      
+    
 
 
     //- JS
     //- JS
     
     
       
       
     script(
     script(
       type='text/javascript'
       type='text/javascript'
-      src='/_assets-legacy/js/runtime.js'
+      src='/_assets-legacy/js/runtime.js?1671237890'
       )
       )
       
       
     
     
       
       
     script(
     script(
       type='text/javascript'
       type='text/javascript'
-      src='/_assets-legacy/js/app.js'
+      src='/_assets-legacy/js/app.js?1671237890'
       )
       )
       
       
     
     

文件差异内容过多而无法显示
+ 0 - 0
ux/public/_assets/icons/fluent-save-as.svg


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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#fff" d="M13.5,38.5v-5H12c-2.481,0-4.5-2.019-4.5-4.5v-5.141l-2.512-0.932 c-0.197-0.073-0.349-0.22-0.428-0.413c-0.08-0.194-0.074-0.406,0.015-0.596L7.5,15.705v-0.111c0-3.502,0.904-6.11,2.687-7.751 l0.376-0.346l-0.354-0.369C9.469,6.357,8.94,5.583,8.663,5.14C9.93,4.276,14.53,1.5,21.268,1.5C28.982,1.5,35.5,7.867,35.5,15.403 c0,5.646-2.612,8.415-4.917,10.856c-1.352,1.433-2.628,2.785-3.067,4.505L27.5,30.826V38.5H13.5z"/><path fill="#4788c7" d="M21.268,2C28.711,2,35,8.138,35,15.403c0,5.448-2.54,8.139-4.781,10.514 c-1.397,1.481-2.717,2.879-3.188,4.724L27,30.763v0.126V38H14v-4v-1h-1h-1c-2.206,0-4-1.794-4-4v-4.793v-0.696l-0.652-0.242 l-2.185-0.81c-0.082-0.03-0.121-0.09-0.139-0.134s-0.032-0.114,0.005-0.193l2.877-6.112L8,15.817v-0.224 c0-3.356,0.85-5.84,2.526-7.383l0.752-0.692L10.57,6.783c-0.518-0.54-0.928-1.082-1.215-1.499C10.99,4.255,15.259,2,21.268,2 M21.268,1C13.043,1,8,5,8,5s0.657,1.234,1.849,2.475C8.132,9.055,7,11.635,7,15.594l-2.877,6.112 c-0.309,0.657,0.01,1.439,0.691,1.691L7,24.207V29c0,2.761,2.239,5,5,5h1v5h15v-8.111c1.105-4.326,8-6.228,8-15.486 C36,7.449,29.223,1,21.268,1L21.268,1z"/><g><path fill="#98ccfd" d="M18.548,23.841c-0.073-0.203-0.28-0.922-0.28-1.756c0-2.916,4.013-4.213,4.013-6.565 c0-2.044-2.383-2.154-2.757-2.154c-1.212,0-2.366,0.541-3.463,1.622v-2.817C17.393,11.39,18.775,11,20.206,11 c3.955,0,4.817,2.557,4.817,3.988c0,3.792-4.305,4.749-4.305,7.183c0,0.762,0.313,1.476,0.402,1.671H18.548z M19.938,29 c-0.79,0-1.744-0.566-1.744-1.622c0-1.163,1.125-1.634,1.744-1.634c1.168,0,1.732,0.953,1.732,1.634 C21.67,28.424,20.654,29,19.938,29z"/></g></svg>

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#fff" d="M6,36.5c-1.379,0-2.5-1.121-2.5-2.5V6c0-1.379,1.121-2.5,2.5-2.5h28c1.379,0,2.5,1.121,2.5,2.5v28 c0,1.379-1.121,2.5-2.5,2.5H6z"/><path fill="#4788c7" d="M34,4c1.103,0,2,0.897,2,2v28c0,1.103-0.897,2-2,2H6c-1.103,0-2-0.897-2-2V6c0-1.103,0.897-2,2-2 H34 M34,3H6C4.343,3,3,4.343,3,6v28c0,1.657,1.343,3,3,3h28c1.657,0,3-1.343,3-3V6C37,4.343,35.657,3,34,3L34,3z"/><path fill="#4788c7" d="M28.5 13h-17c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h17c.276 0 .5.224.5.5v0C29 12.776 28.776 13 28.5 13zM23.5 17h-12c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h12c.276 0 .5.224.5.5v0C24 16.776 23.776 17 23.5 17zM28.5 21h-17c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h17c.276 0 .5.224.5.5v0C29 20.776 28.776 21 28.5 21zM28.5 29h-17c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h17c.276 0 .5.224.5.5v0C29 28.776 28.776 29 28.5 29zM23.385 25h-12c-.276 0-.5-.224-.5-.5l0 0c0-.276.224-.5.5-.5h12c.276 0 .5.224.5.5v0C23.885 24.776 23.661 25 23.385 25z"/></svg>

+ 32 - 3
ux/src/components/FileManager.vue

@@ -44,6 +44,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
         @lazy-load='treeLazyLoad'
         @lazy-load='treeLazyLoad'
         :use-lazy-load='true'
         :use-lazy-load='true'
         @context-action='treeContextAction'
         @context-action='treeContextAction'
+        :display-mode='state.displayMode'
       )
       )
   q-drawer.fileman-right(:model-value='true', :width='350', side='right')
   q-drawer.fileman-right(:model-value='true', :width='350', side='right')
     .q-pa-md
     .q-pa-md
@@ -90,7 +91,6 @@ q-layout.fileman(view='hHh lpR lFr', container)
             )
             )
             q-tooltip(anchor='bottom middle', self='top middle') {{t(`fileman.viewOptions`)}}
             q-tooltip(anchor='bottom middle', self='top middle') {{t(`fileman.viewOptions`)}}
             q-menu(
             q-menu(
-              auto-close
               transition-show='jump-down'
               transition-show='jump-down'
               transition-hide='jump-up'
               transition-hide='jump-up'
               anchor='bottom right'
               anchor='bottom right'
@@ -103,11 +103,38 @@ q-layout.fileman(view='hHh lpR lFr', container)
                   q-separator.q-my-sm
                   q-separator.q-my-sm
                   q-item(clickable)
                   q-item(clickable)
                     q-item-section(side)
                     q-item-section(side)
-                      q-icon(name='las la-circle', color='grey', size='xs')
+                      q-icon(name='las la-list', color='grey', size='xs')
+                    q-item-section.q-pr-sm Browse using...
+                    q-item-section(side)
+                      q-icon(name='las la-angle-right', color='grey', size='xs')
+                    q-menu(
+                      anchor='top end'
+                      self='top start'
+                      )
+                      q-list.q-pa-sm(dense)
+                        q-item(clickable, @click='state.displayMode = `path`')
+                          q-item-section(side)
+                            q-icon(
+                              :name='state.displayMode === `path` ? `las la-check-circle` : `las la-circle`'
+                              :color='state.displayMode === `path` ? `positive` : `grey`'
+                              size='xs'
+                              )
+                          q-item-section.q-pr-sm Browse Using Paths
+                        q-item(clickable, @click='state.displayMode = `title`')
+                          q-item-section(side)
+                            q-icon(
+                              :name='state.displayMode === `title` ? `las la-check-circle` : `las la-circle`'
+                              :color='state.displayMode === `title` ? `positive` : `grey`'
+                              size='xs'
+                              )
+                          q-item-section.q-pr-sm Browse Using Titles
+                  q-item(clickable)
+                    q-item-section(side)
+                      q-icon(name='las la-stop', color='grey', size='xs')
                     q-item-section.q-pr-sm Compact List
                     q-item-section.q-pr-sm Compact List
                   q-item(clickable)
                   q-item(clickable)
                     q-item-section(side)
                     q-item-section(side)
-                      q-icon(name='las la-check-circle', color='positive', size='xs')
+                      q-icon(name='las la-check-square', color='positive', size='xs')
                     q-item-section.q-pr-sm Show Folders
                     q-item-section.q-pr-sm Show Folders
           q-btn.q-mr-sm(
           q-btn.q-mr-sm(
             flat
             flat
@@ -254,6 +281,7 @@ const state = reactive({
   currentFileId: '',
   currentFileId: '',
   treeNodes: {},
   treeNodes: {},
   treeRoots: [],
   treeRoots: [],
+  displayMode: 'title',
   isUploading: false,
   isUploading: false,
   shouldCancelUpload: false,
   shouldCancelUpload: false,
   uploadPercentage: 0,
   uploadPercentage: 0,
@@ -450,6 +478,7 @@ async function loadTree (parentId, types) {
           case 'TreeItemFolder': {
           case 'TreeItemFolder': {
             state.treeNodes[item.id] = {
             state.treeNodes[item.id] = {
               text: item.title,
               text: item.title,
+              fileName: item.fileName,
               children: []
               children: []
             }
             }
             if (!item.folderPath) {
             if (!item.folderPath) {

+ 0 - 42
ux/src/components/PageBrowser.vue

@@ -1,42 +0,0 @@
-<template lang="pug">
-.row
-  .col-auto.bg-grey-1(style='width: 250px;')
-    q-tree(
-      :nodes='tree'
-      default-expand-all
-      node-key='label'
-      @lazy-load='onLazyLoad'
-    )
-  .col Doude
-</template>
-
-<script>
-export default {
-  data () {
-    return {
-      tree: [
-        {
-          label: 'Item 1',
-          icon: 'las la-folder',
-          children: [
-            { label: 'Item 1.1' },
-            {
-              label: 'Item 1.2',
-              icon: 'las la-folder',
-              children: [
-                { label: 'Item 1.2.1' },
-                { label: 'Item 1.2.2' }
-              ]
-            }
-          ]
-        }
-      ]
-    }
-  },
-  methods: {
-    async onLazyLoad ({ node, key, done, fail }) {
-      done([])
-    }
-  }
-}
-</script>

+ 109 - 0
ux/src/components/PageDeleteDialog.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(`pageDeleteDialog.title`)}}
+    q-card-section
+      .text-body2
+        i18n-t(keypath='pageDeleteDialog.confirm')
+          template(v-slot:name)
+            strong {{pageName}}
+      .text-caption.text-grey.q-mt-sm {{t('pageDeleteDialog.pageId', { id: pageId })}}
+    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({
+  pageId: {
+    type: String,
+    required: true
+  },
+  pageName: {
+    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 deletePage ($id: UUID!) {
+          deletePage(id: $id) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        id: props.pageId
+      }
+    })
+    if (resp?.data?.deletePage?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('pageDeleteDialog.deleteSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.deletePage?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.isLoading = false
+}
+</script>

+ 362 - 43
ux/src/components/PageSaveDialog.vue

@@ -1,57 +1,376 @@
 <template lang="pug">
 <template lang="pug">
-q-card.page-save-dialog(style='width: 860px; max-width: 90vw;')
-  q-toolbar.bg-primary.text-white
-    .text-subtitle2 {{$t('editor.pageSave.title')}}
-  page-browser
-  q-card-section
-    q-input(
-      v-model='reason'
-      label='Reason for change'
-      dense
-      outlined
-    )
-  q-card-actions.card-actions
-    q-space
-    q-btn.acrylic-btn(
-      icon='las la-times'
-      :label='$t(`common.actions.cancel`)'
-      color='grey-7'
-      padding='xs md'
-      v-close-popup
-      flat
-    )
-    q-btn(
-      icon='las la-check'
-      :label='$t(`common.actions.save`)'
-      unelevated
-      color='primary'
-      padding='xs md'
-      @click=''
-      v-close-popup
-    )
+q-dialog(ref='dialogRef', @hide='onDialogHide')
+  q-card.page-save-dialog(style='width: 860px; max-width: 90vw;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-save-as.svg', left, size='sm')
+      span {{t('pageSaveDialog.title')}}
+    .row.page-save-dialog-browser
+      .col-4.q-px-sm
+        tree(
+          :nodes='state.treeNodes'
+          :roots='state.treeRoots'
+          v-model:selected='state.currentFolderId'
+          @lazy-load='treeLazyLoad'
+          :use-lazy-load='true'
+          @context-action='treeContextAction'
+          :context-action-list='[`newFolder`]'
+          :display-mode='state.displayMode'
+        )
+      .col-8
+        q-list.page-save-dialog-filelist(dense)
+          q-item(
+            v-for='item of files'
+            :key='item.id'
+            clickable
+            active-class='active'
+            :active='item.id === state.currentFileId'
+            @click.native='state.currentFileId = item.id'
+            @dblclick.native='openItem(item)'
+            )
+            q-item-section(side)
+              q-icon(:name='item.icon', size='sm')
+            q-item-section
+              q-item-label {{item.title}}
+    q-list.q-py-sm
+      q-item
+        blueprint-icon(icon='new-document')
+        q-item-section
+          q-input(
+            v-model='state.title'
+            label='Page Title'
+            dense
+            outlined
+          )
+      q-item
+        blueprint-icon(icon='file-submodule')
+        q-item-section
+          q-input(
+            v-model='state.path'
+            label='Path Name'
+            dense
+            outlined
+          )
+    q-card-actions.card-actions.q-px-md
+      q-btn.acrylic-btn(
+        icon='las la-ellipsis-h'
+        color='blue-grey'
+        padding='xs sm'
+        flat
+        )
+        q-tooltip(anchor='center right' self='center left') Display Options
+        q-menu(
+          auto-close
+          transition-show='jump-down'
+          transition-hide='jump-up'
+          anchor='top left'
+          self='bottom left'
+          )
+          q-card.q-pa-sm
+            q-list(dense)
+              q-item(clickable, @click='state.displayMode = `path`')
+                q-item-section(side)
+                  q-icon(
+                    :name='state.displayMode === `path` ? `las la-check-circle` : `las la-circle`'
+                    :color='state.displayMode === `path` ? `positive` : `grey`'
+                    size='xs'
+                    )
+                q-item-section.q-pr-sm Browse Using Paths
+              q-item(clickable, @click='state.displayMode = `title`')
+                q-item-section(side)
+                  q-icon(
+                    :name='state.displayMode === `title` ? `las la-check-circle` : `las la-circle`'
+                    :color='state.displayMode === `title` ? `positive` : `grey`'
+                    size='xs'
+                    )
+                q-item-section.q-pr-sm Browse Using Titles
+      q-space
+      q-btn.acrylic-btn(
+        icon='las la-times'
+        :label='t(`common.actions.cancel`)'
+        color='grey-7'
+        padding='xs md'
+        @click='onDialogCancel'
+        flat
+      )
+      q-btn(
+        icon='las la-check'
+        :label='t(`common.actions.save`)'
+        unelevated
+        color='primary'
+        padding='xs md'
+        @click='save'
+        v-close-popup
+      )
 </template>
 </template>
 
 
-<script>
-import PageBrowser from './PageBrowser.vue'
+<script setup>
+import { useI18n } from 'vue-i18n'
+import { computed, onMounted, reactive } from 'vue'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { cloneDeep, find } from 'lodash-es'
+import gql from 'graphql-tag'
 
 
-export default {
-  components: {
-    PageBrowser
-  },
-  data () {
-    return {
-      reason: ''
-    }
+import fileTypes from '../helpers/fileTypes'
+
+import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
+import Tree from 'src/components/TreeNav.vue'
+
+import { usePageStore } from 'src/stores/page'
+import { useSiteStore } from 'src/stores/site'
+
+// PROPS
+
+const props = defineProps({
+  mode: {
+    type: String,
+    required: false,
+    default: 'save'
   },
   },
-  computed: {
+  pageId: {
+    type: String,
+    required: true
   },
   },
-  mounted () {
+  pageName: {
+    type: String,
+    required: false,
+    default: ''
   },
   },
-  methods: {
+  pagePath: {
+    type: String,
+    required: false,
+    default: ''
   }
   }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// STORES
+
+const pageStore = usePageStore()
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  displayMode: 'title',
+  currentFolderId: '',
+  currentFileId: '',
+  treeNodes: {},
+  treeRoots: [],
+  fileList: [
+    {
+      id: '1',
+      type: 'folder',
+      title: 'Beep Boop'
+    },
+    {
+      id: '2',
+      type: 'folder',
+      title: 'Second Folder'
+    },
+    {
+      id: '3',
+      type: 'page',
+      title: 'Some Page',
+      pageType: 'markdown'
+    }
+  ],
+  title: '',
+  path: ''
+})
+
+const displayModes = [
+  { value: 'title', label: t('pageSaveDialog.displayModeTitle') },
+  { value: 'path', label: t('pageSaveDialog.displayModePath') }
+]
+
+// COMPUTED
+
+const files = computed(() => {
+  return state.fileList.map(f => {
+    switch (f.type) {
+      case 'folder': {
+        f.icon = fileTypes.folder.icon
+        break
+      }
+      case 'page': {
+        f.icon = fileTypes.page.icon
+        break
+      }
+    }
+    return f
+  })
+})
+
+// METHODS
+
+async function save () {
+  onDialogOK()
 }
 }
+
+async function treeLazyLoad (nodeId, { done, fail }) {
+  await loadTree(nodeId, ['folder', 'page'])
+  done()
+}
+
+async function loadTree (parentId, types) {
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query loadTree (
+          $siteId: UUID!
+          $parentId: UUID
+          $types: [TreeItemType]
+        ) {
+          tree (
+            siteId: $siteId
+            parentId: $parentId
+            types: $types
+          ) {
+            __typename
+            ... on TreeItemFolder {
+              id
+              folderPath
+              fileName
+              title
+              childrenCount
+            }
+            ... on TreeItemPage {
+              id
+              folderPath
+              fileName
+              title
+              createdAt
+              updatedAt
+              pageEditor
+            }
+          }
+        }
+      `,
+      variables: {
+        siteId: siteStore.id,
+        parentId,
+        types
+      },
+      fetchPolicy: 'network-only'
+    })
+    const items = cloneDeep(resp?.data?.tree)
+    if (items?.length > 0) {
+      const newTreeRoots = []
+      for (const item of items) {
+        switch (item.__typename) {
+          case 'TreeItemFolder': {
+            state.treeNodes[item.id] = {
+              text: item.title,
+              fileName: item.fileName,
+              children: []
+            }
+            if (!item.folderPath) {
+              newTreeRoots.push(item.id)
+            } else {
+              state.treeNodes[parentId].children.push(item.id)
+            }
+            break
+          }
+        }
+      }
+      if (newTreeRoots.length > 0) {
+        state.treeRoots = newTreeRoots
+      }
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to load folder tree.',
+      caption: err.message
+    })
+  }
+}
+
+function treeContextAction (nodeId, action) {
+  switch (action) {
+    case 'newFolder': {
+      newFolder(nodeId)
+      break
+    }
+  }
+}
+
+function newFolder (parentId) {
+  $q.dialog({
+    component: FolderCreateDialog,
+    componentProps: {
+      parentId
+    }
+  }).onOk(() => {
+    loadTree(parentId)
+  })
+}
+
+// MOUNTED
+
+onMounted(() => {
+  loadTree()
+  state.title = props.pageName || ''
+  state.path = props.pagePath || ''
+})
+
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">
+.page-save-dialog {
 
 
+  &-browser {
+    height: 300px;
+    max-height: 90vh;
+    border-bottom: 1px solid $blue-grey-1;
+
+    > .col-4 {
+      @at-root .body--light & {
+        background-color: $blue-grey-1;
+        border-bottom-color: $blue-grey-1;
+      }
+      @at-root .body--dark & {
+        background-color: $dark-4;
+        border-bottom-color: $dark-4;
+      }
+    }
+  }
+
+  &-filelist {
+    padding: 8px 12px;
+
+    > .q-item {
+      padding: 4px 6px;
+      border-radius: 4px;
+
+      &.active {
+        background-color: var(--q-primary);
+        color: #FFF;
+
+        .fileman-filelist-label .q-item__label--caption {
+          color: rgba(255,255,255,.7);
+        }
+
+        .fileman-filelist-side .text-caption {
+          color: rgba(255,255,255,.7);
+        }
+      }
+    }
+  }
+
+}
 </style>
 </style>

+ 15 - 3
ux/src/components/TreeLevel.vue

@@ -6,6 +6,7 @@ ul.treeview-level
       q-icon(name='img:/_assets/icons/fluent-ftp.svg', size='sm')
       q-icon(name='img:/_assets/icons/fluent-ftp.svg', size='sm')
       .treeview-label-text(:class='$q.dark.isActive ? `text-purple-4` : `text-purple`') root
       .treeview-label-text(:class='$q.dark.isActive ? `text-purple-4` : `text-purple`') root
       q-menu(
       q-menu(
+        v-if='rootContextActionList.length > 0'
         touch-position
         touch-position
         context-menu
         context-menu
         auto-close
         auto-close
@@ -14,10 +15,15 @@ ul.treeview-level
         )
         )
         q-card.q-pa-sm
         q-card.q-pa-sm
           q-list(dense, style='min-width: 150px;')
           q-list(dense, style='min-width: 150px;')
-            q-item(clickable, @click='createRootFolder')
+            q-item(
+              v-for='action of rootContextActionList'
+              :key='action.key'
+              clickable
+              @click='action.handler(null)'
+              )
               q-item-section(side)
               q-item-section(side)
-                q-icon(name='las la-plus-circle', color='primary')
-              q-item-section New Folder
+                q-icon(:name='action.icon', :color='action.iconColor')
+              q-item-section(:class='action.labelColor && (`text-` + action.labelColor)') {{action.label}}
       q-icon(
       q-icon(
         v-if='!selection'
         v-if='!selection'
         name='las la-angle-right'
         name='las la-angle-right'
@@ -62,9 +68,15 @@ const roots = inject('roots')
 const nodes = inject('nodes')
 const nodes = inject('nodes')
 const selection = inject('selection')
 const selection = inject('selection')
 const emitContextAction = inject('emitContextAction')
 const emitContextAction = inject('emitContextAction')
+const contextActionList = inject('contextActionList')
 
 
 // COMPUTED
 // COMPUTED
 
 
+const rootContextActionList = computed(() => {
+  if (props.parentId) { return [] }
+  return contextActionList.filter(c => c.key === 'newFolder')
+})
+
 const level = computed(() => {
 const level = computed(() => {
   const items = []
   const items = []
   if (!props.parentId) {
   if (!props.parentId) {

+ 52 - 0
ux/src/components/TreeNav.vue

@@ -7,6 +7,7 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
+import { useI18n } from 'vue-i18n'
 import { computed, onMounted, provide, reactive, toRef } from 'vue'
 import { computed, onMounted, provide, reactive, toRef } from 'vue'
 import { findKey } from 'lodash-es'
 import { findKey } from 'lodash-es'
 
 
@@ -30,6 +31,14 @@ const props = defineProps({
   useLazyLoad: {
   useLazyLoad: {
     type: Boolean,
     type: Boolean,
     default: false
     default: false
+  },
+  contextActionList: {
+    type: Array,
+    default: () => ['newFolder', 'duplicate', 'rename', 'move', 'del']
+  },
+  displayMode: {
+    type: String,
+    default: 'title'
   }
   }
 })
 })
 
 
@@ -37,6 +46,48 @@ const props = defineProps({
 
 
 const emit = defineEmits(['update:selected', 'lazyLoad', 'contextAction'])
 const emit = defineEmits(['update:selected', 'lazyLoad', 'contextAction'])
 
 
+// I18N
+
+const { t } = useI18n()
+
+// Context Actions
+
+const contextActions = {
+  newFolder: {
+    icon: 'las la-plus-circle',
+    iconColor: 'blue',
+    label: t('common.actions.newFolder')
+  },
+  duplicate: {
+    icon: 'las la-copy',
+    iconColor: 'teal',
+    label: t('common.actions.duplicate') + '...'
+  },
+  rename: {
+    icon: 'las la-redo',
+    iconColor: 'teal',
+    label: t('common.actions.rename') + '...'
+  },
+  move: {
+    icon: 'las la-arrow-right',
+    iconColor: 'teal',
+    label: t('common.actions.moveTo') + '...'
+  },
+  del: {
+    icon: 'las la-trash-alt',
+    iconColor: 'negative',
+    label: t('common.actions.delete'),
+    labelColor: 'negative'
+  }
+}
+provide('contextActionList', props.contextActionList.map(key => ({
+  key,
+  ...contextActions[key],
+  handler: (nodeId) => {
+    emit('contextAction', nodeId, key)
+  }
+})))
+
 // DATA
 // DATA
 
 
 const state = reactive({
 const state = reactive({
@@ -75,6 +126,7 @@ provide('roots', toRef(props, 'roots'))
 provide('nodes', props.nodes)
 provide('nodes', props.nodes)
 provide('loaded', state.loaded)
 provide('loaded', state.loaded)
 provide('opened', state.opened)
 provide('opened', state.opened)
+provide('displayMode', toRef(props, 'displayMode'))
 provide('selection', selection)
 provide('selection', selection)
 provide('emitLazyLoad', emitLazyLoad)
 provide('emitLazyLoad', emitLazyLoad)
 provide('emitContextAction', emitContextAction)
 provide('emitContextAction', emitContextAction)

+ 12 - 20
ux/src/components/TreeNode.vue

@@ -7,7 +7,7 @@ li.treeview-node
       size='sm'
       size='sm'
       @click.stop='hasChildren ? toggleNode() : openNode()'
       @click.stop='hasChildren ? toggleNode() : openNode()'
       )
       )
-    .treeview-label-text {{node.text}}
+    .treeview-label-text {{displayMode === 'path' ? node.fileName : node.text}}
     q-spinner.q-mr-xs(
     q-spinner.q-mr-xs(
       color='primary'
       color='primary'
       v-if='state.isLoading'
       v-if='state.isLoading'
@@ -19,6 +19,7 @@ li.treeview-node
       )
       )
     //- RIGHT-CLICK MENU
     //- RIGHT-CLICK MENU
     q-menu(
     q-menu(
+      v-if='contextActionList.length > 0'
       touch-position
       touch-position
       context-menu
       context-menu
       auto-close
       auto-close
@@ -29,26 +30,15 @@ li.treeview-node
       )
       )
       q-card.q-pa-sm
       q-card.q-pa-sm
         q-list(dense, style='min-width: 150px;')
         q-list(dense, style='min-width: 150px;')
-          q-item(clickable, @click='contextAction(`newFolder`)')
+          q-item(
+            v-for='action of contextActionList'
+            :key='action.key'
+            clickable
+            @click='action.handler(node.id)'
+            )
             q-item-section(side)
             q-item-section(side)
-              q-icon(name='las la-plus-circle', color='primary')
-            q-item-section New Folder
-          q-item(clickable)
-            q-item-section(side)
-              q-icon(name='las la-copy', color='teal')
-            q-item-section Duplicate...
-          q-item(clickable)
-            q-item-section(side)
-              q-icon(name='las la-redo', color='teal')
-            q-item-section Rename...
-          q-item(clickable)
-            q-item-section(side)
-              q-icon(name='las la-arrow-right', color='teal')
-            q-item-section Move to...
-          q-item(clickable)
-            q-item-section(side)
-              q-icon(name='las la-trash-alt', color='negative')
-            q-item-section.text-negative Delete
+              q-icon(:name='action.icon', :color='action.iconColor')
+            q-item-section(:class='action.labelColor && (`text-` + action.labelColor)') {{action.label}}
   //- SUB-LEVEL
   //- SUB-LEVEL
   transition(name='treeview')
   transition(name='treeview')
     tree-level(
     tree-level(
@@ -89,9 +79,11 @@ const $q = useQuasar()
 
 
 const loaded = inject('loaded')
 const loaded = inject('loaded')
 const opened = inject('opened')
 const opened = inject('opened')
+const displayMode = inject('displayMode')
 const selection = inject('selection')
 const selection = inject('selection')
 const emitLazyLoad = inject('emitLazyLoad')
 const emitLazyLoad = inject('emitLazyLoad')
 const emitContextAction = inject('emitContextAction')
 const emitContextAction = inject('emitContextAction')
+const contextActionList = inject('contextActionList')
 
 
 // DATA
 // DATA
 
 

+ 18 - 1
ux/src/i18n/locales/en.json

@@ -1586,5 +1586,22 @@
   "welcome.admin": "Administration Area",
   "welcome.admin": "Administration Area",
   "welcome.createHome": "Create the homepage",
   "welcome.createHome": "Create the homepage",
   "welcome.subtitle": "Let's get started...",
   "welcome.subtitle": "Let's get started...",
-  "welcome.title": "Welcome to Wiki.js!"
+  "welcome.title": "Welcome to Wiki.js!",
+  "admin.utilities.scanPageProblems": "Scan for Page Problems",
+  "admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.",
+  "pageSaveDialog.title": "Save As...",
+  "admin.general.reasonForChange": "Reason for Change",
+  "admin.general.reasonForChangeHint": "Should users be prompted the reason for changes made to a page?",
+  "admin.general.reasonForChangeRequired": "Required",
+  "admin.general.reasonForChangeOptional": "Optional",
+  "admin.general.reasonForChangeOff": "Off",
+  "pageDeleteDialog.title": "Confirm Page Deletion",
+  "pageDeleteDialog.confirm": "Are you sure you want to delete the page {name}?",
+  "pageDeleteDialog.pageId": "Page ID {id}",
+  "pageDeleteDialog.deleteSuccess": "Page deleted successfully.",
+  "common.actions.newFolder": "New Folder",
+  "common.actions.duplicate": "Duplicate",
+  "common.actions.moveTo": "Move To",
+  "pageSaveDialog.displayModeTitle": "Title",
+  "pageSaveDialog.displayModePath": "Path"
 }
 }

+ 25 - 3
ux/src/pages/AdminGeneral.vue

@@ -11,7 +11,7 @@ q-page.admin-general
         icon='las la-question-circle'
         icon='las la-question-circle'
         flat
         flat
         color='grey'
         color='grey'
-        :href='siteStore.docsBase + `/admin/sites`'
+        :href='siteStore.docsBase + `/admin/sites#general`'
         target='_blank'
         target='_blank'
         type='a'
         type='a'
         )
         )
@@ -208,6 +208,21 @@ q-page.admin-general
               unchecked-icon='las la-times'
               unchecked-icon='las la-times'
               :aria-label='t(`admin.general.allowSearch`)'
               :aria-label='t(`admin.general.allowSearch`)'
               )
               )
+        q-separator.q-my-sm(inset)
+        q-item(tag='label')
+          blueprint-icon(icon='confusion')
+          q-item-section
+            q-item-label {{t(`admin.general.reasonForChange`)}}
+            q-item-label(caption) {{t(`admin.general.reasonForChangeHint`)}}
+          q-item-section(avatar)
+            q-btn-toggle(
+              v-model='state.config.features.reasonForChange'
+              push
+              glossy
+              no-caps
+              toggle-color='primary'
+              :options='reasonForChangeModes'
+            )
 
 
       //- -----------------------
       //- -----------------------
       //- URL Handling
       //- URL Handling
@@ -493,6 +508,7 @@ const state = reactive({
       ratingsMode: 'off',
       ratingsMode: 'off',
       comments: false,
       comments: false,
       contributions: false,
       contributions: false,
+      reasonForChange: 'off',
       profile: false
       profile: false
     },
     },
     defaults: {
     defaults: {
@@ -528,6 +544,11 @@ const ratingsModes = [
   { value: 'thumbs', label: t('admin.general.ratingsThumbs') },
   { value: 'thumbs', label: t('admin.general.ratingsThumbs') },
   { value: 'stars', label: t('admin.general.ratingsStars') }
   { value: 'stars', label: t('admin.general.ratingsStars') }
 ]
 ]
+const reasonForChangeModes = [
+  { value: 'off', label: t('admin.general.reasonForChangeOff') },
+  { value: 'optional', label: t('admin.general.reasonForChangeOptional') },
+  { value: 'required', label: t('admin.general.reasonForChangeRequired') }
+]
 const dateFormats = [
 const dateFormats = [
   { value: '', label: t('profile.localeDefault') },
   { value: '', label: t('profile.localeDefault') },
   { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
   { value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
@@ -586,10 +607,11 @@ async function load () {
           }
           }
           features {
           features {
             comments
             comments
-            ratings
-            ratingsMode
             contributions
             contributions
             profile
             profile
+            ratings
+            ratingsMode
+            reasonForChange
             search
             search
           }
           }
           defaults {
           defaults {

+ 13 - 1
ux/src/pages/AdminUtilities.vue

@@ -109,7 +109,19 @@ q-page.admin-utilities
               @click=''
               @click=''
               :label='t(`common.actions.proceed`)'
               :label='t(`common.actions.proceed`)'
             )
             )
-
+        q-item
+          blueprint-icon(icon='rescan-document', :hue-rotate='45')
+          q-item-section
+            q-item-label {{t(`admin.utilities.scanPageProblems`)}}
+            q-item-label(caption) {{t(`admin.utilities.scanPageProblemsHint`)}}
+          q-item-section(side)
+            q-btn.acrylic-btn(
+              flat
+              icon='las la-arrow-circle-right'
+              color='primary'
+              @click=''
+              :label='t(`common.actions.proceed`)'
+            )
 </template>
 </template>
 
 
 <script setup>
 <script setup>

+ 55 - 31
ux/src/pages/Index.vue

@@ -193,7 +193,7 @@ q-page.column
                 @click='state.tagEditMode = !state.tagEditMode'
                 @click='state.tagEditMode = !state.tagEditMode'
               )
               )
           page-tags.q-mt-sm(:edit='state.tagEditMode')
           page-tags.q-mt-sm(:edit='state.tagEditMode')
-      template(v-if='pageStore.allowRatings && pageStore.ratingsMode !== `off`')
+      template(v-if='siteStore.features.ratingsMode !== `off` && pageStore.allowRatings')
         q-separator(v-if='pageStore.showToc || pageStore.showTags')
         q-separator(v-if='pageStore.showToc || pageStore.showTags')
         //- Rating
         //- Rating
         .q-pa-md.flex.items-center
         .q-pa-md.flex.items-center
@@ -201,13 +201,13 @@ q-page.column
           .text-caption.text-grey-7 Rate this page
           .text-caption.text-grey-7 Rate this page
         .q-px-md
         .q-px-md
           q-rating(
           q-rating(
-            v-if='pageStore.ratingsMode === `stars`'
+            v-if='siteStore.features.ratingsMode === `stars`'
             v-model='state.currentRating'
             v-model='state.currentRating'
             icon='las la-star'
             icon='las la-star'
             color='secondary'
             color='secondary'
             size='sm'
             size='sm'
           )
           )
-          .flex.items-center(v-else-if='pageStore.ratingsMode === `thumbs`')
+          .flex.items-center(v-else-if='siteStore.features.ratingsMode === `thumbs`')
             q-btn.acrylic-btn(
             q-btn.acrylic-btn(
               flat
               flat
               icon='las la-thumbs-down'
               icon='las la-thumbs-down'
@@ -237,26 +237,13 @@ q-page.column
         )
         )
         q-tooltip(anchor='center left' self='center right') Page Data
         q-tooltip(anchor='center left' self='center right') Page Data
       q-separator.q-my-sm(inset)
       q-separator.q-my-sm(inset)
-      q-btn.q-py-sm(
-        flat
-        icon='las la-history'
-        color='grey'
-        aria-label='Page History'
-        )
-        q-tooltip(anchor='center left' self='center right') Page History
-      q-btn.q-py-sm(
-        flat
-        icon='las la-code'
-        color='grey'
-        aria-label='Page Source'
-        )
-        q-tooltip(anchor='center left' self='center right') Page Source
       q-btn.q-py-sm(
       q-btn.q-py-sm(
         flat
         flat
         icon='las la-ellipsis-h'
         icon='las la-ellipsis-h'
         color='grey'
         color='grey'
         aria-label='Page Actions'
         aria-label='Page Actions'
         )
         )
+        q-tooltip(anchor='center left' self='center right') Page Actions
         q-menu(
         q-menu(
           anchor='top left'
           anchor='top left'
           self='top right'
           self='top right'
@@ -264,6 +251,16 @@ q-page.column
           transition-show='jump-left'
           transition-show='jump-left'
           )
           )
           q-list(padding, style='min-width: 225px;')
           q-list(padding, style='min-width: 225px;')
+            q-item(clickable)
+              q-item-section.items-center(avatar)
+                q-icon(color='deep-orange-9', name='las la-history', size='sm')
+              q-item-section
+                q-item-label View History
+            q-item(clickable)
+              q-item-section.items-center(avatar)
+                q-icon(color='deep-orange-9', name='las la-code', size='sm')
+              q-item-section
+                q-item-label View Source
             q-item(clickable)
             q-item(clickable)
               q-item-section.items-center(avatar)
               q-item-section.items-center(avatar)
                 q-icon(color='deep-orange-9', name='las la-atom', size='sm')
                 q-icon(color='deep-orange-9', name='las la-atom', size='sm')
@@ -285,6 +282,7 @@ q-page.column
         icon='las la-copy'
         icon='las la-copy'
         color='grey'
         color='grey'
         aria-label='Duplicate Page'
         aria-label='Duplicate Page'
+        @click='duplicatePage'
         )
         )
         q-tooltip(anchor='center left' self='center right') Duplicate Page
         q-tooltip(anchor='center left' self='center right') Duplicate Page
       q-btn.q-py-sm(
       q-btn.q-py-sm(
@@ -292,6 +290,7 @@ q-page.column
         icon='las la-share'
         icon='las la-share'
         color='grey'
         color='grey'
         aria-label='Rename / Move Page'
         aria-label='Rename / Move Page'
+        @click='renamePage'
         )
         )
         q-tooltip(anchor='center left' self='center right') Rename / Move Page
         q-tooltip(anchor='center left' self='center right') Rename / Move Page
       q-btn.q-py-sm(
       q-btn.q-py-sm(
@@ -299,7 +298,7 @@ q-page.column
         icon='las la-trash'
         icon='las la-trash'
         color='grey'
         color='grey'
         aria-label='Delete Page'
         aria-label='Delete Page'
-        @click='savePage'
+        @click='deletePage'
         )
         )
         q-tooltip(anchor='center left' self='center right') Delete Page
         q-tooltip(anchor='center left' self='center right') Delete Page
 
 
@@ -313,13 +312,6 @@ q-page.column
     no-shake
     no-shake
     )
     )
     component(:is='sideDialogs[state.sideDialogComponent]')
     component(:is='sideDialogs[state.sideDialogComponent]')
-
-  q-dialog(
-    v-model='state.showGlobalDialog'
-    transition-show='jump-up'
-    transition-hide='jump-down'
-    )
-    component(:is='globalDialogs[state.globalDialogComponent]')
 </template>
 </template>
 
 
 <script setup>
 <script setup>
@@ -349,9 +341,6 @@ const sideDialogs = {
     loadingComponent: LoadingGeneric
     loadingComponent: LoadingGeneric
   })
   })
 }
 }
-const globalDialogs = {
-  PageSaveDialog: defineAsyncComponent(() => import('../components/PageSaveDialog.vue'))
-}
 
 
 // QUASAR
 // QUASAR
 
 
@@ -472,9 +461,44 @@ function togglePageData () {
   state.showSideDialog = true
   state.showSideDialog = true
 }
 }
 
 
-function savePage () {
-  state.globalDialogComponent = 'PageSaveDialog'
-  state.showGlobalDialog = true
+function duplicatePage () {
+  $q.dialog({
+    component: defineAsyncComponent(() => import('../components/PageSaveDialog.vue')),
+    componentProps: {
+      mode: 'duplicate',
+      pageId: pageStore.id,
+      pageName: pageStore.title,
+      pagePath: pageStore.path
+    }
+  }).onOk(() => {
+    // TODO: change route to new location
+  })
+}
+
+function renamePage () {
+  $q.dialog({
+    component: defineAsyncComponent(() => import('../components/PageSaveDialog.vue')),
+    componentProps: {
+      mode: 'rename',
+      pageId: pageStore.id,
+      pageName: pageStore.title,
+      pagePath: pageStore.path
+    }
+  }).onOk(() => {
+    // TODO: change route to new location
+  })
+}
+
+function deletePage () {
+  $q.dialog({
+    component: defineAsyncComponent(() => import('../components/PageDeleteDialog.vue')),
+    componentProps: {
+      pageId: pageStore.id,
+      pageName: pageStore.title
+    }
+  }).onOk(() => {
+    router.replace('/')
+  })
 }
 }
 
 
 function refreshTocExpanded (baseToc, lvl) {
 function refreshTocExpanded (baseToc, lvl) {

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

@@ -21,11 +21,13 @@ export const useSiteStore = defineStore('site', {
     searchRestrictLocale: false,
     searchRestrictLocale: false,
     searchRestrictPath: false,
     searchRestrictPath: false,
     printView: false,
     printView: false,
-    ratingsMode: 'thumbs',
     pageDataTemplates: [],
     pageDataTemplates: [],
     showSideNav: true,
     showSideNav: true,
     showSidebar: true,
     showSidebar: true,
     overlay: null,
     overlay: null,
+    features: {
+      ratingsMode: 'off'
+    },
     theme: {
     theme: {
       dark: false,
       dark: false,
       injectCSS: '',
       injectCSS: '',
@@ -76,6 +78,9 @@ export const useSiteStore = defineStore('site', {
                 company
                 company
                 contentLicense
                 contentLicense
                 footerExtra
                 footerExtra
+                features {
+                  ratingsMode
+                }
                 theme {
                 theme {
                   dark
                   dark
                   colorPrimary
                   colorPrimary
@@ -107,6 +112,10 @@ export const useSiteStore = defineStore('site', {
           this.company = clone(siteInfo.company)
           this.company = clone(siteInfo.company)
           this.contentLicense = clone(siteInfo.contentLicense)
           this.contentLicense = clone(siteInfo.contentLicense)
           this.footerExtra = clone(siteInfo.footerExtra)
           this.footerExtra = clone(siteInfo.footerExtra)
+          this.features = {
+            ...this.features,
+            ...clone(siteInfo.features)
+          }
           this.theme = {
           this.theme = {
             ...this.theme,
             ...this.theme,
             ...clone(siteInfo.theme)
             ...clone(siteInfo.theme)

部分文件因为文件数量过多而无法显示