2
0
Эх сурвалжийг харах

feat: remove localeCode ref + new nav items + fetch current

NGPixel 1 жил өмнө
parent
commit
5eaba2cbb0

+ 1 - 1
server/core/auth.mjs

@@ -188,7 +188,7 @@ export default {
             name: 'API',
             pictureUrl: null,
             timezone: 'America/New_York',
-            localeCode: 'en',
+            locale: 'en',
             permissions: _.get(WIKI.auth.groups, `${user.grp}.permissions`, []),
             groups: [user.grp],
             getPermissions () {

+ 65 - 12
server/db/migrations/3.0.0.mjs

@@ -178,8 +178,9 @@ export async function up (knex) {
     })
     // NAVIGATION ----------------------------
     .createTable('navigation', table => {
-      table.string('key').notNullable().primary()
-      table.jsonb('config')
+      table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
+      table.string('name').notNullable()
+      table.jsonb('items').notNullable().defaultTo('[]')
     })
     // PAGE HISTORY ------------------------
     .createTable('pageHistory', table => {
@@ -187,6 +188,7 @@ export async function up (knex) {
       table.uuid('pageId').notNullable().index()
       table.string('action').defaultTo('updated')
       table.jsonb('affectedFields').notNullable().defaultTo('[]')
+      table.string('locale', 10).notNullable().defaultTo('en')
       table.string('path').notNullable()
       table.string('hash').notNullable()
       table.string('alias')
@@ -211,11 +213,12 @@ export async function up (knex) {
     .createTable('pageLinks', table => {
       table.increments('id').primary()
       table.string('path').notNullable()
-      table.string('localeCode', 10).notNullable()
+      table.string('locale', 10).notNullable()
     })
     // PAGES -------------------------------
     .createTable('pages', table => {
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
+      table.string('locale', 10).notNullable()
       table.string('path').notNullable()
       table.string('hash').notNullable()
       table.string('alias')
@@ -293,7 +296,7 @@ export async function up (knex) {
       table.string('fileName').notNullable().index()
       table.string('hash').notNullable().index()
       table.enu('type', ['folder', 'page', 'asset']).notNullable().index()
-      table.string('localeCode', 10).notNullable().defaultTo('en').index()
+      table.string('locale', 10).notNullable().defaultTo('en').index()
       table.string('title').notNullable()
       table.jsonb('meta').notNullable().defaultTo('{}')
       table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
@@ -369,16 +372,14 @@ export async function up (knex) {
       table.uuid('siteId').notNullable().references('id').inTable('sites').index()
     })
     .table('pageHistory', table => {
-      table.string('localeCode', 10).references('code').inTable('locales')
       table.uuid('authorId').notNullable().references('id').inTable('users')
       table.uuid('siteId').notNullable().references('id').inTable('sites').index()
     })
     .table('pageLinks', table => {
       table.uuid('pageId').notNullable().references('id').inTable('pages').onDelete('CASCADE')
-      table.index(['path', 'localeCode'])
+      table.index(['path', 'locale'])
     })
     .table('pages', table => {
-      table.string('localeCode', 10).references('code').inTable('locales').index()
       table.uuid('authorId').notNullable().references('id').inTable('users').index()
       table.uuid('creatorId').notNullable().references('id').inTable('users').index()
       table.uuid('ownerId').notNullable().references('id').inTable('users').index()
@@ -392,6 +393,7 @@ export async function up (knex) {
       table.unique(['siteId', 'tag'])
     })
     .table('tree', table => {
+      table.uuid('navigationId').references('id').inTable('navigation').index()
       table.uuid('siteId').notNullable().references('id').inTable('sites')
     })
     .table('userKeys', table => {
@@ -413,6 +415,7 @@ export async function up (knex) {
 
   const groupAdminId = uuid()
   const groupGuestId = '10000000-0000-4000-8000-000000000001'
+  const navDefaultId = uuid()
   const siteId = uuid()
   const authModuleId = uuid()
   const userAdminId = uuid()
@@ -619,6 +622,10 @@ export async function up (knex) {
           config: {}
         }
       },
+      nav: {
+        mode: 'mixed',
+        defaultId: navDefaultId,
+      },
       theme: {
         dark: false,
         codeBlocksTheme: 'github-dark',
@@ -747,6 +754,52 @@ export async function up (knex) {
     }
   ])
 
+  // -> NAVIGATION
+
+  await knex('navigation').insert({
+    id: navDefaultId,
+    name: 'Default',
+    items: JSON.stringify([
+      {
+        id: uuid(),
+        type: 'header',
+        label: 'Sample Header'
+      },
+      {
+        id: uuid(),
+        type: 'link',
+        icon: 'mdi-file-document-outline',
+        label: 'Sample Link 1',
+        target: '/',
+        openInNewWindow: false,
+        children: []
+      },
+      {
+        id: uuid(),
+        type: 'link',
+        icon: 'mdi-book-open-variant',
+        label: 'Sample Link 2',
+        target: '/',
+        openInNewWindow: false,
+        children: []
+      },
+      {
+        id: uuid(),
+        type: 'separator',
+      },
+      {
+        id: uuid(),
+        type: 'link',
+        icon: 'mdi-airballoon',
+        label: 'Sample Link 3',
+        target: '/',
+        openInNewWindow: false,
+        children: []
+      }
+    ]),
+    siteId: siteId
+  })
+
   // -> STORAGE MODULE
 
   await knex('storage').insert({
@@ -782,11 +835,11 @@ export async function up (knex) {
       cron: '5 0 * * *',
       type: 'system'
     },
-    {
-      task: 'refreshAutocomplete',
-      cron: '0 */6 * * *',
-      type: 'system'
-    },
+    // {
+    //   task: 'refreshAutocomplete',
+    //   cron: '0 */6 * * *',
+    //   type: 'system'
+    // },
     {
       task: 'updateLocales',
       cron: '0 0 * * *',

+ 2 - 2
server/graph/resolvers/asset.mjs

@@ -170,7 +170,7 @@ export default {
           folder = {
             folderPath: '',
             fileName: '',
-            localeCode: args.locale,
+            locale: args.locale,
             siteId: args.siteId
           }
         }
@@ -263,7 +263,7 @@ export default {
             parentPath: folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName,
             fileName: formattedFilename,
             title: formattedFilename,
-            locale: folder.localeCode,
+            locale: folder.locale,
             siteId: folder.siteId,
             meta: {
               authorId: asset.authorId,

+ 3 - 3
server/graph/resolvers/comment.mjs

@@ -32,7 +32,7 @@ export default {
      * Fetch list of comments for a page
      */
     async comments (obj, args, context) {
-      const page = await WIKI.db.pages.query().select('id').findOne({ localeCode: args.locale, path: args.path })
+      const page = await WIKI.db.pages.query().select('id').findOne({ locale: args.locale, path: args.path })
       if (page) {
         if (WIKI.auth.checkAccess(context.req.user, ['read:comments'], args)) {
           const comments = await WIKI.db.comments.query().where('pageId', page.id).orderBy('createdAt')
@@ -57,11 +57,11 @@ export default {
       if (!cm || !cm.pageId) {
         throw new WIKI.Error.CommentNotFound()
       }
-      const page = await WIKI.db.pages.query().select('localeCode', 'path').findById(cm.pageId)
+      const page = await WIKI.db.pages.query().select('locale', 'path').findById(cm.pageId)
       if (page) {
         if (WIKI.auth.checkAccess(context.req.user, ['read:comments'], {
           path: page.path,
-          locale: page.localeCode
+          locale: page.locale
         })) {
           return {
             ...cm,

+ 9 - 26
server/graph/resolvers/navigation.mjs

@@ -2,22 +2,19 @@ import { generateError, generateSuccess } from '../../helpers/graph.mjs'
 
 export default {
   Query: {
-    async navigationTree (obj, args, context, info) {
-      return WIKI.db.navigation.getTree({ cache: false, locale: 'all', bypassAuth: true })
-    },
-    navigationConfig (obj, args, context, info) {
-      return WIKI.config.nav
+    async navigationById (obj, args, context, info) {
+      return WIKI.db.navigation.getNav({ id: args.id, cache: true, userGroups: context.req.user?.groups })
     }
   },
   Mutation: {
-    async updateNavigationTree (obj, args, context) {
+    async updateNavigation (obj, args, context) {
       try {
-        await WIKI.db.navigation.query().patch({
-          config: args.tree
-        }).where('key', 'site')
-        for (const tree of args.tree) {
-          await WIKI.cache.set(`nav:sidebar:${tree.locale}`, tree.items, 300)
-        }
+        // await WIKI.db.navigation.query().patch({
+        //   config: args.tree
+        // }).where('key', 'site')
+        // for (const tree of args.tree) {
+        //   await WIKI.cache.set(`nav:sidebar:${tree.locale}`, tree.items, 300)
+        // }
 
         return {
           responseResult: generateSuccess('Navigation updated successfully')
@@ -25,20 +22,6 @@ export default {
       } catch (err) {
         return generateError(err)
       }
-    },
-    async updateNavigationConfig (obj, args, context) {
-      try {
-        WIKI.config.nav = {
-          mode: args.mode
-        }
-        await WIKI.configSvc.saveToDb(['nav'])
-
-        return {
-          responseResult: generateSuccess('Navigation config updated successfully')
-        }
-      } catch (err) {
-        return generateError(err)
-      }
     }
   }
 }

+ 29 - 31
server/graph/resolvers/page.mjs

@@ -12,10 +12,10 @@ export default {
      * PAGE HISTORY
      */
     async pageHistoryById (obj, args, context, info) {
-      const page = await WIKI.db.pages.query().select('path', 'localeCode').findById(args.id)
+      const page = await WIKI.db.pages.query().select('path', 'locale').findById(args.id)
       if (WIKI.auth.checkAccess(context.req.user, ['read:history'], {
         path: page.path,
-        locale: page.localeCode
+        locale: page.locale
       })) {
         return WIKI.db.pageHistory.getHistory({
           pageId: args.id,
@@ -30,10 +30,10 @@ export default {
      * PAGE VERSION
      */
     async pageVersionById (obj, args, context, info) {
-      const page = await WIKI.db.pages.query().select('path', 'localeCode').findById(args.pageId)
+      const page = await WIKI.db.pages.query().select('path', 'locale').findById(args.pageId)
       if (WIKI.auth.checkAccess(context.req.user, ['read:history'], {
         path: page.path,
-        locale: page.localeCode
+        locale: page.locale
       })) {
         return WIKI.db.pageHistory.getVersion({
           pageId: args.pageId,
@@ -68,7 +68,7 @@ export default {
         const searchCols = [
           'id',
           'path',
-          'localeCode AS locale',
+          'locale',
           'title',
           'description',
           'icon',
@@ -99,7 +99,7 @@ export default {
               builder.where('path', 'ILIKE', `${args.path}%`)
             }
             if (args.locale?.length > 0) {
-              builder.whereIn('localeCode', args.locale)
+              builder.whereIn('locale', args.locale)
             }
             if (args.editor) {
               builder.where('editor', args.editor)
@@ -143,7 +143,7 @@ export default {
       let results = await WIKI.db.pages.query().column([
         'pages.id',
         'path',
-        { locale: 'localeCode' },
+        'locale',
         'title',
         'description',
         'isPublished',
@@ -162,7 +162,7 @@ export default {
             queryBuilder.limit(args.limit)
           }
           if (args.locale) {
-            queryBuilder.where('localeCode', args.locale)
+            queryBuilder.where('locale', args.locale)
           }
           if (args.creatorId && args.authorId && args.creatorId > 0 && args.authorId > 0) {
             queryBuilder.where(function () {
@@ -220,15 +220,14 @@ export default {
       if (page) {
         if (WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
           path: page.path,
-          locale: page.localeCode
+          locale: page.locale
         })) {
           return {
             ...page,
             ...page.config,
             scriptCss: page.scripts?.css,
             scriptJsLoad: page.scripts?.jsLoad,
-            scriptJsUnload: page.scripts?.jsUnload,
-            locale: page.localeCode
+            scriptJsUnload: page.scripts?.jsUnload
           }
         } else {
           throw new Error('ERR_FORBIDDEN')
@@ -253,8 +252,7 @@ export default {
           ...page.config,
           scriptCss: page.scripts?.css,
           scriptJsLoad: page.scripts?.jsLoad,
-          scriptJsUnload: page.scripts?.jsUnload,
-          locale: page.localeCode
+          scriptJsUnload: page.scripts?.jsUnload
         }
       } else {
         throw new Error('ERR_PAGE_NOT_FOUND')
@@ -275,13 +273,13 @@ export default {
       const page = await WIKI.db.pages.query().findOne({
         alias: args.alias,
         siteId: args.siteId
-      }).select('id', 'path', 'localeCode')
+      }).select('id', 'path', 'locale')
       if (!page) {
         throw new Error('ERR_ALIAS_NOT_FOUND')
       }
       return {
         id: page.id,
-        path: WIKI.sites[args.siteId].config.localeNamespacing ? `${page.localeCode}/${page.path}` : page.path
+        path: WIKI.sites[args.siteId].config.localeNamespacing ? `${page.locale}/${page.path}` : page.path
       }
     },
 
@@ -305,7 +303,7 @@ export default {
       if (args.path && !args.parent) {
         curPage = await WIKI.db.knex('pageTree').first('parent', 'ancestors').where({
           path: args.path,
-          localeCode: args.locale
+          locale: args.locale
         })
         if (curPage) {
           args.parent = curPage.parent || 0
@@ -315,7 +313,7 @@ export default {
       }
 
       const results = await WIKI.db.knex('pageTree').where(builder => {
-        builder.where('localeCode', args.locale)
+        builder.where('locale', args.locale)
         switch (args.mode) {
           case 'FOLDERS':
             builder.andWhere('isFolder', true)
@@ -336,12 +334,12 @@ export default {
       return results.filter(r => {
         return WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
           path: r.path,
-          locale: r.localeCode
+          locale: r.locale
         })
       }).map(r => ({
         ...r,
         parent: r.parent || 0,
-        locale: r.localeCode
+        locale: r.locale
       }))
     },
     /**
@@ -352,25 +350,25 @@ export default {
 
       if (WIKI.config.db.type === 'mysql' || WIKI.config.db.type === 'mariadb' || WIKI.config.db.type === 'sqlite') {
         results = await WIKI.db.knex('pages')
-          .column({ id: 'pages.id' }, { path: 'pages.path' }, 'title', { link: 'pageLinks.path' }, { locale: 'pageLinks.localeCode' })
+          .column({ id: 'pages.id' }, { path: 'pages.path' }, 'title', { link: 'pageLinks.path' }, { locale: 'pageLinks.locale' })
           .leftJoin('pageLinks', 'pages.id', 'pageLinks.pageId')
           .where({
-            'pages.localeCode': args.locale
+            'pages.locale': args.locale
           })
           .unionAll(
             WIKI.db.knex('pageLinks')
-              .column({ id: 'pages.id' }, { path: 'pages.path' }, 'title', { link: 'pageLinks.path' }, { locale: 'pageLinks.localeCode' })
+              .column({ id: 'pages.id' }, { path: 'pages.path' }, 'title', { link: 'pageLinks.path' }, { locale: 'pageLinks.locale' })
               .leftJoin('pages', 'pageLinks.pageId', 'pages.id')
               .where({
-                'pages.localeCode': args.locale
+                'pages.locale': args.locale
               })
           )
       } else {
         results = await WIKI.db.knex('pages')
-          .column({ id: 'pages.id' }, { path: 'pages.path' }, 'title', { link: 'pageLinks.path' }, { locale: 'pageLinks.localeCode' })
+          .column({ id: 'pages.id' }, { path: 'pages.path' }, 'title', { link: 'pageLinks.path' }, { locale: 'pageLinks.locale' })
           .fullOuterJoin('pageLinks', 'pages.id', 'pageLinks.pageId')
           .where({
-            'pages.localeCode': args.locale
+            'pages.locale': args.locale
           })
       }
 
@@ -403,11 +401,11 @@ export default {
      * CHECK FOR EDITING CONFLICT
      */
     async checkConflicts (obj, args, context, info) {
-      let page = await WIKI.db.pages.query().select('path', 'localeCode', 'updatedAt').findById(args.id)
+      let page = await WIKI.db.pages.query().select('path', 'locale', 'updatedAt').findById(args.id)
       if (page) {
         if (WIKI.auth.checkAccess(context.req.user, ['write:pages', 'manage:pages'], {
           path: page.path,
-          locale: page.localeCode
+          locale: page.locale
         })) {
           return page.updatedAt > args.checkoutDate
         } else {
@@ -425,12 +423,12 @@ export default {
       if (page) {
         if (WIKI.auth.checkAccess(context.req.user, ['write:pages', 'manage:pages'], {
           path: page.path,
-          locale: page.localeCode
+          locale: page.locale
         })) {
           return {
             ...page,
             tags: page.tags.map(t => t.tag),
-            locale: page.localeCode
+            locale: page.locale
           }
         } else {
           throw new WIKI.Error.PageViewForbidden()
@@ -626,14 +624,14 @@ export default {
      */
     async restorePage (obj, args, context) {
       try {
-        const page = await WIKI.db.pages.query().select('path', 'localeCode').findById(args.pageId)
+        const page = await WIKI.db.pages.query().select('path', 'locale').findById(args.pageId)
         if (!page) {
           throw new WIKI.Error.PageNotFound()
         }
 
         if (!WIKI.auth.checkAccess(context.req.user, ['write:pages'], {
           path: page.path,
-          locale: page.localeCode
+          locale: page.locale
         })) {
           throw new WIKI.Error.PageRestoreForbidden()
         }

+ 1 - 1
server/graph/resolvers/tree.mjs

@@ -142,7 +142,7 @@ export default {
         .select(WIKI.db.knex.raw('tree.*, nlevel(tree."folderPath") AS depth'))
         .where({
           siteId: args.siteId,
-          localeCode: args.locale,
+          locale: args.locale,
           folderPath: _.dropRight(parentPathParts).join('.'),
           fileName: _.last(parentPathParts)
         })

+ 11 - 44
server/graph/schemas/navigation.graphql

@@ -3,8 +3,9 @@
 # ===============================================
 
 extend type Query {
-  navigationTree: [NavigationTree]
-  navigationConfig: NavigationConfig
+  navigationById(
+    id: UUID!
+  ): [NavigationItem]
 }
 
 # -----------------------------------------------
@@ -12,11 +13,10 @@ extend type Query {
 # -----------------------------------------------
 
 extend type Mutation {
-  updateNavigationTree(
-    tree: [NavigationTreeInput]!
-  ): DefaultResponse
-  updateNavigationConfig(
-    mode: NavigationMode!
+  updateNavigation(
+    id: UUID!
+    name: String!
+    items: [JSON]!
   ): DefaultResponse
 }
 
@@ -24,45 +24,12 @@ extend type Mutation {
 # TYPES
 # -----------------------------------------------
 
-type NavigationTree {
-  locale: String
-  items: [NavigationItem]
-}
-
-input NavigationTreeInput {
-  locale: String!
-  items: [NavigationItemInput]!
-}
-
 type NavigationItem {
-  id: String
-  kind: String
-  label: String
-  icon: String
-  targetType: String
-  target: String
-  visibilityMode: String
-  visibilityGroups: [Int]
-}
-
-input NavigationItemInput {
-  id: String!
-  kind: String!
+  id: UUID
+  type: String
   label: String
   icon: String
-  targetType: String
   target: String
-  visibilityMode: String
-  visibilityGroups: [Int]
-}
-
-type NavigationConfig {
-  mode: NavigationMode
-}
-
-enum NavigationMode {
-  NONE
-  TREE
-  MIXED
-  STATIC
+  openInNewWindow: Boolean
+  children: [NavigationItem]
 }

+ 1 - 0
server/graph/schemas/page.graphql

@@ -237,6 +237,7 @@ type Page {
   isBrowsable: Boolean
   isSearchable: Boolean
   locale: String
+  navigationId: UUID
   password: String
   path: String
   publishEndDate: Date

+ 4 - 4
server/helpers/common.mjs

@@ -122,10 +122,10 @@ export function parseModuleProps (props) {
 }
 
 export function getDictNameFromLocale (locale) {
-  const localeCode = locale.length > 2 ? locale.substring(0, 2) : locale
-  if (localeCode in WIKI.config.search.dictOverrides) {
-    return WIKI.config.search.dictOverrides[localeCode]
+  const loc = locale.length > 2 ? locale.substring(0, 2) : locale
+  if (loc in WIKI.config.search.dictOverrides) {
+    return WIKI.config.search.dictOverrides[loc]
   } else {
-    return WIKI.data.tsDictMappings[localeCode] ?? 'simple'
+    return WIKI.data.tsDictMappings[loc] ?? 'simple'
   }
 }

+ 5 - 0
server/locales/en.json

@@ -1683,7 +1683,9 @@
   "history.restore.confirmText": "Are you sure you want to restore this page content as it was on {date}? This version will be copied on top of the current history. As such, newer versions will still be preserved.",
   "history.restore.confirmTitle": "Restore page version?",
   "history.restore.success": "Page version restored succesfully!",
+  "navEdit.clearItems": "Clear All Items",
   "navEdit.editMenuItems": "Edit Menu Items",
+  "navEdit.emptyMenuText": "Click the Add button to add your first menu item.",
   "navEdit.header": "Header",
   "navEdit.icon": "Icon",
   "navEdit.iconHint": "Icon to display to the left of the menu item.",
@@ -1691,8 +1693,11 @@
   "navEdit.labelHint": "Text to display on the menu item.",
   "navEdit.link": "Link",
   "navEdit.nestItem": "Nest Item",
+  "navEdit.nestingWarn": "The previous menu item must be a normal link or another nested link. Invalid nested items will be shown in red.",
+  "navEdit.noSelection": "Select a menu item from the left to start editing.",
   "navEdit.openInNewWindow": "Open in New Window",
   "navEdit.openInNewWindowHint": "Whether the link should open in a new window / tab.",
+  "navEdit.selectGroups": "Group(s):",
   "navEdit.separator": "Separator",
   "navEdit.target": "Target",
   "navEdit.targetHint": "Target path or external link to point to.",

+ 1 - 1
server/models/assets.mjs

@@ -160,7 +160,7 @@ export class Asset extends Model {
       .innerJoin('assets', 'tree.id', 'assets.id')
       .where(id ? { 'tree.id': id } : {
         'tree.hash': generateHash(path),
-        'tree.localeCode': locale,
+        'tree.locale': locale,
         'tree.siteId': siteId
       })
       .first()

+ 3 - 3
server/models/comments.mjs

@@ -99,7 +99,7 @@ export class Comment extends Model {
     if (page) {
       if (!WIKI.auth.checkAccess(user, ['write:comments'], {
         path: page.path,
-        locale: page.localeCode
+        locale: page.locale
       })) {
         throw new WIKI.Error.CommentPostForbidden()
       }
@@ -136,7 +136,7 @@ export class Comment extends Model {
     if (page) {
       if (!WIKI.auth.checkAccess(user, ['manage:comments'], {
         path: page.path,
-        locale: page.localeCode
+        locale: page.locale
       })) {
         throw new WIKI.Error.CommentManageForbidden()
       }
@@ -169,7 +169,7 @@ export class Comment extends Model {
     if (page) {
       if (!WIKI.auth.checkAccess(user, ['manage:comments'], {
         path: page.path,
-        locale: page.localeCode
+        locale: page.locale
       })) {
         throw new WIKI.Error.CommentManageForbidden()
       }

+ 18 - 5
server/models/navigation.mjs

@@ -1,25 +1,38 @@
 import { Model } from 'objection'
-import { has, intersection } from 'lodash-es'
+import { has, intersection, templateSettings } from 'lodash-es'
 
 /**
  * Navigation model
  */
 export class Navigation extends Model {
   static get tableName() { return 'navigation' }
-  static get idColumn() { return 'key' }
 
   static get jsonSchema () {
     return {
       type: 'object',
-      required: ['key'],
 
       properties: {
-        key: {type: 'string'},
-        config: {type: 'array', items: {type: 'object'}}
+        name: {type: 'string'},
+        items: {type: 'array', items: {type: 'object'}}
       }
     }
   }
 
+  static async getNav ({ id, cache = false, userGroups = [] }) {
+    const result = await WIKI.db.navigation.query().findById(id).select('items')
+    return result.items.filter(item => {
+      return !item.visibilityGroups?.length || intersection(item.visibilityGroups, userGroups).length > 0
+    }).map(item => {
+      if (!item.children || item.children?.length < 1) { return item }
+      return {
+        ...item,
+        children: item.children.filter(child => {
+          return !child.visibilityGroups?.length || intersection(child.visibilityGroups, userGroups).length > 0
+        })
+      }
+    })
+  }
+
   static async getTree({ cache = false, locale = 'en', groups = [], bypassAuth = false } = {}) {
     if (cache) {
       const navTreeCached = await WIKI.cache.get(`nav:sidebar:${locale}`)

+ 3 - 3
server/models/pageHistory.mjs

@@ -69,7 +69,7 @@ export class PageHistory extends Model {
         relation: Model.BelongsToOneRelation,
         modelClass: Locale,
         join: {
-          from: 'pageHistory.localeCode',
+          from: 'pageHistory.locale',
           to: 'locales.code'
         }
       }
@@ -94,7 +94,7 @@ export class PageHistory extends Model {
       editor: opts.editor,
       hash: opts.hash,
       publishState: opts.publishState,
-      localeCode: opts.localeCode,
+      locale: opts.locale,
       path: opts.path,
       publishEndDate: opts.publishEndDate?.toISO(),
       publishStartDate: opts.publishStartDate?.toISO(),
@@ -126,7 +126,7 @@ export class PageHistory extends Model {
         {
           versionId: 'pageHistory.id',
           editor: 'pageHistory.editorKey',
-          locale: 'pageHistory.localeCode',
+          locale: 'pageHistory.locale',
           authorName: 'author.name'
         }
       ])

+ 2 - 2
server/models/pageLinks.mjs

@@ -11,12 +11,12 @@ export class PageLink extends Model {
   static get jsonSchema () {
     return {
       type: 'object',
-      required: ['path', 'localeCode'],
+      required: ['path', 'locale'],
 
       properties: {
         id: {type: 'integer'},
         path: {type: 'string'},
-        localeCode: {type: 'string'}
+        locale: {type: 'string'}
       }
     }
   }

+ 30 - 40
server/models/pages.mjs

@@ -99,14 +99,6 @@ export class Page extends Model {
           from: 'pages.creatorId',
           to: 'users.id'
         }
-      },
-      locale: {
-        relation: Model.BelongsToOneRelation,
-        modelClass: Locale,
-        join: {
-          from: 'pages.localeCode',
-          to: 'locales.code'
-        }
       }
     }
   }
@@ -267,7 +259,7 @@ export class Page extends Model {
     // -> Check for duplicate
     const dupCheck = await WIKI.db.pages.query().findOne({
       siteId: opts.siteId,
-      localeCode: opts.locale,
+      locale: opts.locale,
       path: opts.path
     }).select('id')
     if (dupCheck) {
@@ -345,7 +337,7 @@ export class Page extends Model {
       icon: opts.icon,
       isBrowsable: opts.isBrowsable ?? true,
       isSearchable: opts.isSearchable ?? true,
-      localeCode: opts.locale,
+      locale: opts.locale,
       ownerId: opts.user.id,
       path: opts.path,
       publishState: opts.publishState,
@@ -372,7 +364,7 @@ export class Page extends Model {
       id: page.id,
       parentPath: initial(pathParts).join('/'),
       fileName: last(pathParts),
-      locale: page.localeCode,
+      locale: page.locale,
       title: page.title,
       meta: {
         authorId: page.authorId,
@@ -401,7 +393,7 @@ export class Page extends Model {
 
     // // -> Reconnect Links
     // await WIKI.db.pages.reconnectLinks({
-    //   locale: page.localeCode,
+    //   locale: page.locale,
     //   path: page.path,
     //   mode: 'create'
     // })
@@ -427,7 +419,7 @@ export class Page extends Model {
 
     // -> Check for page access
     if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
-      locale: ogPage.localeCode,
+      locale: ogPage.locale,
       path: ogPage.path
     })) {
       throw new Error('ERR_PAGE_UPDATE_FORBIDDEN')
@@ -596,7 +588,7 @@ export class Page extends Model {
     // -> Format CSS Scripts
     if (opts.patch.scriptCss) {
       if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
-        locale: ogPage.localeCode,
+        locale: ogPage.locale,
         path: ogPage.path
       })) {
         patch.scripts = {
@@ -610,7 +602,7 @@ export class Page extends Model {
     // -> Format JS Scripts
     if (opts.patch.scriptJsLoad) {
       if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
-        locale: ogPage.localeCode,
+        locale: ogPage.locale,
         path: ogPage.path
       })) {
         patch.scripts = {
@@ -622,7 +614,7 @@ export class Page extends Model {
     }
     if (opts.patch.scriptJsUnload) {
       if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
-        locale: ogPage.localeCode,
+        locale: ogPage.locale,
         path: ogPage.path
       })) {
         patch.scripts = {
@@ -701,11 +693,11 @@ export class Page extends Model {
       if (!id) {
         throw new Error('Must provide either the page ID or the page object.')
       }
-      page = await WIKI.db.pages.query().findById(id).select('id', 'localeCode', 'render', 'password')
+      page = await WIKI.db.pages.query().findById(id).select('id', 'locale', 'render', 'password')
     }
     // -> Exclude password-protected content from being indexed
     const safeContent = page.password ? '' : WIKI.db.pages.cleanHTML(page.render)
-    const dictName = getDictNameFromLocale(page.localeCode)
+    const dictName = getDictNameFromLocale(page.locale)
     return WIKI.db.knex('pages').where('id', page.id).update({
       searchContent: safeContent,
       ts: WIKI.db.knex.raw(`
@@ -747,7 +739,7 @@ export class Page extends Model {
 
     // -> Check for page access
     if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
-      locale: ogPage.localeCode,
+      locale: ogPage.locale,
       path: ogPage.path
     })) {
       throw new WIKI.Error.PageUpdateForbidden()
@@ -908,7 +900,7 @@ export class Page extends Model {
     } else {
       page = await WIKI.db.pages.query().findOne({
         path: opts.path,
-        localeCode: opts.locale
+        locale: opts.locale
       })
     }
     if (!page) {
@@ -932,7 +924,7 @@ export class Page extends Model {
 
     // -> Check for source page access
     if (!WIKI.auth.checkAccess(opts.user, ['manage:pages'], {
-      locale: page.localeCode,
+      locale: page.locale,
       path: page.path
     })) {
       throw new WIKI.Error.PageMoveForbidden()
@@ -948,7 +940,7 @@ export class Page extends Model {
     // -> Check for existing page at destination path
     const destPage = await WIKI.db.pages.query().findOne({
       path: opts.destinationPath,
-      localeCode: opts.destinationLocale
+      locale: opts.destinationLocale
     })
     if (destPage) {
       throw new WIKI.Error.PagePathCollision()
@@ -967,7 +959,7 @@ export class Page extends Model {
     const destinationTitle = (page.title === page.path ? opts.destinationPath : page.title)
     await WIKI.db.pages.query().patch({
       path: opts.destinationPath,
-      localeCode: opts.destinationLocale,
+      locale: opts.destinationLocale,
       title: destinationTitle,
       hash: destinationHash
     }).findById(page.id)
@@ -983,7 +975,7 @@ export class Page extends Model {
     await WIKI.data.searchEngine.renamed({
       ...page,
       destinationPath: opts.destinationPath,
-      destinationLocaleCode: opts.destinationLocale,
+      destinationLocale: opts.destinationLocale,
       destinationHash
     })
 
@@ -994,7 +986,7 @@ export class Page extends Model {
         page: {
           ...page,
           destinationPath: opts.destinationPath,
-          destinationLocaleCode: opts.destinationLocale,
+          destinationLocale: opts.destinationLocale,
           destinationHash,
           moveAuthorId: opts.user.id,
           moveAuthorName: opts.user.name,
@@ -1005,7 +997,7 @@ export class Page extends Model {
 
     // -> Reconnect Links : Changing old links to the new path
     await WIKI.db.pages.reconnectLinks({
-      sourceLocale: page.localeCode,
+      sourceLocale: page.locale,
       sourcePath: page.path,
       locale: opts.destinationLocale,
       path: opts.destinationPath,
@@ -1066,7 +1058,7 @@ export class Page extends Model {
 
     // -> Reconnect Links
     await WIKI.db.pages.reconnectLinks({
-      locale: page.localeCode,
+      locale: page.locale,
       path: page.path,
       mode: 'delete'
     })
@@ -1118,7 +1110,7 @@ export class Page extends Model {
         .whereIn('pages.id', function () {
           this.select('pageLinks.pageId').from('pageLinks').where({
             'pageLinks.path': opts.path,
-            'pageLinks.localeCode': opts.locale
+            'pageLinks.locale': opts.locale
           })
         })
       affectedHashes = qryHashes.map(h => h.hash)
@@ -1131,7 +1123,7 @@ export class Page extends Model {
         .whereIn('pages.id', function () {
           this.select('pageLinks.pageId').from('pageLinks').where({
             'pageLinks.path': opts.path,
-            'pageLinks.localeCode': opts.locale
+            'pageLinks.locale': opts.locale
           })
         })
       const qryHashes = await WIKI.db.pages.query()
@@ -1139,7 +1131,7 @@ export class Page extends Model {
         .whereIn('pages.id', function () {
           this.select('pageLinks.pageId').from('pageLinks').where({
             'pageLinks.path': opts.path,
-            'pageLinks.localeCode': opts.locale
+            'pageLinks.locale': opts.locale
           })
         })
       affectedHashes = qryHashes.map(h => h.hash)
@@ -1227,20 +1219,18 @@ export class Page extends Model {
             authorEmail: 'author.email',
             creatorName: 'creator.name',
             creatorEmail: 'creator.email'
-          }
+          },
+          'tree.navigationId'
         ])
         .joinRelated('author')
         .joinRelated('creator')
-        // .withGraphJoined('tags')
-        // .modifyGraph('tags', builder => {
-        //   builder.select('tag')
-        // })
+        .leftJoin('tree', 'pages.id', 'tree.id')
         .where(queryModeID ? {
           'pages.id': opts
         } : {
           'pages.siteId': opts.siteId,
           'pages.path': opts.path,
-          'pages.localeCode': opts.locale
+          'pages.locale': opts.locale
         })
         // .andWhere(builder => {
         //   if (queryModeID) return
@@ -1307,7 +1297,7 @@ export class Page extends Model {
       return {
         ...page,
         path: opts.path,
-        localeCode: opts.locale
+        locale: opts.locale
       }
     } catch (err) {
       if (err.code === 'ENOENT') {
@@ -1346,13 +1336,13 @@ export class Page extends Model {
   static async migrateToLocale({ sourceLocale, targetLocale }) {
     return WIKI.db.pages.query()
       .patch({
-        localeCode: targetLocale
+        locale: targetLocale
       })
       .where({
-        localeCode: sourceLocale
+        locale: sourceLocale
       })
       .whereNotExists(function() {
-        this.select('id').from('pages AS pagesm').where('pagesm.localeCode', targetLocale).andWhereRaw('pagesm.path = pages.path')
+        this.select('id').from('pages AS pagesm').where('pagesm.locale', targetLocale).andWhereRaw('pagesm.path = pages.path')
       })
   }
 

+ 14 - 0
server/models/sites.mjs

@@ -1,5 +1,6 @@
 import { Model } from 'objection'
 import { defaultsDeep, keyBy } from 'lodash-es'
+import { v4 as uuid } from 'uuid'
 
 /**
  * Site model
@@ -47,7 +48,16 @@ export class Site extends Model {
   }
 
   static async createSite (hostname, config) {
+    const newSiteId = uuid
+
+    const newDefaultNav = await WIKI.db.navigation.query().insertAndFetch({
+      name: 'Default',
+      siteId: newSiteId,
+      items: JSON.stringify([])
+    })
+
     const newSite = await WIKI.db.sites.query().insertAndFetch({
+      id: newSiteId,
       hostname,
       isEnabled: true,
       config: defaultsDeep(config, {
@@ -138,6 +148,10 @@ export class Site extends Model {
             config: {}
           }
         },
+        nav: {
+          mode: 'mixed',
+          defaultId: newDefaultNav.id,
+        },
         uploads: {
           conflictBehavior: 'overwrite',
           normalizeFilename: true

+ 6 - 14
server/models/tree.mjs

@@ -43,14 +43,6 @@ export class Tree extends Model {
 
   static get relationMappings() {
     return {
-      locale: {
-        relation: Model.BelongsToOneRelation,
-        modelClass: Locale,
-        join: {
-          from: 'tree.localeCode',
-          to: 'locales.code'
-        }
-      },
       site: {
         relation: Model.BelongsToOneRelation,
         modelClass: Site,
@@ -99,7 +91,7 @@ export class Tree extends Model {
       const parent = await WIKI.db.knex('tree').where({
         ...parentFilter,
         type: 'folder',
-        localeCode: locale,
+        locale: locale,
         siteId
       }).first()
       if (parent) {
@@ -153,7 +145,7 @@ export class Tree extends Model {
       type: 'page',
       title: title,
       hash: generateHash(fullPath),
-      localeCode: locale,
+      locale: locale,
       siteId,
       meta
     }).returning('*')
@@ -196,7 +188,7 @@ export class Tree extends Model {
       type: 'asset',
       title: title,
       hash: generateHash(fullPath),
-      localeCode: locale,
+      locale: locale,
       siteId,
       meta
     }).returning('*')
@@ -251,7 +243,7 @@ export class Tree extends Model {
     // Check for collision
     const existingFolder = await WIKI.db.knex('tree').select('id').where({
       siteId: siteId,
-      localeCode: locale,
+      locale: locale,
       folderPath: encodeFolderPath(parentPath),
       fileName: pathName
     }).first()
@@ -284,7 +276,7 @@ export class Tree extends Model {
           type: 'folder',
           title: ancestor.fileName,
           hash: generateHash(newAncestorFullPath),
-          localeCode: locale,
+          locale: locale,
           siteId: siteId,
           meta: {
             children: 1
@@ -306,7 +298,7 @@ export class Tree extends Model {
       type: 'folder',
       title: title,
       hash: generateHash(fullPath),
-      localeCode: locale,
+      locale: locale,
       siteId: siteId,
       meta: {
         children: 0

+ 1 - 9
server/models/users.mjs

@@ -53,14 +53,6 @@ export class User extends Model {
           },
           to: 'groups.id'
         }
-      },
-      locale: {
-        relation: Model.BelongsToOneRelation,
-        modelClass: Locale,
-        join: {
-          from: 'users.localeCode',
-          to: 'locales.code'
-        }
       }
     }
   }
@@ -232,7 +224,7 @@ export class User extends Model {
         email: primaryEmail,
         name: displayName,
         pictureUrl: pictureUrl,
-        localeCode: WIKI.config.lang.code,
+        locale: WIKI.config.lang.code,
         defaultEditor: 'markdown',
         tfaIsActive: false,
         isSystem: false,

+ 1 - 1
server/modules/comments/default/comment.js

@@ -89,7 +89,7 @@ module.exports = {
           content,
           name: user.name,
           email: user.email,
-          permalink: `${WIKI.config.host}/${page.localeCode}/${page.path}`,
+          permalink: `${WIKI.config.host}/${page.locale}/${page.path}`,
           permalinkDate: page.updatedAt,
           type: (replyTo > 0) ? 'reply' : 'comment',
           role: userRole

+ 10 - 10
server/modules/rendering/core/renderer.mjs

@@ -68,12 +68,12 @@ export async function render () {
           // -> Reformat paths
           if (href.indexOf('/') !== 0) {
             if (this.config.absoluteLinks) {
-              href = `/${this.page.localeCode}/${href}`
+              href = `/${this.page.locale}/${href}`
             } else {
-              href = (this.page.path === 'home') ? `/${this.page.localeCode}/${href}` : `/${this.page.localeCode}/${this.page.path}/${href}`
+              href = (this.page.path === 'home') ? `/${this.page.locale}/${href}` : `/${this.page.locale}/${this.page.path}/${href}`
             }
           } else if (href.charAt(3) !== '/') {
-            href = `/${this.page.localeCode}${href}`
+            href = `/${this.page.locale}${href}`
           }
 
           try {
@@ -101,7 +101,7 @@ export async function render () {
         }
         // -> Save internal references
         internalRefs.push({
-          localeCode: pagePath.locale,
+          locale: pagePath.locale,
           path: pagePath.path
         })
 
@@ -127,7 +127,7 @@ export async function render () {
 
   if (internalRefs.length > 0) {
     // -> Find matching pages
-    const results = await WIKI.db.pages.query().column('id', 'path', 'localeCode').where(builder => {
+    const results = await WIKI.db.pages.query().column('id', 'path', 'locale').where(builder => {
       internalRefs.forEach((ref, idx) => {
         if (idx < 1) {
           builder.where(ref)
@@ -148,7 +148,7 @@ export async function render () {
         return
       }
       if (_.some(results, r => {
-        return r.localeCode === hrefObj.locale && r.path === hrefObj.path
+        return r.locale === hrefObj.locale && r.path === hrefObj.path
       })) {
         $(elm).addClass(`is-valid-page`)
       } else {
@@ -158,21 +158,21 @@ export async function render () {
 
     // -> Add missing links
     const missingLinks = _.differenceWith(internalRefs, pastLinks, (nLink, pLink) => {
-      return nLink.localeCode === pLink.localeCode && nLink.path === pLink.path
+      return nLink.locale === pLink.locale && nLink.path === pLink.path
     })
     if (missingLinks.length > 0) {
       if (WIKI.config.db.type === 'postgres') {
         await WIKI.db.pageLinks.query().insert(missingLinks.map(lnk => ({
           pageId: this.page.id,
           path: lnk.path,
-          localeCode: lnk.localeCode
+          locale: lnk.locale
         })))
       } else {
         for (const lnk of missingLinks) {
           await WIKI.db.pageLinks.query().insert({
             pageId: this.page.id,
             path: lnk.path,
-            localeCode: lnk.localeCode
+            locale: lnk.locale
           })
         }
       }
@@ -182,7 +182,7 @@ export async function render () {
   // -> Remove outdated links
   if (pastLinks) {
     const outdatedLinks = _.differenceWith(pastLinks, internalRefs, (nLink, pLink) => {
-      return nLink.localeCode === pLink.localeCode && nLink.path === pLink.path
+      return nLink.locale === pLink.locale && nLink.path === pLink.path
     })
     if (outdatedLinks.length > 0) {
       await WIKI.db.pageLinks.query().delete().whereIn('id', _.map(outdatedLinks, 'id'))

+ 1 - 1
server/tasks/workers/rebuild-search-index.mjs

@@ -9,7 +9,7 @@ export async function task ({ payload }) {
 
     let idx = 0
     await pipeline(
-      WIKI.db.knex.select('id', 'title', 'description', 'localeCode', 'render', 'password').from('pages').stream(),
+      WIKI.db.knex.select('id', 'title', 'description', 'locale', 'render', 'password').from('pages').stream(),
       new Transform({
         objectMode: true,
         transform: async (page, enc, cb) => {

+ 7 - 7
ux/src/components/IconPickerDialog.vue

@@ -125,7 +125,7 @@ import { computed, onMounted, reactive } from 'vue'
 // PROPS
 
 const props = defineProps({
-  value: {
+  modelValue: {
     type: String,
     required: true
   }
@@ -133,7 +133,7 @@ const props = defineProps({
 
 // EMITS
 
-const emit = defineEmits(['input'])
+const emit = defineEmits(['update:modelValue'])
 
 // DATA
 
@@ -169,24 +169,24 @@ const iconPackRefWebsite = computed(() => {
 
 function apply () {
   if (state.currentTab === 'img') {
-    emit('input', `img:${state.imgPath}`)
+    emit('update:modelValue', `img:${state.imgPath}`)
   } else {
-    emit('input', state.iconName)
+    emit('update:modelValue', state.iconName)
   }
 }
 
 // MOUNTED
 
 onMounted(() => {
-  if (props.value?.startsWith('img:')) {
+  if (props.modelValue?.startsWith('img:')) {
     state.currentTab = 'img'
-    state.imgPath = props.value.substring(4)
+    state.imgPath = props.modelValue.substring(4)
   } else {
     state.currentTab = 'icon'
     for (const pack of iconPacks) {
       if (props.value?.startsWith(pack.prefix)) {
         state.selPack = pack.value
-        state.selIcon = props.value.substring(pack.prefix.length)
+        state.selIcon = props.modelValue.substring(pack.prefix.length)
         break
       }
     }

+ 6 - 4
ux/src/components/NavEditMenu.vue

@@ -16,19 +16,19 @@ q-card(style='min-width: 350px')
   q-list(padding)
     q-item(tag='label')
       q-item-section(side)
-        q-radio(v-model='state.mode', val='inherit')
+        q-radio(v-model='state.mode', val='inherit', :disable='isRoot')
       q-item-section
         q-item-label Inherit
         q-item-label(caption) Use the menu items and settings from the parent path.
     q-item(tag='label')
       q-item-section(side)
-        q-radio(v-model='state.mode', val='starting')
+        q-radio(v-model='state.mode', val='starting', :disable='isRoot')
       q-item-section
         q-item-label Override Current + Descendants
         q-item-label(caption) Set menu items and settings for this path and all children.
     q-item(tag='label')
       q-item-section(side)
-        q-radio(v-model='state.mode', val='exact')
+        q-radio(v-model='state.mode', val='exact', :disable='isRoot')
       q-item-section
         q-item-label Override Current Only
         q-item-label(caption) Set menu items and settings only for this path.
@@ -50,9 +50,10 @@ q-card(style='min-width: 350px')
 </template>
 
 <script setup>
-import { onMounted, reactive, ref, watch } from 'vue'
+import { computed, onMounted, reactive, ref, watch } from 'vue'
 import { useI18n } from 'vue-i18n'
 
+import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 
 // PROPS
@@ -66,6 +67,7 @@ const props = defineProps({
 
 // STORES
 
+const pageStore = usePageStore()
 const siteStore = useSiteStore()
 
 // I18N

+ 150 - 111
ux/src/components/NavEditOverlay.vue

@@ -4,6 +4,12 @@ q-layout(view='hHh lpR fFf', container)
     q-icon(name='img:/_assets/icons/fluent-sidebar-menu.svg', left, size='md')
     span {{t(`navEdit.editMenuItems`)}}
     q-space
+    transition(name='syncing')
+      q-spinner-tail.q-mr-sm(
+        v-show='state.loading > 0'
+        color='accent'
+        size='24px'
+      )
     q-btn.q-mr-sm(
       flat
       rounded
@@ -81,39 +87,65 @@ q-layout(view='hHh lpR fFf', container)
             q-item-section(side)
               q-icon.handle(name='mdi-drag-horizontal', size='sm')
 
-      .q-pa-md
-        q-btn.full-width.acrylic-btn(
+      .q-pa-md.flex
+        q-btn.acrylic-btn(
+          style='flex: 1;'
           flat
           color='positive'
           :label='t(`common.actions.add`)'
           :aria-label='t(`common.actions.add`)'
           icon='las la-plus-circle'
           )
-          q-menu(fit, :offset='[0, 10]')
+          q-menu(fit, :offset='[0, 10]', auto-close)
             q-list(separator)
-              q-item(clickable)
+              q-item(clickable, @click='addItem(`header`)')
                 q-item-section(side)
                   q-icon(name='las la-heading')
                 q-item-section
-                  q-item-label Header
-              q-item(clickable)
+                  q-item-label {{t('navEdit.header')}}
+              q-item(clickable, @click='addItem(`link`)')
                 q-item-section(side)
                   q-icon(name='las la-link')
                 q-item-section
                   q-item-label {{t('navEdit.link')}}
-              q-item(clickable)
+              q-item(clickable, @click='addItem(`separator`)')
                 q-item-section(side)
                   q-icon(name='las la-minus')
                 q-item-section
-                  q-item-label Separator
-              q-item(clickable, style='border-top-width: 5px;')
+                  q-item-label {{t('navEdit.separator')}}
+        q-btn.q-ml-sm.acrylic-btn(
+          flat
+          color='grey'
+          :aria-label='t(`common.actions.add`)'
+          icon='las la-ellipsis-v'
+          padding='xs sm'
+          )
+          q-menu(:offset='[0, 10]' anchor='bottom right' self='top right' auto-close)
+            q-list(separator)
+              q-item(clickable, @click='clearItems', :disable='state.items.length < 1')
                 q-item-section(side)
-                  q-icon(name='mdi-import')
+                  q-icon(name='las la-trash-alt', color='negative')
                 q-item-section
-                  q-item-label Copy from...
+                  q-item-label {{t('navEdit.clearItems')}}
+              //- q-item(clickable)
+              //-   q-item-section(side)
+              //-     q-icon(name='mdi-import')
+              //-   q-item-section
+              //-     q-item-label Copy from...
 
   q-page-container
     q-page.q-pa-md
+      template(v-if='state.items.length < 1')
+        q-card
+          q-card-section
+            q-icon.q-mr-sm(name='las la-arrow-left', size='xs')
+            span {{ t('navEdit.emptyMenuText') }}
+      template(v-else-if='!state.selected')
+        q-card
+          q-card-section
+            q-icon.q-mr-sm(name='las la-arrow-left', size='xs')
+            span {{ t('navEdit.noSelection') }}
+
       template(v-if='state.current.type === `header`')
         q-card.q-pb-sm
           q-card-section
@@ -135,10 +167,11 @@ q-layout(view='hHh lpR fFf', container)
           q-space
           q-btn.acrylic-btn(
             flat
+            icon='las la-trash-alt'
             :label='t(`common.actions.delete`)'
             color='negative'
             padding='xs md'
-            @click=''
+            @click='removeItem(state.current.id)'
           )
 
       template(v-if='state.current.type === `link`')
@@ -175,7 +208,9 @@ q-layout(view='hHh lpR fFf', container)
                   q-icon.cursor-pointer(
                     name='las la-icons'
                     color='primary'
-                  )
+                    )
+                    q-menu(content-class='shadow-7')
+                      icon-picker-dialog(v-model='state.current.icon')
           q-separator.q-my-sm(inset)
           q-item
             blueprint-icon(icon='link')
@@ -219,49 +254,52 @@ q-layout(view='hHh lpR fFf', container)
                 toggle-color='primary'
                 :options='visibilityOptions'
               )
-          q-item(v-if='state.current.visibilityLimited')
-            q-item-section
-            q-item-section
-              q-select(
-                outlined
-                v-model='state.current.visibility'
-                :options='state.groups'
-                option-value='value'
-                option-label='label'
-                emit-value
-                map-options
-                dense
-                options-dense
-                :virtual-scroll-slice-size='1000'
-                :aria-label='t(`admin.general.uploadConflictBehavior`)'
-                )
+          q-item.items-center(v-if='state.current.visibilityLimited')
+            q-space
+            .text-caption.q-mr-md {{ t('navEdit.selectGroups') }}
+            q-select(
+              style='width: 100%; max-width: calc(50% - 34px);'
+              outlined
+              v-model='state.current.visibilityGroups'
+              :options='state.groups'
+              option-value='id'
+              option-label='name'
+              emit-value
+              map-options
+              dense
+              multiple
+              :aria-label='t(`navEdit.selectGroups`)'
+              )
 
-        q-card.q-pa-md.q-mt-md.flex
-          q-btn.acrylic-btn(
-            v-if='state.current.isNested'
-            flat
-            :label='t(`navEdit.unnestItem`)'
-            icon='mdi-format-indent-decrease'
-            color='teal'
-            padding='xs md'
-            @click='state.current.isNested = false'
-          )
-          q-btn.acrylic-btn(
-            v-else
-            flat
-            :label='t(`navEdit.nestItem`)'
-            icon='mdi-format-indent-increase'
-            color='teal'
-            padding='xs md'
-            @click='state.current.isNested = true'
-          )
+        q-card.q-pa-md.q-mt-md.flex.items-start
+          div
+            q-btn.acrylic-btn(
+              v-if='state.current.isNested'
+              flat
+              :label='t(`navEdit.unnestItem`)'
+              icon='mdi-format-indent-decrease'
+              color='teal'
+              padding='xs md'
+              @click='state.current.isNested = false'
+            )
+            q-btn.acrylic-btn(
+              v-else
+              flat
+              :label='t(`navEdit.nestItem`)'
+              icon='mdi-format-indent-increase'
+              color='teal'
+              padding='xs md'
+              @click='state.current.isNested = true'
+            )
+            .text-caption.q-mt-md.text-grey-7 {{ t('navEdit.nestingWarn') }}
           q-space
           q-btn.acrylic-btn(
             flat
+            icon='las la-trash-alt'
             :label='t(`common.actions.delete`)'
             color='negative'
             padding='xs md'
-            @click=''
+            @click='removeItem(state.current.id)'
           )
 
       template(v-if='state.current.type === `separator`')
@@ -272,10 +310,11 @@ q-layout(view='hHh lpR fFf', container)
           q-space
           q-btn.acrylic-btn(
             flat
+            icon='las la-trash-alt'
             :label='t(`common.actions.delete`)'
             color='negative'
             padding='xs md'
-            @click=''
+            @click='removeItem(state.current.id)'
           )
 
 </template>
@@ -284,6 +323,7 @@ q-layout(view='hHh lpR fFf', container)
 import { useI18n } from 'vue-i18n'
 import { useQuasar } from 'quasar'
 import { onMounted, reactive, ref } from 'vue'
+import { v4 as uuid } from 'uuid'
 import gql from 'graphql-tag'
 import { cloneDeep } from 'lodash-es'
 
@@ -291,6 +331,8 @@ import { useSiteStore } from 'src/stores/site'
 
 import { Sortable } from 'sortablejs-vue3'
 
+import IconPickerDialog from 'src/components/IconPickerDialog.vue'
+
 // QUASAR
 
 const $q = useQuasar()
@@ -307,66 +349,8 @@ const { t } = useI18n()
 
 const state = reactive({
   loading: 0,
-  selected: '3',
-  items: [
-    {
-      id: '1',
-      type: 'header',
-      label: 'General'
-    },
-    {
-      id: '2',
-      type: 'link',
-      label: 'Dogs',
-      icon: 'las la-dog'
-    },
-    {
-      id: '3',
-      type: 'link',
-      label: 'Cats',
-      icon: 'las la-cat'
-    },
-    {
-      id: '4',
-      type: 'separator'
-    },
-    {
-      id: '5',
-      type: 'header',
-      label: 'User Guide'
-    },
-    {
-      id: '6',
-      type: 'link',
-      label: 'Editing Pages',
-      icon: 'las la-file-alt'
-    },
-    {
-      id: '7',
-      type: 'link',
-      label: 'Permissions',
-      icon: 'las la-key',
-      isNested: true
-    },
-    {
-      id: '8',
-      type: 'link',
-      label: 'Supersuperlongtitleveryveryversupersupersupersupersuper long word',
-      icon: 'las la-key'
-    },
-    {
-      id: '9',
-      type: 'link',
-      label: 'Users',
-      icon: 'las la-users'
-    },
-    {
-      id: '10',
-      type: 'link',
-      label: 'Locales',
-      icon: 'las la-globe'
-    }
-  ],
+  selected: null,
+  items: [],
   current: {
     label: '',
     icon: '',
@@ -409,12 +393,67 @@ function setItem (item) {
   state.current = item
 }
 
+function addItem (type) {
+  const newItem = {
+    id: uuid(),
+    type
+  }
+  switch (type) {
+    case 'header': {
+      newItem.label = t('navEdit.header')
+      break
+    }
+    case 'link': {
+      newItem.label = t('navEdit.link')
+      newItem.icon = 'mdi-text-box-outline'
+      newItem.target = '/'
+      newItem.openInNewWindow = false
+      newItem.visibilityGroups = []
+      newItem.visibilityLimited = false
+      newItem.isNested = false
+      break
+    }
+  }
+  state.items.push(newItem)
+  state.selected = newItem.id
+  state.current = newItem
+}
+
+function removeItem (id) {
+  state.items = state.items.filter(item => item.id !== id)
+  state.selected = null
+  state.current = {}
+}
+
+function clearItems () {
+  state.items = []
+  state.selected = null
+  state.current = {}
+}
+
 function close () {
   siteStore.$patch({ overlay: '' })
 }
 
-onMounted(() => {
+async function loadGroups () {
+  state.loading++
+  const resp = await APOLLO_CLIENT.query({
+    query: gql`
+      query getGroupsForEditNavMenu {
+        groups {
+          id
+          name
+        }
+      }
+    `,
+    fetchPolicy: 'network-only'
+  })
+  state.groups = cloneDeep(resp?.data?.groups ?? [])
+  state.loading--
+}
 
+onMounted(() => {
+  loadGroups()
 })
 </script>
 

+ 27 - 14
ux/src/components/NavSidebar.vue

@@ -8,20 +8,22 @@ q-scroll-area.sidebar-nav(
     dense
     dark
     )
-    q-item-label.text-blue-2.text-caption(header) Header
-    q-item(to='/install')
-      q-item-section(side)
-        q-icon(name='las la-dog', color='white')
-      q-item-section Link 1
-    q-item(to='/install')
-      q-item-section(side)
-        q-icon(name='las la-cat', color='white')
-      q-item-section Link 2
-    q-separator.q-my-sm(dark)
-    q-item(to='/install')
-      q-item-section(side)
-        q-icon(name='mdi-fruit-grapes', color='white')
-      q-item-section.text-wordbreak-all Link 3
+    template(v-for='item of siteStore.nav.items')
+      q-item-label.text-blue-2.text-caption.text-wordbreak-all(
+        v-if='item.type === `header`'
+        header
+        ) {{ item.label }}
+      q-item(
+        v-else-if='item.type === `link`'
+        :to='item.target'
+        )
+        q-item-section(side)
+          q-icon(:name='item.icon', color='white')
+        q-item-section.text-wordbreak-all.text-white {{ item.label }}
+      q-separator.q-my-sm(
+        v-else-if='item.type === `separator`'
+        dark
+        )
 </template>
 
 <script setup>
@@ -30,6 +32,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 
+import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 
 // QUASAR
@@ -38,6 +41,7 @@ const $q = useQuasar()
 
 // STORES
 
+const pageStore = usePageStore()
 const siteStore = useSiteStore()
 
 // ROUTER
@@ -63,6 +67,15 @@ const barStyle = {
   width: '9px',
   opacity: 0.1
 }
+
+// WATCHERS
+
+watch(() => pageStore.navigationId, (newValue) => {
+  if (newValue !== siteStore.nav.currentId) {
+    siteStore.fetchNavigation(newValue)
+  }
+}, { immediate: true })
+
 </script>
 
 <style lang="scss">

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

@@ -33,6 +33,14 @@ q-layout(view='hHh Lpr lff')
     nav-sidebar
     q-bar.bg-blue-9.text-white(dense, v-if='userStore.authenticated')
       q-btn.col(
+        v-if='isRoot'
+        icon='las la-dharmachakra'
+        label='Edit Nav'
+        flat
+        @click='siteStore.$patch({ overlay: `NavEdit` })'
+        )
+      q-btn.col(
+        v-else
         icon='las la-dharmachakra'
         label='Edit Nav'
         flat
@@ -44,7 +52,6 @@ q-layout(view='hHh Lpr lff')
           :offset='[0, 10]'
           )
           nav-edit-menu(:menu-hide-handler='navEditMenu.hide')
-
       q-separator(vertical)
       q-btn.col(
         icon='las la-bookmark'
@@ -78,6 +85,7 @@ import { useI18n } from 'vue-i18n'
 import { useCommonStore } from 'src/stores/common'
 import { useEditorStore } from 'src/stores/editor'
 import { useFlagsStore } from 'src/stores/flags'
+import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 import { useUserStore } from 'src/stores/user'
 
@@ -99,6 +107,7 @@ const $q = useQuasar()
 const commonStore = useCommonStore()
 const editorStore = useEditorStore()
 const flagsStore = useFlagsStore()
+const pageStore = usePageStore()
 const siteStore = useSiteStore()
 const userStore = useUserStore()
 
@@ -127,6 +136,10 @@ const isSidebarShown = computed(() => {
   return siteStore.showSideNav && !siteStore.sideNavIsDisabled && !(editorStore.isActive && editorStore.hideSideNav)
 })
 
+const isRoot = computed(() => {
+  return pageStore.path === '' || pageStore.path === 'home'
+})
+
 </script>
 
 <style lang="scss">

+ 2 - 0
ux/src/stores/page.js

@@ -21,6 +21,7 @@ const pagePropsFragment = gql`
     isBrowsable
     isSearchable
     locale
+    navigationId
     password
     path
     publishEndDate
@@ -197,6 +198,7 @@ export const usePageStore = defineStore('page', {
     isBrowsable: true,
     isSearchable: true,
     locale: 'en',
+    navigationId: null,
     password: '',
     path: '',
     publishEndDate: '',

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

@@ -65,7 +65,10 @@ export const useSiteStore = defineStore('site', {
     sideDialogShown: false,
     sideDialogComponent: '',
     docsBase: 'https://next.js.wiki/docs',
-    nav: {}
+    nav: {
+      currentId: null,
+      items: []
+    }
   }),
   getters: {
     overlayIsShown: (state) => Boolean(state.overlay),
@@ -236,6 +239,44 @@ export const useSiteStore = defineStore('site', {
         console.warn(err.networkError?.result ?? err.message)
         throw err
       }
+    },
+    async fetchNavigation (id) {
+      try {
+        const resp = await APOLLO_CLIENT.query({
+          query: gql`
+            query getNavigationItems ($id: UUID!) {
+              navigationById (
+                id: $id
+              ) {
+                id
+                type
+                label
+                icon
+                target
+                openInNewWindow
+                children {
+                  id
+                  type
+                  label
+                  icon
+                  target
+                  openInNewWindow
+                }
+              }
+            }
+          `,
+          variables: { id }
+        })
+        this.$patch({
+          nav: {
+            currentId: id,
+            items: resp?.data?.navigationById ?? []
+          }
+        })
+      } catch (err) {
+        console.warn(err.networkError?.result ?? err.message)
+        throw err
+      }
     }
   }
 })