Explorar el Código

feat: edit page properties + update dependencies

Nicolas Giard hace 2 años
padre
commit
f05a73dccb

+ 22 - 43
client/components/editor.vue

@@ -295,28 +295,28 @@ export default {
                 $content: String!
                 $content: String!
                 $description: String!
                 $description: String!
                 $editor: String!
                 $editor: String!
-                $isPublished: Boolean!
+                $publishState: PagePublishState!
                 $locale: String!
                 $locale: String!
                 $path: String!
                 $path: String!
                 $publishEndDate: Date
                 $publishEndDate: Date
                 $publishStartDate: Date
                 $publishStartDate: Date
                 $scriptCss: String
                 $scriptCss: String
-                $scriptJs: String
+                $scriptJsLoad: String
                 $siteId: UUID!
                 $siteId: UUID!
-                $tags: [String]!
+                $tags: [String!]
                 $title: String!
                 $title: String!
               ) {
               ) {
                 createPage(
                 createPage(
                   content: $content
                   content: $content
                   description: $description
                   description: $description
                   editor: $editor
                   editor: $editor
-                  isPublished: $isPublished
+                  publishState: $publishState
                   locale: $locale
                   locale: $locale
                   path: $path
                   path: $path
                   publishEndDate: $publishEndDate
                   publishEndDate: $publishEndDate
                   publishStartDate: $publishStartDate
                   publishStartDate: $publishStartDate
                   scriptCss: $scriptCss
                   scriptCss: $scriptCss
-                  scriptJs: $scriptJs
+                  scriptJsLoad: $scriptJsLoad
                   siteId: $siteId
                   siteId: $siteId
                   tags: $tags
                   tags: $tags
                   title: $title
                   title: $title
@@ -337,12 +337,12 @@ export default {
               description: this.$store.get('page/description'),
               description: this.$store.get('page/description'),
               editor: this.$store.get('editor/editorKey'),
               editor: this.$store.get('editor/editorKey'),
               locale: this.$store.get('page/locale'),
               locale: this.$store.get('page/locale'),
-              isPublished: this.$store.get('page/isPublished'),
+              publishState: this.$store.get('page/isPublished') ? 'published' : 'draft',
               path: this.$store.get('page/path'),
               path: this.$store.get('page/path'),
               publishEndDate: this.$store.get('page/publishEndDate') || '',
               publishEndDate: this.$store.get('page/publishEndDate') || '',
               publishStartDate: this.$store.get('page/publishStartDate') || '',
               publishStartDate: this.$store.get('page/publishStartDate') || '',
               scriptCss: this.$store.get('page/scriptCss'),
               scriptCss: this.$store.get('page/scriptCss'),
-              scriptJs: this.$store.get('page/scriptJs'),
+              scriptJsLoad: this.$store.get('page/scriptJs'),
               siteId: this.$store.get('site/id'),
               siteId: this.$store.get('site/id'),
               tags: this.$store.get('page/tags'),
               tags: this.$store.get('page/tags'),
               title: this.$store.get('page/title')
               title: this.$store.get('page/title')
@@ -392,33 +392,11 @@ export default {
             mutation: gql`
             mutation: gql`
               mutation (
               mutation (
                 $id: UUID!
                 $id: UUID!
-                $content: String
-                $description: String
-                $editor: String
-                $isPublished: Boolean
-                $locale: String
-                $path: String
-                $publishEndDate: Date
-                $publishStartDate: Date
-                $scriptCss: String
-                $scriptJs: String
-                $tags: [String]
-                $title: String
+                $patch: PageUpdateInput!
               ) {
               ) {
                 updatePage(
                 updatePage(
                   id: $id
                   id: $id
-                  content: $content
-                  description: $description
-                  editor: $editor
-                  isPublished: $isPublished
-                  locale: $locale
-                  path: $path
-                  publishEndDate: $publishEndDate
-                  publishStartDate: $publishStartDate
-                  scriptCss: $scriptCss
-                  scriptJs: $scriptJs
-                  tags: $tags
-                  title: $title
+                  patch: $patch
                 ) {
                 ) {
                   operation {
                   operation {
                     succeeded
                     succeeded
@@ -432,18 +410,19 @@ export default {
             `,
             `,
             variables: {
             variables: {
               id: this.$store.get('page/id'),
               id: this.$store.get('page/id'),
-              content: this.$store.get('editor/content'),
-              description: this.$store.get('page/description'),
-              editor: this.$store.get('editor/editorKey'),
-              locale: this.$store.get('page/locale'),
-              isPublished: this.$store.get('page/isPublished'),
-              path: this.$store.get('page/path'),
-              publishEndDate: this.$store.get('page/publishEndDate') || '',
-              publishStartDate: this.$store.get('page/publishStartDate') || '',
-              scriptCss: this.$store.get('page/scriptCss'),
-              scriptJs: this.$store.get('page/scriptJs'),
-              tags: this.$store.get('page/tags'),
-              title: this.$store.get('page/title')
+              patch: {
+                content: this.$store.get('editor/content'),
+                description: this.$store.get('page/description'),
+                locale: this.$store.get('page/locale'),
+                publishState: this.$store.get('page/isPublished') ? 'published' : 'draft',
+                path: this.$store.get('page/path'),
+                publishEndDate: this.$store.get('page/publishEndDate') || '',
+                publishStartDate: this.$store.get('page/publishStartDate') || '',
+                scriptCss: this.$store.get('page/scriptCss'),
+                scriptJsLoad: this.$store.get('page/scriptJs'),
+                tags: this.$store.get('page/tags'),
+                title: this.$store.get('page/title')
+              }
             }
             }
           })
           })
           resp = _.get(resp, 'data.updatePage', {})
           resp = _.get(resp, 'data.updatePage', {})

+ 27 - 21
server/db/migrations/3.0.0.js

@@ -10,6 +10,7 @@ exports.up = async knex => {
   // =====================================
   // =====================================
   // PG EXTENSIONS
   // PG EXTENSIONS
   // =====================================
   // =====================================
+  await knex.raw('CREATE EXTENSION IF NOT EXISTS ltree;')
   await knex.raw('CREATE EXTENSION IF NOT EXISTS pgcrypto;')
   await knex.raw('CREATE EXTENSION IF NOT EXISTS pgcrypto;')
 
 
   await knex.schema
   await knex.schema
@@ -187,21 +188,27 @@ exports.up = async knex => {
     .createTable('pageHistory', table => {
     .createTable('pageHistory', table => {
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
       table.uuid('pageId').notNullable().index()
       table.uuid('pageId').notNullable().index()
+      table.string('action').defaultTo('updated')
+      table.jsonb('affectedFields').notNullable().defaultTo('[]')
       table.string('path').notNullable()
       table.string('path').notNullable()
       table.string('hash').notNullable()
       table.string('hash').notNullable()
+      table.string('alias')
       table.string('title').notNullable()
       table.string('title').notNullable()
       table.string('description')
       table.string('description')
+      table.string('icon')
       table.enu('publishState', ['draft', 'published', 'scheduled']).notNullable().defaultTo('draft')
       table.enu('publishState', ['draft', 'published', 'scheduled']).notNullable().defaultTo('draft')
       table.timestamp('publishStartDate')
       table.timestamp('publishStartDate')
       table.timestamp('publishEndDate')
       table.timestamp('publishEndDate')
-      table.string('action').defaultTo('updated')
+      table.jsonb('config').notNullable().defaultTo('{}')
+      table.jsonb('relations').notNullable().defaultTo('[]')
       table.text('content')
       table.text('content')
+      table.text('render')
+      table.jsonb('toc')
       table.string('editor').notNullable()
       table.string('editor').notNullable()
       table.string('contentType').notNullable()
       table.string('contentType').notNullable()
-      table.jsonb('extra').notNullable().defaultTo('{}')
-      table.jsonb('tags').defaultTo('[]')
-      table.timestamp('versionDate').notNullable().defaultTo(knex.fn.now())
+      table.jsonb('scripts').notNullable().defaultTo('{}')
       table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
       table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
+      table.timestamp('versionDate').notNullable().defaultTo(knex.fn.now())
     })
     })
     // PAGE LINKS --------------------------
     // PAGE LINKS --------------------------
     .createTable('pageLinks', table => {
     .createTable('pageLinks', table => {
@@ -212,32 +219,32 @@ exports.up = async knex => {
     // PAGES -------------------------------
     // PAGES -------------------------------
     .createTable('pages', table => {
     .createTable('pages', table => {
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
-      table.string('slug')
       table.string('path').notNullable()
       table.string('path').notNullable()
+      table.specificType('dotPath', 'ltree').notNullable().index()
       table.string('hash').notNullable()
       table.string('hash').notNullable()
+      table.string('alias')
       table.string('title').notNullable()
       table.string('title').notNullable()
       table.string('description')
       table.string('description')
+      table.string('icon')
       table.enu('publishState', ['draft', 'published', 'scheduled']).notNullable().defaultTo('draft')
       table.enu('publishState', ['draft', 'published', 'scheduled']).notNullable().defaultTo('draft')
       table.timestamp('publishStartDate')
       table.timestamp('publishStartDate')
       table.timestamp('publishEndDate')
       table.timestamp('publishEndDate')
+      table.jsonb('config').notNullable().defaultTo('{}')
+      table.jsonb('relations').notNullable().defaultTo('[]')
       table.text('content')
       table.text('content')
       table.text('render')
       table.text('render')
       table.jsonb('toc')
       table.jsonb('toc')
       table.string('editor').notNullable()
       table.string('editor').notNullable()
       table.string('contentType').notNullable()
       table.string('contentType').notNullable()
-      table.jsonb('extra').notNullable().defaultTo('{}')
+      table.boolean('isBrowsable').notNullable().defaultTo(true)
+      table.string('password')
+      table.integer('ratingScore').notNullable().defaultTo(0)
+      table.integer('ratingCount').notNullable().defaultTo(0)
+      table.jsonb('scripts').notNullable().defaultTo('{}')
+      table.jsonb('historyData').notNullable().defaultTo('{}')
       table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
       table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
       table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())
       table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())
     })
     })
-    // PAGE TREE ---------------------------
-    .createTable('pageTree', table => {
-      table.integer('id').unsigned().primary()
-      table.string('path').notNullable()
-      table.integer('depth').unsigned().notNullable()
-      table.string('title').notNullable()
-      table.boolean('isFolder').notNullable().defaultTo(false)
-      table.jsonb('ancestors')
-    })
     // RENDERERS ---------------------------
     // RENDERERS ---------------------------
     .createTable('renderers', table => {
     .createTable('renderers', table => {
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
@@ -365,11 +372,6 @@ exports.up = async knex => {
       table.uuid('creatorId').notNullable().references('id').inTable('users').index()
       table.uuid('creatorId').notNullable().references('id').inTable('users').index()
       table.uuid('siteId').notNullable().references('id').inTable('sites').index()
       table.uuid('siteId').notNullable().references('id').inTable('sites').index()
     })
     })
-    .table('pageTree', table => {
-      table.integer('parent').unsigned().references('id').inTable('pageTree').onDelete('CASCADE')
-      table.uuid('pageId').notNullable().references('id').inTable('pages').onDelete('CASCADE')
-      table.string('localeCode', 5).references('code').inTable('locales')
-    })
     .table('storage', table => {
     .table('storage', table => {
       table.uuid('siteId').notNullable().references('id').inTable('sites')
       table.uuid('siteId').notNullable().references('id').inTable('sites')
     })
     })
@@ -507,7 +509,11 @@ exports.up = async knex => {
       defaults: {
       defaults: {
         timezone: 'America/New_York',
         timezone: 'America/New_York',
         dateFormat: 'YYYY-MM-DD',
         dateFormat: 'YYYY-MM-DD',
-        timeFormat: '12h'
+        timeFormat: '12h',
+        tocDepth: {
+          min: 1,
+          max: 2
+        }
       },
       },
       features: {
       features: {
         ratings: false,
         ratings: false,

+ 27 - 8
server/graph/resolvers/page.js

@@ -143,7 +143,7 @@ module.exports = {
      * FETCH SINGLE PAGE BY ID
      * FETCH SINGLE PAGE BY ID
      */
      */
     async pageById (obj, args, context, info) {
     async pageById (obj, args, context, info) {
-      let page = await WIKI.db.pages.getPageFromDb(args.id)
+      const page = await WIKI.db.pages.getPageFromDb(args.id)
       if (page) {
       if (page) {
         if (WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
         if (WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
           path: page.path,
           path: page.path,
@@ -151,30 +151,37 @@ module.exports = {
         })) {
         })) {
           return {
           return {
             ...page,
             ...page,
-            locale: page.localeCode,
-            editor: page.editorKey
+            ...page.config,
+            scriptCss: page.scripts?.css,
+            scriptJsLoad: page.scripts?.jsLoad,
+            scriptJsUnload: page.scripts?.jsUnload,
+            locale: page.localeCode
           }
           }
         } else {
         } else {
-          throw new WIKI.Error.PageViewForbidden()
+          throw new Error('ERR_FORBIDDEN')
         }
         }
       } else {
       } else {
-        throw new WIKI.Error.PageNotFound()
+        throw new Error('ERR_PAGE_NOT_FOUND')
       }
       }
     },
     },
     /**
     /**
      * FETCH SINGLE PAGE BY PATH
      * FETCH SINGLE PAGE BY PATH
      */
      */
     async pageByPath (obj, args, context, info) {
     async pageByPath (obj, args, context, info) {
+      // console.info(info)
       const pageArgs = pageHelper.parsePath(args.path)
       const pageArgs = pageHelper.parsePath(args.path)
-      let page = await WIKI.db.pages.getPageFromDb({
+      const page = await WIKI.db.pages.getPageFromDb({
         ...pageArgs,
         ...pageArgs,
         siteId: args.siteId
         siteId: args.siteId
       })
       })
       if (page) {
       if (page) {
         return {
         return {
           ...page,
           ...page,
-          locale: page.localeCode,
-          editor: page.editorKey
+          ...page.config,
+          scriptCss: page.scripts?.css,
+          scriptJsLoad: page.scripts?.jsLoad,
+          scriptJsUnload: page.scripts?.jsUnload,
+          locale: page.localeCode
         }
         }
       } else {
       } else {
         throw new Error('ERR_PAGE_NOT_FOUND')
         throw new Error('ERR_PAGE_NOT_FOUND')
@@ -607,8 +614,20 @@ module.exports = {
     }
     }
   },
   },
   Page: {
   Page: {
+    icon (obj) {
+      return obj.icon || 'las la-file-alt'
+    },
+    password (obj) {
+      return obj ? '********' : ''
+    },
     async tags (obj) {
     async tags (obj) {
       return WIKI.db.pages.relatedQuery('tags').for(obj.id)
       return WIKI.db.pages.relatedQuery('tags').for(obj.id)
+    },
+    tocDepth (obj) {
+      return {
+        min: obj.extra?.tocDepth?.min ?? 1,
+        max: obj.extra?.tocDepth?.max ?? 2
+      }
     }
     }
     // comments(pg) {
     // comments(pg) {
     //   return pg.$relatedQuery('comments')
     //   return pg.$relatedQuery('comments')

+ 111 - 35
server/graph/schemas/page.graphql

@@ -32,11 +32,13 @@ extend type Query {
 
 
   pageById(
   pageById(
     id: UUID!
     id: UUID!
+    password: String
   ): Page
   ): Page
 
 
   pageByPath(
   pageByPath(
     siteId: UUID!
     siteId: UUID!
     path: String!
     path: String!
+    password: String
   ): Page
   ): Page
 
 
   tags: [PageTag]!
   tags: [PageTag]!
@@ -69,35 +71,35 @@ extend type Query {
 
 
 extend type Mutation {
 extend type Mutation {
   createPage(
   createPage(
-    siteId: UUID!
+    allowComments: Boolean
+    allowContributions: Boolean
+    allowRatings: Boolean
     content: String!
     content: String!
     description: String!
     description: String!
     editor: String!
     editor: String!
-    isPublished: Boolean!
+    icon: String
+    isBrowsable: Boolean
     locale: String!
     locale: String!
     path: String!
     path: String!
+    publishState: PagePublishState!
     publishEndDate: Date
     publishEndDate: Date
     publishStartDate: Date
     publishStartDate: Date
+    relations: [PageRelationInput!]
     scriptCss: String
     scriptCss: String
-    scriptJs: String
-    tags: [String]!
+    scriptJsLoad: String
+    scriptJsUnload: String
+    showSidebar: Boolean
+    showTags: Boolean
+    showToc: Boolean
+    siteId: UUID!
+    tags: [String!]
     title: String!
     title: String!
+    tocDepth: PageTocDepthInput
   ): PageResponse
   ): PageResponse
 
 
   updatePage(
   updatePage(
     id: UUID!
     id: UUID!
-    content: String
-    description: String
-    editor: String
-    isPublished: Boolean
-    locale: String
-    path: String
-    publishEndDate: Date
-    publishStartDate: Date
-    scriptCss: String
-    scriptJs: String
-    tags: [String]
-    title: String
+    patch: PageUpdateInput!
   ): PageResponse
   ): PageResponse
 
 
   convertPage(
   convertPage(
@@ -163,31 +165,40 @@ type PageMigrationResponse {
 }
 }
 
 
 type Page {
 type Page {
-  id: UUID
-  path: String
-  hash: String
-  title: String
-  description: String
-  isPublished: Boolean
-  publishStartDate: Date
-  publishEndDate: Date
-  tags: [PageTag]
+  allowComments: Boolean
+  allowContributions: Boolean
+  allowRatings: Boolean
+  author: User
   content: String
   content: String
-  render: String
-  toc: [JSON]
   contentType: String
   contentType: String
   createdAt: Date
   createdAt: Date
-  updatedAt: Date
+  creator: User
+  description: String
   editor: String
   editor: String
+  hash: String
+  icon: String
+  id: UUID
+  isBrowsable: Boolean
   locale: String
   locale: String
+  password: String
+  path: String
+  publishEndDate: Date
+  publishStartDate: Date
+  publishState: PagePublishState
+  relations: [PageRelation]
+  render: String
+  scriptJsLoad: String
+  scriptJsUnload: String
   scriptCss: String
   scriptCss: String
-  scriptJs: String
-  authorId: Int
-  authorName: String
-  authorEmail: String
-  creatorId: Int
-  creatorName: String
-  creatorEmail: String
+  showSidebar: Boolean
+  showTags: Boolean
+  showToc: Boolean
+  siteId: UUID
+  tags: [PageTag]
+  title: String
+  toc: [JSON]
+  tocDepth: PageTocDepth
+  updatedAt: Date
 }
 }
 
 
 type PageTag {
 type PageTag {
@@ -299,6 +310,59 @@ type PageConflictLatest {
   updatedAt: Date
   updatedAt: Date
 }
 }
 
 
+type PageRelation {
+  id: UUID
+  position: PageRelationPosition
+  label: String
+  caption: String
+  icon: String
+  target: String
+}
+input PageRelationInput {
+  id: UUID!
+  position: PageRelationPosition!
+  label: String!
+  caption: String
+  icon: String
+  target: String!
+}
+
+input PageUpdateInput {
+  allowComments: Boolean
+  allowContributions: Boolean
+  allowRatings: Boolean
+  content: String
+  description: String
+  icon: String
+  isBrowsable: Boolean
+  locale: String
+  password: String
+  path: String
+  publishEndDate: Date
+  publishStartDate: Date
+  publishState: PagePublishState
+  relations: [PageRelationInput!]
+  scriptJsLoad: String
+  scriptJsUnload: String
+  scriptCss: String
+  showSidebar: Boolean
+  showTags: Boolean
+  showToc: Boolean
+  tags: [String!]
+  title: String
+  tocDepth: PageTocDepthInput
+}
+
+type PageTocDepth {
+  min: Int
+  max: Int
+}
+
+input PageTocDepthInput {
+  min: Int!
+  max: Int!
+}
+
 enum PageOrderBy {
 enum PageOrderBy {
   CREATED
   CREATED
   ID
   ID
@@ -317,3 +381,15 @@ enum PageTreeMode {
   PAGES
   PAGES
   ALL
   ALL
 }
 }
+
+enum PagePublishState {
+  draft
+  published
+  scheduled
+}
+
+enum PageRelationPosition {
+  left
+  center
+  right
+}

+ 2 - 0
server/graph/schemas/site.graphql

@@ -89,6 +89,7 @@ type SiteDefaults {
   timezone: String
   timezone: String
   dateFormat: String
   dateFormat: String
   timeFormat: String
   timeFormat: String
+  tocDepth: PageTocDepth
 }
 }
 
 
 type SiteLocale {
 type SiteLocale {
@@ -174,6 +175,7 @@ input SiteDefaultsInput {
   timezone: String
   timezone: String
   dateFormat: String
   dateFormat: String
   timeFormat: String
   timeFormat: String
+  tocDepth: PageTocDepthInput
 }
 }
 
 
 input SiteThemeInput {
 input SiteThemeInput {

+ 214 - 85
server/models/pages.js

@@ -13,6 +13,8 @@ const TurndownService = require('turndown')
 const turndownPluginGfm = require('@joplin/turndown-plugin-gfm').gfm
 const turndownPluginGfm = require('@joplin/turndown-plugin-gfm').gfm
 const cheerio = require('cheerio')
 const cheerio = require('cheerio')
 
 
+const pageRegex = /^[a-zA0-90-9-_/]*$/
+
 const frontmatterRegex = {
 const frontmatterRegex = {
   html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/,
   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,
   legacy: /^(<!-- TITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)?(<!-- SUBTITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)*([\w\W]*)*/i,
@@ -52,7 +54,7 @@ module.exports = class Page extends Model {
   }
   }
 
 
   static get jsonAttributes() {
   static get jsonAttributes() {
-    return ['extra']
+    return ['config', 'historyData', 'relations', 'scripts', 'toc']
   }
   }
 
 
   static get relationMappings() {
   static get relationMappings() {
@@ -231,11 +233,6 @@ module.exports = class Page extends Model {
       throw new WIKI.Error.Custom('InvalidSiteId', 'Site ID is invalid.')
       throw new WIKI.Error.Custom('InvalidSiteId', 'Site ID is invalid.')
     }
     }
 
 
-    // -> Validate path
-    if (opts.path.includes('.') || opts.path.includes(' ') || opts.path.includes('\\') || opts.path.includes('//')) {
-      throw new WIKI.Error.PageIllegalPath()
-    }
-
     // -> Remove trailing slash
     // -> Remove trailing slash
     if (opts.path.endsWith('/')) {
     if (opts.path.endsWith('/')) {
       opts.path = opts.path.slice(0, -1)
       opts.path = opts.path.slice(0, -1)
@@ -246,6 +243,14 @@ module.exports = class Page extends Model {
       opts.path = opts.path.slice(1)
       opts.path = opts.path.slice(1)
     }
     }
 
 
+    // -> Validate path
+    if (!pageRegex.test(opts.path)) {
+      throw new Error('ERR_INVALID_PATH')
+    }
+
+    opts.path = opts.path.toLowerCase()
+    const dotPath = opts.path.replaceAll('/', '.').replaceAll('-', '_')
+
     // -> Check for page access
     // -> Check for page access
     if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
     if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
       locale: opts.locale,
       locale: opts.locale,
@@ -279,41 +284,52 @@ module.exports = class Page extends Model {
     }
     }
 
 
     // -> Format JS Scripts
     // -> Format JS Scripts
-    let scriptJs = ''
+    let scriptJsLoad = ''
+    let scriptJsUnload = ''
     if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
     if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
       locale: opts.locale,
       locale: opts.locale,
       path: opts.path
       path: opts.path
     })) {
     })) {
-      scriptJs = opts.scriptJs || ''
+      scriptJsLoad = opts.scriptJsLoad || ''
+      scriptJsUnload = opts.scriptJsUnload || ''
     }
     }
 
 
     // -> Create page
     // -> Create page
-    await WIKI.db.pages.query().insert({
+    const page = await WIKI.db.pages.query().insert({
       authorId: opts.user.id,
       authorId: opts.user.id,
       content: opts.content,
       content: opts.content,
       creatorId: opts.user.id,
       creatorId: opts.user.id,
-      contentType: _.get(WIKI.data.editors[opts.editor], 'contentType', 'text'),
+      config: {
+        allowComments: opts.allowComments ?? true,
+        allowContributions: opts.allowContributions ?? true,
+        allowRatings: opts.allowRatings ?? true,
+        showSidebar: opts.showSidebar ?? true,
+        showTags: opts.showTags ?? true,
+        showToc: opts.showToc ?? true,
+        tocDepth: opts.tocDepth ?? WIKI.sites[opts.siteId].config?.defaults.tocDepth
+      },
+      contentType: WIKI.data.editors[opts.editor]?.contentType ?? 'text',
       description: opts.description,
       description: opts.description,
+      dotPath: dotPath,
       editor: opts.editor,
       editor: opts.editor,
       hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
       hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
-      publishState: opts.publishState,
+      icon: opts.icon,
+      isBrowsable: opts.isBrowsable ?? true,
       localeCode: opts.locale,
       localeCode: opts.locale,
       path: opts.path,
       path: opts.path,
+      publishState: opts.publishState,
       publishEndDate: opts.publishEndDate?.toISO(),
       publishEndDate: opts.publishEndDate?.toISO(),
       publishStartDate: opts.publishStartDate?.toISO(),
       publishStartDate: opts.publishStartDate?.toISO(),
+      relations: opts.relations ?? [],
       siteId: opts.siteId,
       siteId: opts.siteId,
       title: opts.title,
       title: opts.title,
       toc: '[]',
       toc: '[]',
-      extra: JSON.stringify({
-        js: scriptJs,
+      scripts: JSON.stringify({
+        jsLoad: scriptJsLoad,
+        jsUnload: scriptJsUnload,
         css: scriptCss
         css: scriptCss
       })
       })
-    })
-    const page = await WIKI.db.pages.getPageFromDb({
-      path: opts.path,
-      locale: opts.locale,
-      userId: opts.user.id
-    })
+    }).returning('*')
 
 
     // -> Save Tags
     // -> Save Tags
     if (opts.tags && opts.tags.length > 0) {
     if (opts.tags && opts.tags.length > 0) {
@@ -365,7 +381,7 @@ module.exports = class Page extends Model {
     // -> Fetch original page
     // -> Fetch original page
     const ogPage = await WIKI.db.pages.query().findById(opts.id)
     const ogPage = await WIKI.db.pages.query().findById(opts.id)
     if (!ogPage) {
     if (!ogPage) {
-      throw new Error('Invalid Page Id')
+      throw new Error('ERR_PAGE_NOT_FOUND')
     }
     }
 
 
     // -> Check for page access
     // -> Check for page access
@@ -373,70 +389,205 @@ module.exports = class Page extends Model {
       locale: ogPage.localeCode,
       locale: ogPage.localeCode,
       path: ogPage.path
       path: ogPage.path
     })) {
     })) {
-      throw new WIKI.Error.PageUpdateForbidden()
+      throw new Error('ERR_PAGE_UPDATE_FORBIDDEN')
     }
     }
 
 
-    // -> Check for empty content
-    if (!opts.content || _.trim(opts.content).length < 1) {
-      throw new WIKI.Error.PageEmptyContent()
+    const patch = {}
+    const historyData = {
+      action: 'updated',
+      affectedFields: []
     }
     }
 
 
     // -> Create version snapshot
     // -> Create version snapshot
-    await WIKI.db.pageHistory.addVersion({
-      ...ogPage,
-      action: opts.action ? opts.action : 'updated',
-      versionDate: ogPage.updatedAt
-    })
+    await WIKI.db.pageHistory.addVersion(ogPage)
+
+    // -> Basic fields
+    if ('title' in opts.patch) {
+      patch.title = opts.patch.title.trim()
+      historyData.affectedFields.push('title')
 
 
-    // -> Format Extra Properties
-    if (!_.isPlainObject(ogPage.extra)) {
-      ogPage.extra = {}
+      if (patch.title.length < 1) {
+        throw new Error('ERR_PAGE_TITLE_MISSING')
+      }
+    }
+
+    if ('description' in opts.patch) {
+      patch.description = opts.patch.description.trim()
+      historyData.affectedFields.push('description')
+    }
+
+    if ('icon' in opts.patch) {
+      patch.icon = opts.patch.icon.trim()
+      historyData.affectedFields.push('icon')
+    }
+
+    if ('content' in opts.patch) {
+      patch.content = opts.patch.content
+      historyData.affectedFields.push('content')
+    }
+
+    // -> Publish State
+    if (opts.patch.publishState) {
+      patch.publishState = opts.patch.publishState
+      historyData.affectedFields.push('publishState')
+
+      if (patch.publishState === 'scheduled' && (!opts.patch.publishStartDate || !opts.patch.publishEndDate)) {
+        throw new Error('ERR_PAGE_MISSING_SCHEDULED_DATES')
+      }
+    }
+    if (opts.patch.publishStartDate) {
+      patch.publishStartDate = opts.patch.publishStartDate
+      historyData.affectedFields.push('publishStartDate')
+    }
+    if (opts.patch.publishEndDate) {
+      patch.publishEndDate = opts.patch.publishEndDate
+      historyData.affectedFields.push('publishEndDate')
+    }
+
+    // -> Page Config
+    if ('isBrowsable' in opts.patch) {
+      patch.config = {
+        ...patch.config ?? ogPage.config ?? {},
+        isBrowsable: opts.patch.isBrowsable
+      }
+      historyData.affectedFields.push('isBrowsable')
+    }
+    if ('allowComments' in opts.patch) {
+      patch.config = {
+        ...patch.config ?? ogPage.config ?? {},
+        allowComments: opts.patch.allowComments
+      }
+      historyData.affectedFields.push('allowComments')
+    }
+    if ('allowContributions' in opts.patch) {
+      patch.config = {
+        ...patch.config ?? ogPage.config ?? {},
+        allowContributions: opts.patch.allowContributions
+      }
+      historyData.affectedFields.push('allowContributions')
+    }
+    if ('allowRatings' in opts.patch) {
+      patch.config = {
+        ...patch.config ?? ogPage.config ?? {},
+        allowRatings: opts.patch.allowRatings
+      }
+      historyData.affectedFields.push('allowRatings')
+    }
+    if ('showSidebar' in opts.patch) {
+      patch.config = {
+        ...patch.config ?? ogPage.config ?? {},
+        showSidebar: opts.patch.showSidebar
+      }
+      historyData.affectedFields.push('showSidebar')
+    }
+    if ('showTags' in opts.patch) {
+      patch.config = {
+        ...patch.config ?? ogPage.config ?? {},
+        showTags: opts.patch.showTags
+      }
+      historyData.affectedFields.push('showTags')
+    }
+    if ('showToc' in opts.patch) {
+      patch.config = {
+        ...patch.config ?? ogPage.config ?? {},
+        showToc: opts.patch.showToc
+      }
+      historyData.affectedFields.push('showToc')
+    }
+    if ('tocDepth' in opts.patch) {
+      patch.config = {
+        ...patch.config ?? ogPage.config ?? {},
+        tocDepth: opts.patch.tocDepth
+      }
+      historyData.affectedFields.push('tocDepth')
+
+      if (patch.config.tocDepth?.min < 1 || patch.config.tocDepth?.min > 6) {
+        throw new Error('ERR_PAGE_INVALID_TOC_DEPTH')
+      }
+      if (patch.config.tocDepth?.max < 1 || patch.config.tocDepth?.max > 6) {
+        throw new Error('ERR_PAGE_INVALID_TOC_DEPTH')
+      }
+    }
+
+    // -> Relations
+    if ('relations' in opts.patch) {
+      patch.relations = opts.patch.relations.map(r => {
+        if (r.label.length < 1) {
+          throw new Error('ERR_PAGE_RELATION_LABEL_MISSING')
+        } else if (r.label.length > 255) {
+          throw new Error('ERR_PAGE_RELATION_LABEL_TOOLONG')
+        } else if (r.icon.length > 255) {
+          throw new Error('ERR_PAGE_RELATION_ICON_INVALID')
+        } else if (r.target.length > 1024) {
+          throw new Error('ERR_PAGE_RELATION_TARGET_INVALID')
+        }
+        return r
+      })
+      historyData.affectedFields.push('relations')
     }
     }
 
 
     // -> Format CSS Scripts
     // -> Format CSS Scripts
-    let scriptCss = _.get(ogPage, 'extra.css', '')
-    if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
-      locale: opts.locale,
-      path: opts.path
-    })) {
-      if (!_.isEmpty(opts.scriptCss)) {
-        scriptCss = new CleanCSS({ inline: false }).minify(opts.scriptCss).styles
-      } else {
-        scriptCss = ''
+    if (opts.patch.scriptCss) {
+      if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
+        locale: ogPage.localeCode,
+        path: ogPage.path
+      })) {
+        patch.scripts = {
+          ...patch.scripts ?? ogPage.scripts ?? {},
+          css: !_.isEmpty(opts.patch.scriptCss) ? new CleanCSS({ inline: false }).minify(opts.patch.scriptCss).styles : ''
+        }
+        historyData.affectedFields.push('scripts.css')
       }
       }
     }
     }
 
 
     // -> Format JS Scripts
     // -> Format JS Scripts
-    let scriptJs = _.get(ogPage, 'extra.js', '')
-    if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
-      locale: opts.locale,
-      path: opts.path
-    })) {
-      scriptJs = opts.scriptJs || ''
+    if (opts.patch.scriptJsLoad) {
+      if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
+        locale: ogPage.localeCode,
+        path: ogPage.path
+      })) {
+        patch.scripts = {
+          ...patch.scripts ?? ogPage.scripts ?? {},
+          jsLoad: opts.patch.scriptJsLoad ?? ''
+        }
+        historyData.affectedFields.push('scripts.jsLoad')
+      }
+    }
+    if (opts.patch.scriptJsUnload) {
+      if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
+        locale: ogPage.localeCode,
+        path: ogPage.path
+      })) {
+        patch.scripts = {
+          ...patch.scripts ?? ogPage.scripts ?? {},
+          jsUnload: opts.patch.scriptJsUnload ?? ''
+        }
+        historyData.affectedFields.push('scripts.jsUnload')
+      }
+    }
+
+    // -> Tags
+    if ('tags' in opts.patch) {
+      historyData.affectedFields.push('tags')
     }
     }
 
 
     // -> Update page
     // -> Update page
     await WIKI.db.pages.query().patch({
     await WIKI.db.pages.query().patch({
+      ...patch,
       authorId: opts.user.id,
       authorId: opts.user.id,
-      content: opts.content,
-      description: opts.description,
-      publishState: opts.publishState,
-      publishEndDate: opts.publishEndDate?.toISO(),
-      publishStartDate: opts.publishStartDate?.toISO(),
-      title: opts.title,
-      extra: JSON.stringify({
-        ...ogPage.extra,
-        js: scriptJs,
-        css: scriptCss
-      })
+      historyData
     }).where('id', ogPage.id)
     }).where('id', ogPage.id)
     let page = await WIKI.db.pages.getPageFromDb(ogPage.id)
     let page = await WIKI.db.pages.getPageFromDb(ogPage.id)
 
 
     // -> Save Tags
     // -> Save Tags
-    await WIKI.db.tags.associateTags({ tags: opts.tags, page })
+    if (opts.patch.tags) {
+      await WIKI.db.tags.associateTags({ tags: opts.patch.tags, page })
+    }
 
 
     // -> Render page to HTML
     // -> Render page to HTML
-    await WIKI.db.pages.renderPage(page)
+    if (opts.patch.content) {
+      await WIKI.db.pages.renderPage(page)
+    }
     WIKI.events.outbound.emit('deletePageFromCache', page.hash)
     WIKI.events.outbound.emit('deletePageFromCache', page.hash)
 
 
     // // -> Update Search Index
     // // -> Update Search Index
@@ -468,11 +619,6 @@ module.exports = class Page extends Model {
         destinationPath: opts.path,
         destinationPath: opts.path,
         user: opts.user
         user: opts.user
       })
       })
-    } else {
-      // -> Update title of page tree entry
-      await WIKI.db.knex.table('pageTree').where({
-        pageId: page.id
-      }).update('title', page.title)
     }
     }
 
 
     // -> Get latest updatedAt
     // -> Get latest updatedAt
@@ -944,6 +1090,8 @@ module.exports = class Page extends Model {
    * @returns {Promise} Promise of the Page Model Instance
    * @returns {Promise} Promise of the Page Model Instance
    */
    */
   static async getPage(opts) {
   static async getPage(opts) {
+    return WIKI.db.pages.getPageFromDb(opts)
+
     // -> Get from cache first
     // -> Get from cache first
     let page = await WIKI.db.pages.getPageFromCache(opts)
     let page = await WIKI.db.pages.getPageFromCache(opts)
     if (!page) {
     if (!page) {
@@ -974,26 +1122,7 @@ module.exports = class Page extends Model {
     try {
     try {
       return WIKI.db.pages.query()
       return WIKI.db.pages.query()
         .column([
         .column([
-          'pages.id',
-          'pages.path',
-          'pages.hash',
-          'pages.title',
-          'pages.description',
-          'pages.publishState',
-          'pages.publishStartDate',
-          'pages.publishEndDate',
-          'pages.content',
-          'pages.render',
-          'pages.toc',
-          'pages.contentType',
-          'pages.createdAt',
-          'pages.updatedAt',
-          'pages.editor',
-          'pages.localeCode',
-          'pages.authorId',
-          'pages.creatorId',
-          'pages.siteId',
-          'pages.extra',
+          'pages.*',
           {
           {
             authorName: 'author.name',
             authorName: 'author.name',
             authorEmail: 'author.email',
             authorEmail: 'author.email',

+ 23 - 23
ux/package.json

@@ -11,7 +11,7 @@
     "lint": "eslint --ext .js,.vue ./"
     "lint": "eslint --ext .js,.vue ./"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@apollo/client": "3.6.9",
+    "@apollo/client": "3.7.1",
     "@codemirror/autocomplete": "6.0.2",
     "@codemirror/autocomplete": "6.0.2",
     "@codemirror/basic-setup": "0.20.0",
     "@codemirror/basic-setup": "0.20.0",
     "@codemirror/closebrackets": "0.19.2",
     "@codemirror/closebrackets": "0.19.2",
@@ -31,8 +31,8 @@
     "@codemirror/state": "6.0.1",
     "@codemirror/state": "6.0.1",
     "@codemirror/tooltip": "0.19.16",
     "@codemirror/tooltip": "0.19.16",
     "@codemirror/view": "6.0.2",
     "@codemirror/view": "6.0.2",
-    "@lezer/common": "1.0.0",
-    "@quasar/extras": "1.15.1",
+    "@lezer/common": "1.0.1",
+    "@quasar/extras": "1.15.5",
     "@tiptap/core": "2.0.0-beta.176",
     "@tiptap/core": "2.0.0-beta.176",
     "@tiptap/extension-code-block": "2.0.0-beta.37",
     "@tiptap/extension-code-block": "2.0.0-beta.37",
     "@tiptap/extension-code-block-lowlight": "2.0.0-beta.68",
     "@tiptap/extension-code-block-lowlight": "2.0.0-beta.68",
@@ -58,45 +58,45 @@
     "@tiptap/starter-kit": "2.0.0-beta.185",
     "@tiptap/starter-kit": "2.0.0-beta.185",
     "@tiptap/vue-3": "2.0.0-beta.91",
     "@tiptap/vue-3": "2.0.0-beta.91",
     "apollo-upload-client": "17.0.0",
     "apollo-upload-client": "17.0.0",
-    "browser-fs-access": "0.31.0",
+    "browser-fs-access": "0.31.1",
     "clipboard": "2.0.11",
     "clipboard": "2.0.11",
     "codemirror": "6.0.1",
     "codemirror": "6.0.1",
-    "filesize": "9.0.11",
+    "filesize": "10.0.5",
     "filesize-parser": "1.5.0",
     "filesize-parser": "1.5.0",
     "graphql": "16.6.0",
     "graphql": "16.6.0",
     "graphql-tag": "2.12.6",
     "graphql-tag": "2.12.6",
     "js-cookie": "3.0.1",
     "js-cookie": "3.0.1",
     "jwt-decode": "3.1.2",
     "jwt-decode": "3.1.2",
     "lodash-es": "4.17.21",
     "lodash-es": "4.17.21",
-    "luxon": "3.0.1",
-    "pinia": "2.0.20",
+    "luxon": "3.1.0",
+    "pinia": "2.0.23",
     "pug": "3.0.2",
     "pug": "3.0.2",
-    "quasar": "2.7.7",
-    "socket.io-client": "4.5.2",
+    "quasar": "2.10.1",
+    "socket.io-client": "4.5.3",
     "tippy.js": "6.3.7",
     "tippy.js": "6.3.7",
-    "uuid": "8.3.2",
-    "v-network-graph": "0.6.6",
-    "vue": "3.2.37",
-    "vue-codemirror": "6.0.2",
+    "uuid": "9.0.0",
+    "v-network-graph": "0.6.10",
+    "vue": "3.2.41",
+    "vue-codemirror": "6.1.1",
     "vue-i18n": "9.2.2",
     "vue-i18n": "9.2.2",
-    "vue-router": "4.1.3",
+    "vue-router": "4.1.6",
     "vue3-otp-input": "0.3.6",
     "vue3-otp-input": "0.3.6",
     "vuedraggable": "4.1.0",
     "vuedraggable": "4.1.0",
-    "xterm": "4.19.0",
+    "xterm": "5.0.0",
     "zxcvbn": "4.4.2"
     "zxcvbn": "4.4.2"
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@intlify/vite-plugin-vue-i18n": "6.0.1",
-    "@quasar/app-vite": "1.0.6",
-    "@types/lodash": "4.14.184",
-    "@volar/vue-language-plugin-pug": "1.0.1",
+    "@intlify/vite-plugin-vue-i18n": "6.0.3",
+    "@quasar/app-vite": "1.1.3",
+    "@types/lodash": "4.14.188",
+    "@volar/vue-language-plugin-pug": "1.0.9",
     "browserlist": "latest",
     "browserlist": "latest",
-    "eslint": "8.22.0",
+    "eslint": "8.27.0",
     "eslint-config-standard": "17.0.0",
     "eslint-config-standard": "17.0.0",
     "eslint-plugin-import": "2.26.0",
     "eslint-plugin-import": "2.26.0",
-    "eslint-plugin-n": "15.2.4",
-    "eslint-plugin-promise": "6.0.0",
-    "eslint-plugin-vue": "9.3.0"
+    "eslint-plugin-n": "15.5.0",
+    "eslint-plugin-promise": "6.1.1",
+    "eslint-plugin-vue": "9.7.0"
   },
   },
   "engines": {
   "engines": {
     "node": "^18 || ^16",
     "node": "^18 || ^16",

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M10.5 1.5H28.5V4.5H10.5z"/><path fill="#4788c7" d="M28,2v2H11V2H28 M29,1H10v4h19V1L29,1z"/><path fill="#b6dcfe" d="M1.5 34.5H37.5V37.5H1.5z"/><path fill="#4788c7" d="M37 35v2H2v-2H37M38 34H1v4h37V34L38 34zM37.5 32A.5.5 0 1 0 37.5 33 .5.5 0 1 0 37.5 32zM36.808 30A.5.5 0 1 0 36.808 31 .5.5 0 1 0 36.808 30zM36.115 28A.5.5 0 1 0 36.115 29 .5.5 0 1 0 36.115 28zM35.423 26A.5.5 0 1 0 35.423 27 .5.5 0 1 0 35.423 26zM34.731 24A.5.5 0 1 0 34.731 25 .5.5 0 1 0 34.731 24zM34.038 22A.5.5 0 1 0 34.038 23 .5.5 0 1 0 34.038 22zM33.346 20A.5.5 0 1 0 33.346 21 .5.5 0 1 0 33.346 20zM32.654 18A.5.5 0 1 0 32.654 19 .5.5 0 1 0 32.654 18zM31.962 16A.5.5 0 1 0 31.962 17 .5.5 0 1 0 31.962 16zM31.269 14A.5.5 0 1 0 31.269 15 .5.5 0 1 0 31.269 14zM30.577 12A.5.5 0 1 0 30.577 13 .5.5 0 1 0 30.577 12zM29.885 10A.5.5 0 1 0 29.885 11 .5.5 0 1 0 29.885 10zM29.192 8A.5.5 0 1 0 29.192 9 .5.5 0 1 0 29.192 8zM28.5 6A.5.5 0 1 0 28.5 7 .5.5 0 1 0 28.5 6z"/><g><path fill="#4788c7" d="M1.5 32A.5.5 0 1 0 1.5 33 .5.5 0 1 0 1.5 32zM2.192 30A.5.5 0 1 0 2.192 31 .5.5 0 1 0 2.192 30zM2.885 28A.5.5 0 1 0 2.885 29 .5.5 0 1 0 2.885 28zM3.577 26A.5.5 0 1 0 3.577 27 .5.5 0 1 0 3.577 26zM4.269 24A.5.5 0 1 0 4.269 25 .5.5 0 1 0 4.269 24zM4.962 22A.5.5 0 1 0 4.962 23 .5.5 0 1 0 4.962 22zM5.654 20A.5.5 0 1 0 5.654 21 .5.5 0 1 0 5.654 20zM6.346 18A.5.5 0 1 0 6.346 19 .5.5 0 1 0 6.346 18zM7.038 16A.5.5 0 1 0 7.038 17 .5.5 0 1 0 7.038 16zM7.731 14A.5.5 0 1 0 7.731 15 .5.5 0 1 0 7.731 14zM8.423 12A.5.5 0 1 0 8.423 13 .5.5 0 1 0 8.423 12zM9.115 10A.5.5 0 1 0 9.115 11 .5.5 0 1 0 9.115 10zM9.808 8A.5.5 0 1 0 9.808 9 .5.5 0 1 0 9.808 8zM10.5 6A.5.5 0 1 0 10.5 7 .5.5 0 1 0 10.5 6z"/></g><g><path fill="#4788c7" d="M21 27L21 19 18 19 18 27 13 27 19.5 34 26 27z"/></g><g><path fill="#4788c7" d="M18 12L18 20 21 20 21 12 26 12 19.5 5 13 12z"/></g></svg>

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

@@ -81,7 +81,7 @@ q-header.bg-header.text-white.site-header(
         round
         round
         dense
         dense
         icon='las la-tools'
         icon='las la-tools'
-        color='secondary'
+        color='positive'
         to='/_admin'
         to='/_admin'
         aria-label='Administration'
         aria-label='Administration'
         )
         )

+ 50 - 22
ux/src/components/PagePropertiesDialog.vue

@@ -41,28 +41,39 @@ q-card.page-properties-dialog
           outlined
           outlined
           dense
           dense
         )
         )
+        q-input(
+          v-model='pageStore.icon'
+          :label='t(`editor.props.icon`)'
+          outlined
+          dense
+          )
+          template(#append)
+            q-icon.cursor-pointer(
+              name='las la-icons'
+              color='primary'
+            )
     q-card-section.alt-card(id='refCardPublishState')
     q-card-section.alt-card(id='refCardPublishState')
       .text-overline.q-pb-xs.items-center.flex #[q-icon.q-mr-sm(name='las la-power-off', size='xs')] {{t('editor.props.publishState')}}
       .text-overline.q-pb-xs.items-center.flex #[q-icon.q-mr-sm(name='las la-power-off', size='xs')] {{t('editor.props.publishState')}}
       q-form.q-gutter-md
       q-form.q-gutter-md
         div
         div
           q-btn-toggle(
           q-btn-toggle(
-            v-model='pageStore.isPublished'
+            v-model='pageStore.publishState'
             push
             push
             glossy
             glossy
             no-caps
             no-caps
             toggle-color='primary'
             toggle-color='primary'
             :options=`[
             :options=`[
-              { label: t('editor.props.draft'), value: false },
-              { label: t('editor.props.published'), value: true },
-              { label: t('editor.props.dateRange'), value: null }
+              { label: t('editor.props.draft'), value: 'draft' },
+              { label: t('editor.props.published'), value: 'published' },
+              { label: t('editor.props.dateRange'), value: 'scheduled' }
             ]`
             ]`
           )
           )
-        .text-caption(v-if='pageStore.isPublished'): em {{t('editor.props.publishedHint')}}
-        .text-caption(v-else-if='pageStore.isPublished === false'): em {{t('editor.props.draftHint')}}
-        template(v-else-if='pageStore.isPublished === null')
+        .text-caption(v-if='pageStore.publishState === `published`'): em {{t('editor.props.publishedHint')}}
+        .text-caption(v-else-if='pageStore.publishState === `draft`'): em {{t('editor.props.draftHint')}}
+        template(v-else-if='pageStore.publishState === `scheduled`')
           .text-caption: em {{t('editor.props.dateRangeHint')}}
           .text-caption: em {{t('editor.props.dateRangeHint')}}
           q-date(
           q-date(
-            v-model='pageStore.publishingRange'
+            v-model='publishingRange'
             range
             range
             flat
             flat
             bordered
             bordered
@@ -230,7 +241,7 @@ q-card.page-properties-dialog
       q-form.q-gutter-md.q-pt-sm
       q-form.q-gutter-md.q-pt-sm
         div
         div
           q-toggle(
           q-toggle(
-            v-model='pageStore.showInTree'
+            v-model='pageStore.isBrowsable'
             dense
             dense
             :label='$t(`editor.props.showInTree`)'
             :label='$t(`editor.props.showInTree`)'
             color='primary'
             color='primary'
@@ -240,6 +251,7 @@ q-card.page-properties-dialog
         div
         div
           q-toggle(
           q-toggle(
             v-model='state.requirePassword'
             v-model='state.requirePassword'
+            @update:model-value='toggleRequirePassword'
             dense
             dense
             :label='$t(`editor.props.requirePassword`)'
             :label='$t(`editor.props.requirePassword`)'
             color='primary'
             color='primary'
@@ -252,7 +264,7 @@ q-card.page-properties-dialog
           )
           )
           q-input(
           q-input(
             ref='iptPagePassword'
             ref='iptPagePassword'
-            v-model='state.password'
+            v-model='pageStore.password'
             :label='t(`editor.props.password`)'
             :label='t(`editor.props.password`)'
             :hint='t(`editor.props.passwordHint`)'
             :hint='t(`editor.props.passwordHint`)'
             outlined
             outlined
@@ -272,7 +284,7 @@ q-card.page-properties-dialog
 <script setup>
 <script setup>
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
 import { useQuasar } from 'quasar'
 import { useQuasar } from 'quasar'
-import { nextTick, onMounted, reactive, ref, watch } from 'vue'
+import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
 import { DateTime } from 'luxon'
 import { DateTime } from 'luxon'
 
 
 import PageRelationDialog from './PageRelationDialog.vue'
 import PageRelationDialog from './PageRelationDialog.vue'
@@ -302,9 +314,7 @@ const { t } = useI18n()
 const state = reactive({
 const state = reactive({
   showRelationDialog: false,
   showRelationDialog: false,
   showScriptsDialog: false,
   showScriptsDialog: false,
-  publishingRange: {},
   requirePassword: false,
   requirePassword: false,
-  password: '',
   editRelationId: null,
   editRelationId: null,
   pageScriptsMode: 'jsLoad',
   pageScriptsMode: 'jsLoad',
   showQuickAccess: true
   showQuickAccess: true
@@ -325,19 +335,23 @@ const quickaccess = [
 
 
 const iptPagePassword = ref(null)
 const iptPagePassword = ref(null)
 
 
-// WATCHERS
+// COMPUTED
 
 
-watch(() => state.requirePassword, (newValue) => {
-  if (newValue) {
-    nextTick(() => {
-      iptPagePassword.value.focus()
-      iptPagePassword.value.$el.scrollIntoView({
-        behavior: 'smooth'
-      })
-    })
+const publishingRange = computed({
+  get () {
+    return {
+      from: pageStore.publishStartDate,
+      to: pageStore.publishEndDate
+    }
+  },
+  set (newValue) {
+    pageStore.publishStartDate = newValue?.from
+    pageStore.publishEndDate = newValue?.to
   }
   }
 })
 })
 
 
+// WATCHERS
+
 pageStore.$subscribe(() => {
 pageStore.$subscribe(() => {
   editorStore.$patch({
   editorStore.$patch({
     lastChangeTimestamp: DateTime.utc()
     lastChangeTimestamp: DateTime.utc()
@@ -366,10 +380,24 @@ function jumpToSection (id) {
     behavior: 'smooth'
     behavior: 'smooth'
   })
   })
 }
 }
+function toggleRequirePassword (newValue) {
+  if (newValue) {
+    nextTick(() => {
+      iptPagePassword.value.focus()
+      iptPagePassword.value.$el.scrollIntoView({
+        behavior: 'smooth'
+      })
+    })
+  } else {
+    pageStore.password = ''
+  }
+}
 
 
 // MOUNTED
 // MOUNTED
 
 
 onMounted(() => {
 onMounted(() => {
+  state.requirePassword = pageStore.password?.length > 0
+
   setTimeout(() => {
   setTimeout(() => {
     state.showQuickAccess = true
     state.showQuickAccess = true
   }, 300)
   }, 300)

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

@@ -1552,5 +1552,8 @@
   "profile.avatarUploadSuccess": "Profile picture uploaded successfully.",
   "profile.avatarUploadSuccess": "Profile picture uploaded successfully.",
   "profile.avatarUploadFailed": "Failed to upload user profile picture.",
   "profile.avatarUploadFailed": "Failed to upload user profile picture.",
   "profile.avatarClearSuccess": "Profile picture cleared successfully.",
   "profile.avatarClearSuccess": "Profile picture cleared successfully.",
-  "profile.avatarClearFailed": "Failed to clear profile picture."
+  "profile.avatarClearFailed": "Failed to clear profile picture.",
+  "admin.general.defaultTocDepth": "Default ToC Depth",
+  "admin.general.defaultTocDepthHint": "The default minimum and maximum header levels to show in the table of contents.",
+  "editor.props.icon": "Icon"
 }
 }

+ 33 - 2
ux/src/pages/AdminGeneral.vue

@@ -371,6 +371,25 @@ q-page.admin-general
               toggle-color='primary'
               toggle-color='primary'
               :options='timeFormats'
               :options='timeFormats'
             )
             )
+        q-separator.q-my-sm(inset)
+        q-item
+          blueprint-icon(icon='depth')
+          q-item-section
+            q-item-label {{t(`admin.general.defaultTocDepth`)}}
+            q-item-label(caption) {{t(`admin.general.defaultTocDepthHint`)}}
+          q-item-section.col-auto.q-pl-sm(style='min-width: 180px;')
+            .text-caption {{t('editor.props.tocMinMaxDepth')}} #[strong (H{{state.config.defaults.tocDepth.min}} &rarr; H{{state.config.defaults.tocDepth.max}})]
+            q-range(
+              v-model='state.config.defaults.tocDepth'
+              :min='1'
+              :max='6'
+              color='primary'
+              :left-label-value='`H` + state.config.defaults.tocDepth.min'
+              :right-label-value='`H` + state.config.defaults.tocDepth.max'
+              snap
+              label
+              markers
+            )
 
 
       //- -----------------------
       //- -----------------------
       //- SEO
       //- SEO
@@ -479,7 +498,11 @@ const state = reactive({
     defaults: {
     defaults: {
       timezone: '',
       timezone: '',
       dateFormat: '',
       dateFormat: '',
-      timeFormat: ''
+      timeFormat: '',
+      tocDepth: {
+        min: 1,
+        max: 2
+      }
     },
     },
     robots: {
     robots: {
       index: false,
       index: false,
@@ -573,6 +596,10 @@ async function load () {
             timezone
             timezone
             dateFormat
             dateFormat
             timeFormat
             timeFormat
+            tocDepth {
+              min
+              max
+            }
           }
           }
         }
         }
       }
       }
@@ -635,7 +662,11 @@ async function save () {
           defaults: {
           defaults: {
             timezone: state.config.defaults?.timezone ?? 'America/New_York',
             timezone: state.config.defaults?.timezone ?? 'America/New_York',
             dateFormat: state.config.defaults?.dateFormat ?? 'YYYY-MM-DD',
             dateFormat: state.config.defaults?.dateFormat ?? 'YYYY-MM-DD',
-            timeFormat: state.config.defaults?.timeFormat ?? '12h'
+            timeFormat: state.config.defaults?.timeFormat ?? '12h',
+            tocDepth: {
+              min: state.config.defaults?.tocDepth?.min ?? 1,
+              max: state.config.defaults?.tocDepth?.max ?? 2
+            }
           }
           }
         }
         }
       }
       }

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

@@ -325,7 +325,7 @@ q-page.admin-mail
 <script setup>
 <script setup>
 import { cloneDeep } from 'lodash-es'
 import { cloneDeep } from 'lodash-es'
 import gql from 'graphql-tag'
 import gql from 'graphql-tag'
-import filesize from 'filesize'
+import { filesize } from 'filesize'
 import filesizeParser from 'filesize-parser'
 import filesizeParser from 'filesize-parser'
 
 
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'

+ 16 - 2
ux/src/pages/Index.vue

@@ -21,7 +21,7 @@ q-page.column
           :to='brd.path'
           :to='brd.path'
           )
           )
     .col-auto.flex.items-center.justify-end
     .col-auto.flex.items-center.justify-end
-      template(v-if='!pageStore.isPublished')
+      template(v-if='!pageStore.publishState === `draft`')
         .text-caption.text-accent: strong Unpublished
         .text-caption.text-accent: strong Unpublished
         q-separator.q-mx-sm(vertical)
         q-separator.q-mx-sm(vertical)
       .text-caption.text-grey-6 Last modified on #[strong {{lastModified}}]
       .text-caption.text-grey-6 Last modified on #[strong {{lastModified}}]
@@ -233,6 +233,7 @@ q-page.column
         color='deep-orange-9'
         color='deep-orange-9'
         aria-label='Page Data'
         aria-label='Page Data'
         @click='togglePageData'
         @click='togglePageData'
+        disable
         )
         )
         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)
@@ -519,7 +520,20 @@ async function discardChanges () {
 }
 }
 
 
 async function saveChanges () {
 async function saveChanges () {
-
+  $q.loading.show()
+  try {
+    await pageStore.pageSave()
+    $q.notify({
+      type: 'positive',
+      message: 'Page saved successfully.'
+    })
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to save page changes.'
+    })
+  }
+  $q.loading.hide()
 }
 }
 </script>
 </script>
 
 

+ 140 - 38
ux/src/stores/page.js

@@ -1,11 +1,57 @@
 import { defineStore } from 'pinia'
 import { defineStore } from 'pinia'
 import gql from 'graphql-tag'
 import gql from 'graphql-tag'
-import { cloneDeep, last, transform } from 'lodash-es'
+import { cloneDeep, last, pick, transform } from 'lodash-es'
 import { DateTime } from 'luxon'
 import { DateTime } from 'luxon'
 
 
 import { useSiteStore } from './site'
 import { useSiteStore } from './site'
 import { useEditorStore } from './editor'
 import { useEditorStore } from './editor'
 
 
+const pagePropsFragment = gql`
+  fragment PageRead on Page {
+    allowComments
+    allowContributions
+    allowRatings
+    contentType
+    createdAt
+    description
+    editor
+    icon
+    id
+    isBrowsable
+    locale
+    password
+    path
+    publishEndDate
+    publishStartDate
+    publishState
+    relations {
+      id
+      position
+      label
+      caption
+      icon
+      target
+    }
+    render
+    scriptJsLoad
+    scriptJsUnload
+    scriptCss
+    showSidebar
+    showTags
+    showToc
+    tags {
+      tag
+      title
+    }
+    title
+    toc
+    tocDepth {
+      min
+      max
+    }
+    updatedAt
+  }
+`
 const gqlQueries = {
 const gqlQueries = {
   pageById: gql`
   pageById: gql`
     query loadPage (
     query loadPage (
@@ -14,16 +60,10 @@ const gqlQueries = {
       pageById(
       pageById(
         id: $id
         id: $id
       ) {
       ) {
-        id
-        title
-        description
-        path
-        locale
-        updatedAt
-        render
-        toc
+        ...PageRead
       }
       }
     }
     }
+    ${pagePropsFragment}
   `,
   `,
   pageByPath: gql`
   pageByPath: gql`
     query loadPage (
     query loadPage (
@@ -34,58 +74,49 @@ const gqlQueries = {
         siteId: $siteId
         siteId: $siteId
         path: $path
         path: $path
       ) {
       ) {
-        id
-        title
-        description
-        path
-        locale
-        updatedAt
-        render
-        toc
+        ...PageRead
       }
       }
     }
     }
+    ${pagePropsFragment}
   `
   `
 }
 }
 
 
 export const usePageStore = defineStore('page', {
 export const usePageStore = defineStore('page', {
   state: () => ({
   state: () => ({
-    isLoading: true,
-    mode: 'view',
-    editor: 'wysiwyg',
-    editorMode: 'edit',
-    id: 0,
+    allowComments: false,
+    allowContributions: true,
+    allowRatings: true,
     authorId: 0,
     authorId: 0,
     authorName: '',
     authorName: '',
+    commentsCount: 0,
+    content: '',
     createdAt: '',
     createdAt: '',
     description: '',
     description: '',
-    isPublished: true,
-    showInTree: true,
+    icon: 'las la-file-alt',
+    id: '',
+    isBrowsable: true,
     locale: 'en',
     locale: 'en',
+    password: '',
     path: '',
     path: '',
     publishEndDate: '',
     publishEndDate: '',
     publishStartDate: '',
     publishStartDate: '',
-    tags: [],
-    title: '',
-    icon: 'las la-file-alt',
-    updatedAt: '',
+    publishState: '',
     relations: [],
     relations: [],
+    render: '',
     scriptJsLoad: '',
     scriptJsLoad: '',
     scriptJsUnload: '',
     scriptJsUnload: '',
-    scriptStyles: '',
-    allowComments: false,
-    allowContributions: true,
-    allowRatings: true,
+    scriptCss: '',
     showSidebar: true,
     showSidebar: true,
-    showToc: true,
     showTags: true,
     showTags: true,
+    showToc: true,
+    tags: [],
+    title: '',
+    toc: [],
     tocDepth: {
     tocDepth: {
       min: 1,
       min: 1,
       max: 2
       max: 2
     },
     },
-    commentsCount: 0,
-    content: '',
-    render: '',
-    toc: []
+    updatedAt: ''
   }),
   }),
   getters: {
   getters: {
     breadcrumbs: (state) => {
     breadcrumbs: (state) => {
@@ -120,7 +151,11 @@ export const usePageStore = defineStore('page', {
           throw new Error('ERR_PAGE_NOT_FOUND')
           throw new Error('ERR_PAGE_NOT_FOUND')
         }
         }
         // Update page store
         // Update page store
-        this.$patch(pageData)
+        this.$patch({
+          ...pageData,
+          relations: pageData.relations.map(r => pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])),
+          tocDepth: pick(pageData.tocDepth, ['min', 'max'])
+        })
         // Update editor state timestamps
         // Update editor state timestamps
         const curDate = DateTime.utc()
         const curDate = DateTime.utc()
         editorStore.$patch({
         editorStore.$patch({
@@ -174,6 +209,73 @@ export const usePageStore = defineStore('page', {
       // -> View Mode
       // -> View Mode
       this.mode = 'edit'
       this.mode = 'edit'
     },
     },
+    /**
+     * PAGE SAVE
+     */
+    async pageSave () {
+      const editorStore = useEditorStore()
+      try {
+        const resp = await APOLLO_CLIENT.mutate({
+          mutation: gql`
+            mutation savePage (
+              $id: UUID!
+              $patch: PageUpdateInput!
+            ) {
+              updatePage (
+                id: $id
+                patch: $patch
+              ) {
+                operation {
+                  succeeded
+                  message
+                }
+              }
+            }
+            `,
+          variables: {
+            id: this.id,
+            patch: pick(this, [
+              'allowComments',
+              'allowContributions',
+              'allowRatings',
+              // 'content',
+              'description',
+              'icon',
+              'isBrowsable',
+              'locale',
+              'password',
+              'path',
+              'publishEndDate',
+              'publishStartDate',
+              'publishState',
+              'relations',
+              'scriptJsLoad',
+              'scriptJsUnload',
+              'scriptCss',
+              'showSidebar',
+              'showTags',
+              'showToc',
+              'tags',
+              'title',
+              'tocDepth'
+            ])
+          }
+        })
+        const result = resp?.data?.updatePage?.operation ?? {}
+        if (!result.succeeded) {
+          throw new Error(result.message)
+        }
+        // Update editor state timestamps
+        const curDate = DateTime.utc()
+        editorStore.$patch({
+          lastChangeTimestamp: curDate,
+          lastSaveTimestamp: curDate
+        })
+      } catch (err) {
+        console.warn(err)
+        throw err
+      }
+    },
     generateToc () {
     generateToc () {
 
 
     }
     }

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 235 - 347
ux/yarn.lock


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio