Browse Source

feat: edit navigation

NGPixel 1 year ago
parent
commit
607b8d8100

+ 10 - 10
server/db/migrations/3.0.0.mjs

@@ -179,7 +179,6 @@ export async function up (knex) {
     // NAVIGATION ----------------------------
     // NAVIGATION ----------------------------
     .createTable('navigation', table => {
     .createTable('navigation', 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('name').notNullable()
       table.jsonb('items').notNullable().defaultTo('[]')
       table.jsonb('items').notNullable().defaultTo('[]')
     })
     })
     // PAGE HISTORY ------------------------
     // PAGE HISTORY ------------------------
@@ -298,6 +297,8 @@ export async function up (knex) {
       table.enu('type', ['folder', 'page', 'asset']).notNullable().index()
       table.enu('type', ['folder', 'page', 'asset']).notNullable().index()
       table.string('locale', 10).notNullable().defaultTo('en').index()
       table.string('locale', 10).notNullable().defaultTo('en').index()
       table.string('title').notNullable()
       table.string('title').notNullable()
+      table.enum('navigationMode', ['inherit', 'override', 'overrideExact', 'hide', 'hideExact']).notNullable().defaultTo('inherit').index()
+      table.uuid('navigationId').index()
       table.jsonb('meta').notNullable().defaultTo('{}')
       table.jsonb('meta').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())
@@ -393,7 +394,6 @@ export async function up (knex) {
       table.unique(['siteId', 'tag'])
       table.unique(['siteId', 'tag'])
     })
     })
     .table('tree', table => {
     .table('tree', table => {
-      table.uuid('navigationId').references('id').inTable('navigation').index()
       table.uuid('siteId').notNullable().references('id').inTable('sites')
       table.uuid('siteId').notNullable().references('id').inTable('sites')
     })
     })
     .table('userKeys', table => {
     .table('userKeys', table => {
@@ -415,7 +415,6 @@ export async function up (knex) {
 
 
   const groupAdminId = uuid()
   const groupAdminId = uuid()
   const groupGuestId = '10000000-0000-4000-8000-000000000001'
   const groupGuestId = '10000000-0000-4000-8000-000000000001'
-  const navDefaultId = uuid()
   const siteId = uuid()
   const siteId = uuid()
   const authModuleId = uuid()
   const authModuleId = uuid()
   const userAdminId = uuid()
   const userAdminId = uuid()
@@ -568,6 +567,7 @@ export async function up (knex) {
         }
         }
       },
       },
       features: {
       features: {
+        browse: true,
         ratings: false,
         ratings: false,
         ratingsMode: 'off',
         ratingsMode: 'off',
         comments: false,
         comments: false,
@@ -622,10 +622,6 @@ export async function up (knex) {
           config: {}
           config: {}
         }
         }
       },
       },
-      nav: {
-        mode: 'mixed',
-        defaultId: navDefaultId,
-      },
       theme: {
       theme: {
         dark: false,
         dark: false,
         codeBlocksTheme: 'github-dark',
         codeBlocksTheme: 'github-dark',
@@ -757,13 +753,13 @@ export async function up (knex) {
   // -> NAVIGATION
   // -> NAVIGATION
 
 
   await knex('navigation').insert({
   await knex('navigation').insert({
-    id: navDefaultId,
-    name: 'Default',
+    id: siteId,
     items: JSON.stringify([
     items: JSON.stringify([
       {
       {
         id: uuid(),
         id: uuid(),
         type: 'header',
         type: 'header',
-        label: 'Sample Header'
+        label: 'Sample Header',
+        visibilityGroups: []
       },
       },
       {
       {
         id: uuid(),
         id: uuid(),
@@ -772,6 +768,7 @@ export async function up (knex) {
         label: 'Sample Link 1',
         label: 'Sample Link 1',
         target: '/',
         target: '/',
         openInNewWindow: false,
         openInNewWindow: false,
+        visibilityGroups: [],
         children: []
         children: []
       },
       },
       {
       {
@@ -781,11 +778,13 @@ export async function up (knex) {
         label: 'Sample Link 2',
         label: 'Sample Link 2',
         target: '/',
         target: '/',
         openInNewWindow: false,
         openInNewWindow: false,
+        visibilityGroups: [],
         children: []
         children: []
       },
       },
       {
       {
         id: uuid(),
         id: uuid(),
         type: 'separator',
         type: 'separator',
+        visibilityGroups: []
       },
       },
       {
       {
         id: uuid(),
         id: uuid(),
@@ -794,6 +793,7 @@ export async function up (knex) {
         label: 'Sample Link 3',
         label: 'Sample Link 3',
         target: '/',
         target: '/',
         openInNewWindow: false,
         openInNewWindow: false,
+        visibilityGroups: [],
         children: []
         children: []
       }
       }
     ]),
     ]),

+ 108 - 4
server/graph/resolvers/navigation.mjs

@@ -1,4 +1,5 @@
 import { generateError, generateSuccess } from '../../helpers/graph.mjs'
 import { generateError, generateSuccess } from '../../helpers/graph.mjs'
+import { isNil } from 'lodash-es'
 
 
 export default {
 export default {
   Query: {
   Query: {
@@ -9,15 +10,118 @@ export default {
   Mutation: {
   Mutation: {
     async updateNavigation (obj, args, context) {
     async updateNavigation (obj, args, context) {
       try {
       try {
-        // await WIKI.db.navigation.query().patch({
-        //   config: args.tree
-        // }).where('key', 'site')
+        let updateInherited = false
+        let updateInheritedNavId = null
+        let updateNavId = null
+        let ancestorNavId = null
+
+        const treeEntry = await WIKI.db.knex('tree').where('id', args.pageId).first()
+        if (!treeEntry) {
+          throw new Error('Invalid ID')
+        }
+        const currentNavId = treeEntry.folderPath === '' && treeEntry.fileName === 'home' ? treeEntry.siteId : treeEntry.id
+        const treeEntryPath = treeEntry.folderPath ? `${treeEntry.folderPath}.${treeEntry.fileName}` : treeEntry.fileName
+
+        // -> Create / Update Nav Menu Items
+        if (!isNil(args.items)) {
+          await WIKI.db.knex('navigation').insert({
+            id: currentNavId,
+            items: JSON.stringify(args.items),
+            siteId: treeEntry.siteId
+          }).onConflict('id').merge({
+            items: JSON.stringify(args.items)
+          })
+        }
+
+        // -> Find ancestor nav ID
+        const ancNavResult = await WIKI.db.knex.raw(`
+          SELECT "navigationId", "navigationMode", nlevel("folderPath" || "fileName") AS levels
+          FROM tree
+          WHERE ("folderPath" || "fileName") @> :currentPath
+            AND "navigationMode" IN ('override', 'hide')
+          ORDER BY levels DESC
+          LIMIT 1
+        `, {
+          currentPath: treeEntry.folderPath
+        })
+        if (ancNavResult.rowCount > 0) {
+          ancestorNavId = ancNavResult.rows[0]?.navigationId
+        } else {
+          ancestorNavId = treeEntry.siteId
+        }
+
+        // -> Update mode
+        switch (args.mode) {
+          case 'inherit': {
+            updateNavId = ancestorNavId
+            if (['override', 'hide'].includes(treeEntry.navigationMode)) {
+              updateInherited = true
+              updateInheritedNavId = ancestorNavId
+            }
+            break
+          }
+          case 'override': {
+            updateNavId = treeEntry.id
+            updateInherited = true
+            updateInheritedNavId = treeEntry.id
+            break
+          }
+          case 'overrideExact': {
+            updateNavId = treeEntry.id
+            if (['override', 'hide'].includes(treeEntry.navigationMode)) {
+              updateInherited = true
+              updateInheritedNavId = ancestorNavId
+            }
+            break
+          }
+          case 'hide': {
+            updateInherited = true
+            updateNavId = null
+            break
+          }
+          case 'hideExact': {
+            updateNavId = null
+            if (['override', 'hide'].includes(treeEntry.navigationMode)) {
+              updateInherited = true
+              updateInheritedNavId = ancestorNavId
+            }
+            break
+          }
+        }
+
+        // -> Set for current path
+        await WIKI.db.knex('tree').where('id', treeEntry.id).update({ navigationMode: args.mode, navigationId: updateNavId })
+
+        // -> Update nodes that inherit from current
+        if (updateInherited) {
+          await WIKI.db.knex.raw(`
+            UPDATE tree tt
+            SET "navigationId" = :navId
+            WHERE type IN ('page', 'folder')
+              AND "folderPath" <@ :overridePath
+              AND "navigationMode" = 'inherit'
+              AND NOT EXISTS (
+                SELECT 1
+                FROM tree tc
+                WHERE type IN ('page', 'folder')
+                  AND tc."folderPath" <@ :overridePath
+                  AND tc."folderPath" @> tt."folderPath"
+                  AND tc."navigationMode" IN ('override', 'hide')
+              )
+          `, {
+            navId: updateInheritedNavId,
+            overridePath: treeEntryPath
+          })
+        }
+
         // for (const tree of args.tree) {
         // for (const tree of args.tree) {
         //   await WIKI.cache.set(`nav:sidebar:${tree.locale}`, tree.items, 300)
         //   await WIKI.cache.set(`nav:sidebar:${tree.locale}`, tree.items, 300)
         // }
         // }
 
 
         return {
         return {
-          responseResult: generateSuccess('Navigation updated successfully')
+          operation: generateSuccess('Navigation updated successfully'),
+          navigationMode: args.mode,
+          navigationId: updateNavId
         }
         }
       } catch (err) {
       } catch (err) {
         return generateError(err)
         return generateError(err)

+ 37 - 5
server/graph/schemas/navigation.graphql

@@ -14,10 +14,10 @@ extend type Query {
 
 
 extend type Mutation {
 extend type Mutation {
   updateNavigation(
   updateNavigation(
-    id: UUID!
-    name: String!
-    items: [JSON]!
-  ): DefaultResponse
+    pageId: UUID!
+    mode: NavigationMode!
+    items: [NavigationItemInput!]
+  ): NavigationUpdateResponse
 }
 }
 
 
 # -----------------------------------------------
 # -----------------------------------------------
@@ -26,10 +26,42 @@ extend type Mutation {
 
 
 type NavigationItem {
 type NavigationItem {
   id: UUID
   id: UUID
-  type: String
+  type: NavigationItemType
   label: String
   label: String
   icon: String
   icon: String
   target: String
   target: String
   openInNewWindow: Boolean
   openInNewWindow: Boolean
+  visibilityGroups: [UUID]
   children: [NavigationItem]
   children: [NavigationItem]
 }
 }
+
+input NavigationItemInput {
+  id: UUID!
+  type: NavigationItemType!
+  label: String
+  icon: String
+  target: String
+  openInNewWindow: Boolean
+  visibilityGroups: [UUID!]!
+  children: [NavigationItemInput!]
+}
+
+enum NavigationItemType {
+  header
+  link
+  separator
+}
+
+enum NavigationMode {
+  inherit
+  override
+  overrideExact
+  hide
+  hideExact
+}
+
+type NavigationUpdateResponse {
+  operation: Operation
+  navigationMode: NavigationMode
+  navigationId: UUID
+}

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

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

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

@@ -79,6 +79,7 @@ type SiteRobots {
 }
 }
 
 
 type SiteFeatures {
 type SiteFeatures {
+  browse: Boolean
   ratings: Boolean
   ratings: Boolean
   ratingsMode: SitePageRatingMode
   ratingsMode: SitePageRatingMode
   comments: Boolean
   comments: Boolean
@@ -194,6 +195,7 @@ input SiteRobotsInput {
 }
 }
 
 
 input SiteFeaturesInput {
 input SiteFeaturesInput {
+  browse: Boolean
   ratings: Boolean
   ratings: Boolean
   ratingsMode: SitePageRatingMode
   ratingsMode: SitePageRatingMode
   comments: Boolean
   comments: Boolean

+ 6 - 0
server/locales/en.json

@@ -204,6 +204,8 @@
   "admin.flags.title": "Flags",
   "admin.flags.title": "Flags",
   "admin.flags.warn.hint": "Doing so may result in data loss, performance issues or a broken installation!",
   "admin.flags.warn.hint": "Doing so may result in data loss, performance issues or a broken installation!",
   "admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!",
   "admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!",
+  "admin.general.allowBrowse": "Allow Browsing",
+  "admin.general.allowBrowseHint": "Can users browse using the tree structure of the site to pages they have access to?",
   "admin.general.allowComments": "Allow Comments",
   "admin.general.allowComments": "Allow Comments",
   "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
   "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
   "admin.general.allowContributions": "Allow Contributions",
   "admin.general.allowContributions": "Allow Contributions",
@@ -1697,6 +1699,8 @@
   "navEdit.noSelection": "Select a menu item from the left to start editing.",
   "navEdit.noSelection": "Select a menu item from the left to start editing.",
   "navEdit.openInNewWindow": "Open in New Window",
   "navEdit.openInNewWindow": "Open in New Window",
   "navEdit.openInNewWindowHint": "Whether the link should open in a new window / tab.",
   "navEdit.openInNewWindowHint": "Whether the link should open in a new window / tab.",
+  "navEdit.saveModeSuccess": "Navigation mode set successfully.",
+  "navEdit.saveSuccess": "Menu items saved successfully.",
   "navEdit.selectGroups": "Group(s):",
   "navEdit.selectGroups": "Group(s):",
   "navEdit.separator": "Separator",
   "navEdit.separator": "Separator",
   "navEdit.target": "Target",
   "navEdit.target": "Target",
@@ -1711,6 +1715,8 @@
   "pageDeleteDialog.deleteSuccess": "Page deleted successfully.",
   "pageDeleteDialog.deleteSuccess": "Page deleted successfully.",
   "pageDeleteDialog.pageId": "Page ID {id}",
   "pageDeleteDialog.pageId": "Page ID {id}",
   "pageDeleteDialog.title": "Confirm Page Deletion",
   "pageDeleteDialog.title": "Confirm Page Deletion",
+  "pageDuplicateDialog.title": "Duplicate and Save As...",
+  "pageRenameDialog.title": "Rename / Move to...",
   "pageSaveDialog.displayModePath": "Browse Using Paths",
   "pageSaveDialog.displayModePath": "Browse Using Paths",
   "pageSaveDialog.displayModeTitle": "Browse Using Titles",
   "pageSaveDialog.displayModeTitle": "Browse Using Titles",
   "pageSaveDialog.title": "Save As...",
   "pageSaveDialog.title": "Save As...",

+ 1 - 0
server/models/navigation.mjs

@@ -20,6 +20,7 @@ export class Navigation extends Model {
 
 
   static async getNav ({ id, cache = false, userGroups = [] }) {
   static async getNav ({ id, cache = false, userGroups = [] }) {
     const result = await WIKI.db.navigation.query().findById(id).select('items')
     const result = await WIKI.db.navigation.query().findById(id).select('items')
+    if (!result) { return [] }
     return result.items.filter(item => {
     return result.items.filter(item => {
       return !item.visibilityGroups?.length || intersection(item.visibilityGroups, userGroups).length > 0
       return !item.visibilityGroups?.length || intersection(item.visibilityGroups, userGroups).length > 0
     }).map(item => {
     }).map(item => {

+ 0 - 8
server/models/pageHistory.mjs

@@ -64,14 +64,6 @@ export class PageHistory extends Model {
           from: 'pageHistory.authorId',
           from: 'pageHistory.authorId',
           to: 'users.id'
           to: 'users.id'
         }
         }
-      },
-      locale: {
-        relation: Model.BelongsToOneRelation,
-        modelClass: Locale,
-        join: {
-          from: 'pageHistory.locale',
-          to: 'locales.code'
-        }
       }
       }
     }
     }
   }
   }

+ 2 - 1
server/models/pages.mjs

@@ -1220,7 +1220,8 @@ export class Page extends Model {
             creatorName: 'creator.name',
             creatorName: 'creator.name',
             creatorEmail: 'creator.email'
             creatorEmail: 'creator.email'
           },
           },
-          'tree.navigationId'
+          'tree.navigationId',
+          'tree.navigationMode'
         ])
         ])
         .joinRelated('author')
         .joinRelated('author')
         .joinRelated('creator')
         .joinRelated('creator')

+ 9 - 13
server/models/sites.mjs

@@ -48,16 +48,7 @@ export class Site extends Model {
   }
   }
 
 
   static async createSite (hostname, config) {
   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({
     const newSite = await WIKI.db.sites.query().insertAndFetch({
-      id: newSiteId,
       hostname,
       hostname,
       isEnabled: true,
       isEnabled: true,
       config: defaultsDeep(config, {
       config: defaultsDeep(config, {
@@ -76,6 +67,7 @@ export class Site extends Model {
           }
           }
         },
         },
         features: {
         features: {
+          browse: true,
           ratings: false,
           ratings: false,
           ratingsMode: 'off',
           ratingsMode: 'off',
           comments: false,
           comments: false,
@@ -148,10 +140,6 @@ export class Site extends Model {
             config: {}
             config: {}
           }
           }
         },
         },
-        nav: {
-          mode: 'mixed',
-          defaultId: newDefaultNav.id,
-        },
         uploads: {
         uploads: {
           conflictBehavior: 'overwrite',
           conflictBehavior: 'overwrite',
           normalizeFilename: true
           normalizeFilename: true
@@ -159,6 +147,14 @@ export class Site extends Model {
       })
       })
     })
     })
 
 
+    WIKI.logger.debug(`Creating new root navigation for site ${newSite.id}`)
+
+    await WIKI.db.navigation.query().insert({
+      id: newSite.id,
+      siteId: newSite.id,
+      items: JSON.stringify([])
+    })
+
     WIKI.logger.debug(`Creating new DB storage for site ${newSite.id}`)
     WIKI.logger.debug(`Creating new DB storage for site ${newSite.id}`)
 
 
     await WIKI.db.storage.query().insert({
     await WIKI.db.storage.query().insert({

+ 6 - 3
server/models/tree.mjs

@@ -147,7 +147,8 @@ export class Tree extends Model {
       hash: generateHash(fullPath),
       hash: generateHash(fullPath),
       locale: locale,
       locale: locale,
       siteId,
       siteId,
-      meta
+      meta,
+      navigationId: siteId,
     }).returning('*')
     }).returning('*')
 
 
     return pageEntry[0]
     return pageEntry[0]
@@ -245,7 +246,8 @@ export class Tree extends Model {
       siteId: siteId,
       siteId: siteId,
       locale: locale,
       locale: locale,
       folderPath: encodeFolderPath(parentPath),
       folderPath: encodeFolderPath(parentPath),
-      fileName: pathName
+      fileName: pathName,
+      type: 'folder'
     }).first()
     }).first()
     if (existingFolder) {
     if (existingFolder) {
       throw new Error('ERR_FOLDER_ALREADY_EXISTS')
       throw new Error('ERR_FOLDER_ALREADY_EXISTS')
@@ -354,7 +356,8 @@ export class Tree extends Model {
         .andWhere({
         .andWhere({
           siteId: folder.siteId,
           siteId: folder.siteId,
           folderPath: folder.folderPath,
           folderPath: folder.folderPath,
-          fileName: pathName
+          fileName: pathName,
+          type: 'folder'
         }).first()
         }).first()
       if (existingFolder) {
       if (existingFolder) {
         throw new Error('ERR_FOLDER_ALREADY_EXISTS')
         throw new Error('ERR_FOLDER_ALREADY_EXISTS')

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 48 48" width="96px" height="96px"><path fill="#42a5f5" d="M42 38L17 38 17 5 34 5 42 13z"/><path fill="#e1f5fe" d="M40.5 14L33 14 33 6.5z"/><path fill="#1565c0" d="M22 19H38V21H22zM22 23H34V25H22zM22 27H38V29H22zM22 31H34V33H22z"/><g><path fill="#90caf9" d="M31 43L6 43 6 10 23 10 31 18z"/><path fill="#e1f5fe" d="M29.5 19L22 19 22 11.5z"/><path fill="#1976d2" d="M11 24H26V26H11zM11 28H22V30H11zM11 32H26V34H11zM11 36H22V38H11z"/></g></svg>

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#dff0fe" d="M14.5 33.5L14.5 1.5 30.793 1.5 38.5 9.207 38.5 33.5z"/><path fill="#4788c7" d="M30.586,2L38,9.414V33H15V2H30.586 M31,1H14v33h25V9L31,1L31,1z"/><path fill="#b6dcfe" d="M30.5 9.5L30.5 1.5 30.793 1.5 38.5 9.207 38.5 9.5z"/><path fill="#4788c7" d="M31,2.414L37.586,9H31V2.414 M31,1h-1v9h9V9L31,1L31,1z"/><path fill="#fff" d="M1.5 38.5L1.5 6.5 17.793 6.5 25.5 14.207 25.5 38.5z"/><path fill="#4788c7" d="M17.586,7L25,14.414V38H2V7H17.586 M18,6H1v33h25V14L18,6L18,6z"/><path fill="#dff0fe" d="M17.5 14.5L17.5 6.5 17.793 6.5 25.5 14.207 25.5 14.5z"/><path fill="#4788c7" d="M18 7.414L24.586 14H18V7.414M18 6h-1v9h9v-1L18 6 18 6zM6.5 19h14c.275 0 .5.225.5.5l0 0c0 .275-.225.5-.5.5h-14C6.225 20 6 19.775 6 19.5l0 0C6 19.225 6.225 19 6.5 19zM6.5 25h14c.275 0 .5.225.5.5l0 0c0 .275-.225.5-.5.5h-14C6.225 26 6 25.775 6 25.5l0 0C6 25.225 6.225 25 6.5 25zM6.5 22h11c.275 0 .5.225.5.5l0 0c0 .275-.225.5-.5.5h-11C6.225 23 6 22.775 6 22.5l0 0C6 22.225 6.225 22 6.5 22zM6.5 28h11c.275 0 .5.225.5.5l0 0c0 .275-.225.5-.5.5h-11C6.225 29 6 28.775 6 28.5l0 0C6 28.225 6.225 28 6.5 28zM25.5 15h8c.275 0 .5.225.5.5v0c0 .275-.225.5-.5.5h-8c-.275 0-.5-.225-.5-.5v0C25 15.225 25.225 15 25.5 15zM25.5 21h8c.275 0 .5.225.5.5l0 0c0 .275-.225.5-.5.5h-8c-.275 0-.5-.225-.5-.5l0 0C25 21.225 25.225 21 25.5 21zM25.5 27h8c.275 0 .5.225.5.5l0 0c0 .275-.225.5-.5.5h-8c-.275 0-.5-.225-.5-.5l0 0C25 27.225 25.225 27 25.5 27zM25.5 18h5c.275 0 .5.225.5.5l0 0c0 .275-.225.5-.5.5h-5c-.275 0-.5-.225-.5-.5l0 0C25 18.225 25.225 18 25.5 18zM25.5 24h5c.275 0 .5.225.5.5l0 0c0 .275-.225.5-.5.5h-5c-.275 0-.5-.225-.5-.5l0 0C25 24.225 25.225 24 25.5 24zM6.5 31h14c.275 0 .5.225.5.5l0 0c0 .275-.225.5-.5.5h-14C6.225 32 6 31.775 6 31.5l0 0C6 31.225 6.225 31 6.5 31z"/></svg>

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#dff0fe" d="M24.5 28.5H38.5V38.5H24.5z"/><path fill="#4788c7" d="M38,29v9H25v-9H38 M39,28H24v11h15V28L39,28z"/><path fill="#dff0fe" d="M24.5 13.5H38.5V23.5H24.5z"/><path fill="#4788c7" d="M38,14v9H25v-9H38 M39,13H24v11h15V13L39,13z"/><path fill="#98ccfd" d="M1.5 1.5H15.5V11.5H1.5z"/><path fill="#4788c7" d="M15 2v9H2V2H15M16 1H1v11h15V1L16 1zM24 33L9 33 9 12 8 12 8 34 24 34z"/><path fill="#4788c7" d="M15.5 10.5H16.5V26.5H15.5z" transform="rotate(-90 16 18.5)"/></svg>

+ 20 - 2
ux/src/components/LocaleSelectorMenu.vue

@@ -1,8 +1,9 @@
 <template lang="pug">
 <template lang="pug">
 q-menu.translucent-menu(
 q-menu.translucent-menu(
   auto-close
   auto-close
-  anchor='bottom left'
-  self='top left'
+  :anchor='props.anchor'
+  :self='props.self'
+  :offset='props.offset'
   )
   )
   q-list(padding, style='min-width: 200px;')
   q-list(padding, style='min-width: 200px;')
     q-item(
     q-item(
@@ -25,6 +26,23 @@ import { useQuasar } from 'quasar'
 import { useCommonStore } from 'src/stores/common'
 import { useCommonStore } from 'src/stores/common'
 import { useSiteStore } from 'src/stores/site'
 import { useSiteStore } from 'src/stores/site'
 
 
+// PROPS
+
+const props = defineProps({
+  anchor: {
+    type: String,
+    default: 'bottom left'
+  },
+  self: {
+    type: String,
+    default: 'top left'
+  },
+  offset: {
+    type: Array,
+    default: () => ([0, 0])
+  }
+})
+
 // QUASAR
 // QUASAR
 
 
 const $q = useQuasar()
 const $q = useQuasar()

+ 151 - 31
ux/src/components/NavEditMenu.vue

@@ -3,35 +3,63 @@ q-card(style='min-width: 350px')
   q-card-section.card-header
   q-card-section.card-header
     q-icon(name='img:/_assets/icons/fluent-sidebar-menu.svg', left, size='sm')
     q-icon(name='img:/_assets/icons/fluent-sidebar-menu.svg', left, size='sm')
     span {{t(`navEdit.title`)}}
     span {{t(`navEdit.title`)}}
-  q-card-section
-    q-btn.full-width(
-      unelevated
-      icon='mdi-playlist-edit'
-      color='deep-orange-9'
-      :label='t(`navEdit.editMenuItems`)'
-      @click='startEditing'
-    )
-  q-separator(inset)
-  q-card-section.q-pb-none.text-body2 Mode
   q-list(padding)
   q-list(padding)
-    q-item(tag='label')
-      q-item-section(side)
-        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', :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', :disable='isRoot')
-      q-item-section
-        q-item-label Override Current Only
-        q-item-label(caption) Set menu items and settings only for this path.
+    template(v-if='isRoot')
+      q-item(tag='label')
+        q-item-section(side)
+          q-radio(v-model='state.mode', val='inherit')
+        q-item-section
+          q-item-label Show
+          q-item-label(caption) Show the left sidebar navigaiton menu items.
+      q-item(tag='label')
+        q-item-section(side)
+          q-radio(v-model='state.mode', val='hide')
+        q-item-section
+          q-item-label Hide
+          q-item-label(caption) Completely hide the left sidebar navigation.
+    template(v-else)
+      q-item(tag='label')
+        q-item-section(side)
+          q-radio(v-model='state.mode', val='inherit')
+        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='override')
+        q-item-section
+          q-item-label Override Current + Descendants
+          q-item-label(caption) Set menu items and settings for this path and all descendants.
+      q-item(tag='label')
+        q-item-section(side)
+          q-radio(v-model='state.mode', val='overrideExact')
+        q-item-section
+          q-item-label Override Current Only
+          q-item-label(caption) Set menu items and settings only for this path.
+      q-item(tag='label')
+        q-item-section(side)
+          q-radio(v-model='state.mode', val='hide')
+        q-item-section
+          q-item-label Hide Current + Descendants
+          q-item-label(caption) Completely hide the left sidebar navigation for this path and all descendants.
+      q-item(tag='label')
+        q-item-section(side)
+          q-radio(v-model='state.mode', val='hideExact')
+        q-item-section
+          q-item-label Hide Current Only
+          q-item-label(caption) Completely hide the left sidebar navigation only for this path.
+
+  template(v-if='canEditMenuItems')
+    q-separator(inset)
+    q-card-section
+      q-btn.full-width(
+        unelevated
+        icon='mdi-playlist-edit'
+        color='deep-orange-9'
+        :label='t(`navEdit.editMenuItems`)'
+        @click='startEditing'
+      )
+
   q-card-actions.card-actions
   q-card-actions.card-actions
     q-space
     q-space
     q-btn.acrylic-btn(
     q-btn.acrylic-btn(
@@ -46,12 +74,16 @@ q-card(style='min-width: 350px')
       :label='t(`common.actions.save`)'
       :label='t(`common.actions.save`)'
       color='positive'
       color='positive'
       padding='xs md'
       padding='xs md'
+      @click='save'
+      :loading='state.loading > 0'
       )
       )
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { computed, onMounted, reactive, ref, watch, nextTick } from 'vue'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
+import { useQuasar } from 'quasar'
+import gql from 'graphql-tag'
 
 
 import { usePageStore } from 'src/stores/page'
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 import { useSiteStore } from 'src/stores/site'
@@ -62,9 +94,17 @@ const props = defineProps({
   menuHideHandler: {
   menuHideHandler: {
     type: Function,
     type: Function,
     default: () => ({})
     default: () => ({})
+  },
+  updatePositionHandler: {
+    type: Function,
+    default: () => ({})
   }
   }
 })
 })
 
 
+// QUASAR
+
+const $q = useQuasar()
+
 // STORES
 // STORES
 
 
 const pageStore = usePageStore()
 const pageStore = usePageStore()
@@ -77,14 +117,94 @@ const { t } = useI18n()
 // DATA
 // DATA
 
 
 const state = reactive({
 const state = reactive({
-  mode: 'inherit'
+  mode: 'inherit',
+  loading: 0
+})
+
+// COMPUTED
+
+const isRoot = computed(() => {
+  return pageStore.path === '' || pageStore.path === 'home'
+})
+
+const canEditMenuItems = computed(() => {
+  if (!isRoot.value && state.mode === 'inherit') { return false }
+  return ['inherit', 'override', 'overrideExact'].includes(state.mode)
+})
+
+// WATCHERS
+
+watch(() => state.mode, () => {
+  nextTick(() => {
+    props.updatePositionHandler()
+  })
 })
 })
 
 
 // METHODS
 // METHODS
 
 
 function startEditing () {
 function startEditing () {
-  siteStore.$patch({ overlay: 'NavEdit' })
+  siteStore.$patch({ overlay: 'NavEdit', overlayOpts: { mode: state.mode } })
   props.menuHideHandler()
   props.menuHideHandler()
 }
 }
 
 
+async function save () {
+  state.loading++
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation updateNavMode (
+          $pageId: UUID!
+          $mode: NavigationMode!
+          ) {
+          updateNavigation (
+            pageId: $pageId
+            mode: $mode
+          ) {
+            operation {
+              succeeded
+              message
+            }
+            navigationId
+          }
+        }
+      `,
+      variables: {
+        pageId: pageStore.id,
+        mode: state.mode
+      }
+    })
+    if (resp?.data?.updateNavigation?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('navEdit.saveModeSuccess')
+      })
+      // -> Clear GraphQL Cache
+      APOLLO_CLIENT.cache.evict('ROOT_QUERY')
+      APOLLO_CLIENT.cache.gc()
+
+      // -> Set current nav id
+      pageStore.$patch({
+        navigationMode: state.mode,
+        navigationId: resp.data.updateNavigation.navigationId
+      })
+
+      props.menuHideHandler()
+    } else {
+      throw new Error(resp?.data?.updateNavigation?.operation?.message || 'Unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.loading--
+}
+
+// MOUNTED
+
+onMounted(() => {
+  state.mode = pageStore.navigationMode
+})
+
 </script>
 </script>

+ 230 - 7
ux/src/components/NavEditOverlay.vue

@@ -38,6 +38,7 @@ q-layout(view='hHh lpR fFf', container)
         :aria-label='t(`common.actions.save`)'
         :aria-label='t(`common.actions.save`)'
         icon='las la-check'
         icon='las la-check'
         :disabled='state.loading > 0'
         :disabled='state.loading > 0'
+        @click='save'
       )
       )
 
 
   q-drawer.bg-dark-6(:model-value='true', :width='295', dark)
   q-drawer.bg-dark-6(:model-value='true', :width='295', dark)
@@ -50,6 +51,7 @@ q-layout(view='hHh lpR fFf', container)
         :list='state.items'
         :list='state.items'
         item-key='id'
         item-key='id'
         :options='sortableOptions'
         :options='sortableOptions'
+        @end='updateItemPosition'
         )
         )
         template(#item='{element}')
         template(#item='{element}')
           .nav-edit-item.nav-edit-item-header(
           .nav-edit-item.nav-edit-item-header(
@@ -163,6 +165,36 @@ q-layout(view='hHh lpR fFf', container)
                 hide-bottom-space
                 hide-bottom-space
                 :aria-label='t(`navEdit.label`)'
                 :aria-label='t(`navEdit.label`)'
                 )
                 )
+          q-item
+            blueprint-icon(icon='user-groups')
+            q-item-section
+              q-item-label {{t(`navEdit.visibility`)}}
+              q-item-label(caption) {{t(`navEdit.visibilityHint`)}}
+            q-item-section(avatar)
+              q-btn-toggle(
+                v-model='state.current.visibilityLimited'
+                push
+                glossy
+                no-caps
+                toggle-color='primary'
+                :options='visibilityOptions'
+              )
+          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-card.q-pa-md.q-mt-md.flex
           q-space
           q-space
           q-btn.acrylic-btn(
           q-btn.acrylic-btn(
@@ -303,9 +335,39 @@ q-layout(view='hHh lpR fFf', container)
           )
           )
 
 
       template(v-if='state.current.type === `separator`')
       template(v-if='state.current.type === `separator`')
-        q-card
+        q-card.q-pb-sm
           q-card-section
           q-card-section
             .text-subtitle1 {{t('navEdit.separator')}}
             .text-subtitle1 {{t('navEdit.separator')}}
+          q-item
+            blueprint-icon(icon='user-groups')
+            q-item-section
+              q-item-label {{t(`navEdit.visibility`)}}
+              q-item-label(caption) {{t(`navEdit.visibilityHint`)}}
+            q-item-section(avatar)
+              q-btn-toggle(
+                v-model='state.current.visibilityLimited'
+                push
+                glossy
+                no-caps
+                toggle-color='primary'
+                :options='visibilityOptions'
+              )
+          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-card.q-pa-md.q-mt-md.flex
           q-space
           q-space
           q-btn.acrylic-btn(
           q-btn.acrylic-btn(
@@ -322,11 +384,12 @@ q-layout(view='hHh lpR fFf', container)
 <script setup>
 <script setup>
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
 import { useQuasar } from 'quasar'
 import { useQuasar } from 'quasar'
-import { onMounted, reactive, ref } from 'vue'
+import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
 import { v4 as uuid } from 'uuid'
 import { v4 as uuid } from 'uuid'
 import gql from 'graphql-tag'
 import gql from 'graphql-tag'
-import { cloneDeep } from 'lodash-es'
+import { cloneDeep, last, pick } from 'lodash-es'
 
 
+import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 import { useSiteStore } from 'src/stores/site'
 
 
 import { Sortable } from 'sortablejs-vue3'
 import { Sortable } from 'sortablejs-vue3'
@@ -339,6 +402,7 @@ const $q = useQuasar()
 
 
 // STORES
 // STORES
 
 
+const pageStore = usePageStore()
 const siteStore = useSiteStore()
 const siteStore = useSiteStore()
 
 
 // I18N
 // I18N
@@ -356,7 +420,7 @@ const state = reactive({
     icon: '',
     icon: '',
     target: '/',
     target: '/',
     openInNewWindow: false,
     openInNewWindow: false,
-    visibility: [],
+    visibilityGroups: [],
     visibilityLimited: false,
     visibilityLimited: false,
     isNested: false
     isNested: false
   },
   },
@@ -396,7 +460,9 @@ function setItem (item) {
 function addItem (type) {
 function addItem (type) {
   const newItem = {
   const newItem = {
     id: uuid(),
     id: uuid(),
-    type
+    type,
+    visibilityGroups: [],
+    visibilityLimited: false
   }
   }
   switch (type) {
   switch (type) {
     case 'header': {
     case 'header': {
@@ -408,8 +474,6 @@ function addItem (type) {
       newItem.icon = 'mdi-text-box-outline'
       newItem.icon = 'mdi-text-box-outline'
       newItem.target = '/'
       newItem.target = '/'
       newItem.openInNewWindow = false
       newItem.openInNewWindow = false
-      newItem.visibilityGroups = []
-      newItem.visibilityLimited = false
       newItem.isNested = false
       newItem.isNested = false
       break
       break
     }
     }
@@ -431,6 +495,11 @@ function clearItems () {
   state.current = {}
   state.current = {}
 }
 }
 
 
+function updateItemPosition (ev) {
+  const item = state.items.splice(ev.oldIndex, 1)[0]
+  state.items.splice(ev.newIndex, 0, item)
+}
+
 function close () {
 function close () {
   siteStore.$patch({ overlay: '' })
   siteStore.$patch({ overlay: '' })
 }
 }
@@ -452,9 +521,163 @@ async function loadGroups () {
   state.loading--
   state.loading--
 }
 }
 
 
+async function loadMenuItems () {
+  state.loading++
+  $q.loading.show()
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query getItemsForEditNavMenu (
+          $id: UUID!
+          ) {
+          navigationById (
+            id: $id
+            ) {
+              id
+              type
+              label
+              icon
+              target
+              openInNewWindow
+              visibilityGroups
+              children {
+                id
+                type
+                label
+                icon
+                target
+                openInNewWindow
+                visibilityGroups
+              }
+          }
+        }
+      `,
+      variables: {
+        id: pageStore.isHome ? pageStore.navigationId : pageStore.id
+      },
+      fetchPolicy: 'network-only'
+    })
+    for (const item of cloneDeep(resp?.data?.navigationById ?? [])) {
+      state.items.push({
+        ...pick(item, ['id', 'type', 'label', 'icon', 'target', 'openInNewWindow', 'visibilityGroups']),
+        visibilityLimited: item.visibilityGroups?.length > 0
+      })
+      for (const child of (item?.children ?? [])) {
+        state.items.push({
+          ...pick(child, ['id', 'type', 'label', 'icon', 'target', 'openInNewWindow', 'visibilityGroups']),
+          visibilityLimited: item.visibilityGroups?.length > 0,
+          isNested: true
+        })
+      }
+    }
+  } catch (err) {
+    console.error(err)
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+    close()
+  }
+  $q.loading.hide()
+  state.loading--
+}
+
+function cleanMenuItem (item, isNested = false) {
+  switch (item.type) {
+    case 'header': {
+      return {
+        ...pick(item, ['id', 'type', 'label']),
+        visibilityGroups: item.visibilityLimited ? item.visibilityGroups : []
+      }
+    }
+    case 'link': {
+      return {
+        ...pick(item, ['id', 'type', 'label', 'icon', 'target', 'openInNewWindow']),
+        visibilityGroups: item.visibilityLimited ? item.visibilityGroups : [],
+        ...!isNested && { children: [] }
+      }
+    }
+    case 'separator': {
+      return {
+        ...pick(item, ['id', 'type', 'label', 'icon', 'target', 'openInNewWindow']),
+        visibilityGroups: item.visibilityLimited ? item.visibilityGroups : []
+      }
+    }
+  }
+}
+
+async function save () {
+  state.loading++
+  $q.loading.show()
+  try {
+    const items = []
+    for (const item of state.items) {
+      if (item.isNested) {
+        if (items.length < 1 || last(items)?.type !== 'link') {
+          throw new Error('One or more nested link items are not under a parent link!')
+        }
+        items[items.length - 1].children.push(cleanMenuItem(item, true))
+      } else {
+        items.push(cleanMenuItem(item))
+      }
+    }
+
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation updateMenuItems (
+          $pageId: UUID!
+          $mode: NavigationMode!
+          $items: [NavigationItemInput!]
+          ) {
+          updateNavigation (
+            pageId: $pageId
+            mode: $mode
+            items: $items
+          ) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        pageId: pageStore.id,
+        mode: siteStore.overlayOpts.mode,
+        items
+      }
+    })
+    if (resp?.data?.updateNavigation?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('navEdit.saveSuccess')
+      })
+      siteStore.nav.items = items
+      // -> Clear GraphQL Cache
+      APOLLO_CLIENT.cache.evict('ROOT_QUERY')
+      APOLLO_CLIENT.cache.gc()
+      close()
+    } else {
+      throw new Error(resp?.data?.updateNavigation?.operation?.message || 'Unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  $q.loading.hide()
+  state.loading--
+}
+
 onMounted(() => {
 onMounted(() => {
+  loadMenuItems()
   loadGroups()
   loadGroups()
 })
 })
+
+onBeforeUnmount(() => {
+  siteStore.overlayOpts = {}
+})
 </script>
 </script>
 
 
 <style lang="scss" scoped>
 <style lang="scss" scoped>

+ 92 - 5
ux/src/components/NavSidebar.vue

@@ -3,16 +3,34 @@ q-scroll-area.sidebar-nav(
   :thumb-style='thumbStyle'
   :thumb-style='thumbStyle'
   :bar-style='barStyle'
   :bar-style='barStyle'
   )
   )
-  q-list(
+  q-list.sidebar-nav-list(
     clickable
     clickable
     dense
     dense
     dark
     dark
     )
     )
-    template(v-for='item of siteStore.nav.items')
+    template(v-for='item of siteStore.nav.items', :key='item.id')
       q-item-label.text-blue-2.text-caption.text-wordbreak-all(
       q-item-label.text-blue-2.text-caption.text-wordbreak-all(
         v-if='item.type === `header`'
         v-if='item.type === `header`'
         header
         header
         ) {{ item.label }}
         ) {{ item.label }}
+      q-expansion-item(
+        v-else-if='item.type === `link` && item.children?.length > 0'
+        :icon='item.icon'
+        :label='item.label'
+        dense
+        )
+        q-list(
+          clickable
+          dense
+          dark
+          )
+          q-item(
+            v-for='itemChild of item.children'
+            :to='itemChild.target'
+            )
+            q-item-section(side)
+              q-icon(:name='itemChild.icon', color='white')
+            q-item-section.text-wordbreak-all.text-white {{ itemChild.label }}
       q-item(
       q-item(
         v-else-if='item.type === `link`'
         v-else-if='item.type === `link`'
         :to='item.target'
         :to='item.target'
@@ -20,7 +38,7 @@ q-scroll-area.sidebar-nav(
         q-item-section(side)
         q-item-section(side)
           q-icon(:name='item.icon', color='white')
           q-icon(:name='item.icon', color='white')
         q-item-section.text-wordbreak-all.text-white {{ item.label }}
         q-item-section.text-wordbreak-all.text-white {{ item.label }}
-      q-separator.q-my-sm(
+      q-separator(
         v-else-if='item.type === `separator`'
         v-else-if='item.type === `separator`'
         dark
         dark
         )
         )
@@ -71,7 +89,7 @@ const barStyle = {
 // WATCHERS
 // WATCHERS
 
 
 watch(() => pageStore.navigationId, (newValue) => {
 watch(() => pageStore.navigationId, (newValue) => {
-  if (newValue !== siteStore.nav.currentId) {
+  if (newValue && newValue !== siteStore.nav.currentId) {
     siteStore.fetchNavigation(newValue)
     siteStore.fetchNavigation(newValue)
   }
   }
 }, { immediate: true })
 }, { immediate: true })
@@ -83,9 +101,78 @@ watch(() => pageStore.navigationId, (newValue) => {
   border-top: 1px solid rgba(255,255,255,.15);
   border-top: 1px solid rgba(255,255,255,.15);
   height: calc(100% - 38px - 24px);
   height: calc(100% - 38px - 24px);
 
 
+  &-list > .q-separator {
+    margin-top: 10px;
+    margin-bottom: 10px;
+  }
+
   .q-list {
   .q-list {
     .q-separator + .q-item__label {
     .q-separator + .q-item__label {
-      padding-top: 12px;
+      padding-top: 10px;
+    }
+
+    .q-item__section--avatar {
+      min-width: auto;
+    }
+
+    .q-expansion-item > .q-expansion-item__container {
+      > .q-item {
+        &::before {
+          content: '';
+          display: block;
+          position: absolute;
+          bottom: 0;
+          left: 0px;
+          width: 10px;
+          height: 10px;
+          border-style: solid;
+          border-color: transparent transparent rgba(255,255,255,.25) rgba(255,255,255,.25);
+          transition: all .4s ease;
+        }
+      }
+
+      &::after {
+        content: '';
+        display: block;
+        position: absolute;
+        bottom: -20px;
+        left: 0;
+        width: 10px;
+        height: 10px;
+        border-style: solid;
+        border-color: rgba(255,255,255,.25) transparent transparent rgba(255,255,255,.25);
+        transition: all .4s ease;
+      }
+    }
+
+    .q-expansion-item--collapsed > .q-expansion-item__container {
+      > .q-item {
+        &::before {
+          border-width: 0 0 0 0;
+        }
+      }
+
+      &::after {
+        bottom: 0px;
+        border-width: 0 0 0 0;
+      }
+    }
+
+    .q-expansion-item--expanded > .q-expansion-item__container {
+      > .q-item {
+        &::before {
+          border-width: 0 10px 10px 0;
+        }
+      }
+
+      &::after {
+        bottom: -20px;
+        border-width: 10px 10px 10px 0;
+      }
+    }
+
+    .q-expansion-item__content {
+      border-left: 10px solid rgba(255,255,255,.25);
     }
     }
   }
   }
 }
 }

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

@@ -341,7 +341,7 @@ async function createPage () {
   $q.dialog({
   $q.dialog({
     component: defineAsyncComponent(() => import('../components/TreeBrowserDialog.vue')),
     component: defineAsyncComponent(() => import('../components/TreeBrowserDialog.vue')),
     componentProps: {
     componentProps: {
-      mode: 'createPage',
+      mode: 'savePage',
       folderPath: '',
       folderPath: '',
       itemTitle: pageStore.title,
       itemTitle: pageStore.title,
       itemFileName: pageStore.path
       itemFileName: pageStore.path

+ 0 - 32
ux/src/components/SocialSharingMenu.vue

@@ -15,38 +15,6 @@ q-menu(
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='las la-envelope', size='sm')
         q-icon(color='grey', name='las la-envelope', size='sm')
       q-item-section.q-pr-md Email
       q-item-section.q-pr-md Email
-    q-item(clickable, @click='openSocialPop(`https://www.facebook.com/sharer/sharer.php?u=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title) + `&description=` + encodeURIComponent(props.description))')
-      q-item-section.items-center(avatar)
-        q-icon(color='grey', name='lab la-facebook', size='sm')
-      q-item-section.q-pr-md Facebook
-    q-item(clickable, @click='openSocialPop(`https://www.linkedin.com/shareArticle?mini=true&url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title) + `&summary=` + encodeURIComponent(props.description))')
-      q-item-section.items-center(avatar)
-        q-icon(color='grey', name='lab la-linkedin', size='sm')
-      q-item-section.q-pr-md LinkedIn
-    q-item(clickable, @click='openSocialPop(`https://www.reddit.com/submit?url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title))')
-      q-item-section.items-center(avatar)
-        q-icon(color='grey', name='lab la-reddit', size='sm')
-      q-item-section.q-pr-md Reddit
-    q-item(clickable, @click='openSocialPop(`https://t.me/share/url?url=` + encodeURIComponent(urlFormatted) + `&text=` + encodeURIComponent(props.title))')
-      q-item-section.items-center(avatar)
-        q-icon(color='grey', name='lab la-telegram', size='sm')
-      q-item-section.q-pr-md Telegram
-    q-item(clickable, @click='openSocialPop(`https://twitter.com/intent/tweet?url=` + encodeURIComponent(urlFormatted) + `&text=` + encodeURIComponent(props.title))')
-      q-item-section.items-center(avatar)
-        q-icon(color='grey', name='lab la-twitter', size='sm')
-      q-item-section.q-pr-md Twitter
-    q-item(clickable, :href='`viber://forward?text=` + encodeURIComponent(urlFormatted) + ` ` + encodeURIComponent(props.description)')
-      q-item-section.items-center(avatar)
-        q-icon(color='grey', name='lab la-viber', size='sm')
-      q-item-section.q-pr-md Viber
-    q-item(clickable, @click='openSocialPop(`http://service.weibo.com/share/share.php?url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title))')
-      q-item-section.items-center(avatar)
-        q-icon(color='grey', name='lab la-weibo', size='sm')
-      q-item-section.q-pr-md Weibo
-    q-item(clickable, @click='openSocialPop(`https://api.whatsapp.com/send?text=` + encodeURIComponent(props.title) + `%0D%0A` + encodeURIComponent(urlFormatted))')
-      q-item-section.items-center(avatar)
-        q-icon(color='grey', name='lab la-whatsapp', size='sm')
-      q-item-section.q-pr-md Whatsapp
 </template>
 </template>
 
 
 <script setup>
 <script setup>

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

@@ -1,9 +1,15 @@
 <template lang="pug">
 <template lang="pug">
 q-dialog(ref='dialogRef', @hide='onDialogHide')
 q-dialog(ref='dialogRef', @hide='onDialogHide')
   q-card.page-save-dialog(style='width: 860px; max-width: 90vw;')
   q-card.page-save-dialog(style='width: 860px; max-width: 90vw;')
-    q-card-section.card-header
+    q-card-section.card-header(v-if='props.mode === `savePage`')
       q-icon(name='img:/_assets/icons/fluent-save-as.svg', left, size='sm')
       q-icon(name='img:/_assets/icons/fluent-save-as.svg', left, size='sm')
       span {{t('pageSaveDialog.title')}}
       span {{t('pageSaveDialog.title')}}
+    q-card-section.card-header(v-else-if='props.mode === `duplicatePage`')
+      q-icon(name='img:/_assets/icons/color-documents.svg', left, size='sm')
+      span {{t('pageDuplicateDialog.title')}}
+    q-card-section.card-header(v-else-if='props.mode === `renamePage`')
+      q-icon(name='img:/_assets/icons/fluent-rename.svg', left, size='sm')
+      span {{t('pageRenameDialog.title')}}
     .row.page-save-dialog-browser
     .row.page-save-dialog-browser
       .col-4
       .col-4
         q-scroll-area(
         q-scroll-area(
@@ -30,8 +36,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
             clickable
             clickable
             active-class='active'
             active-class='active'
             :active='item.id === state.currentFileId'
             :active='item.id === state.currentFileId'
-            @click.native='state.currentFileId = item.id'
-            @dblclick.native='openItem(item)'
+            @click.native='selectItem(item)'
             )
             )
             q-item-section(side)
             q-item-section(side)
               q-icon(:name='item.icon', size='sm')
               q-icon(:name='item.icon', size='sm')
@@ -47,6 +52,8 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
             label='Page Title'
             label='Page Title'
             dense
             dense
             outlined
             outlined
+            autofocus
+            @focus='state.currentFileId = null'
           )
           )
       q-item
       q-item
         blueprint-icon(icon='file-submodule')
         blueprint-icon(icon='file-submodule')
@@ -56,6 +63,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
             label='Path Name'
             label='Path Name'
             dense
             dense
             outlined
             outlined
+            @focus='state.pathDirty = true; state.currentFileId = null'
           )
           )
     q-card-actions.card-actions.q-px-md
     q-card-actions.card-actions.q-px-md
       q-btn.acrylic-btn(
       q-btn.acrylic-btn(
@@ -112,10 +120,11 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
 
 
 <script setup>
 <script setup>
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
-import { computed, onMounted, reactive } from 'vue'
+import { computed, onMounted, reactive, watch } from 'vue'
 import { useDialogPluginComponent, useQuasar } from 'quasar'
 import { useDialogPluginComponent, useQuasar } from 'quasar'
 import { cloneDeep, find, initial, last } from 'lodash-es'
 import { cloneDeep, find, initial, last } from 'lodash-es'
 import gql from 'graphql-tag'
 import gql from 'graphql-tag'
+import slugify from 'slugify'
 
 
 import fileTypes from '../helpers/fileTypes'
 import fileTypes from '../helpers/fileTypes'
 
 
@@ -132,7 +141,7 @@ const props = defineProps({
   mode: {
   mode: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: 'pageSave'
+    default: 'savePage'
   },
   },
   itemId: {
   itemId: {
     type: String,
     type: String,
@@ -187,7 +196,8 @@ const state = reactive({
   fileList: [],
   fileList: [],
   title: '',
   title: '',
   path: '',
   path: '',
-  typesToFetch: []
+  typesToFetch: [],
+  pathDirty: false
 })
 })
 
 
 const thumbStyle = {
 const thumbStyle = {
@@ -228,6 +238,17 @@ const files = computed(() => {
   })
   })
 })
 })
 
 
+// WATCHERS
+
+watch(() => state.title, (newValue) => {
+  if (state.pathDirty && !state.path) {
+    state.pathDirty = false
+  }
+  if (!state.pathDirty) {
+    state.path = slugify(newValue, { lower: true, strict: true })
+  }
+})
+
 // METHODS
 // METHODS
 
 
 async function save () {
 async function save () {
@@ -247,6 +268,7 @@ async function treeLazyLoad (nodeId, isCurrent, { done, fail }) {
 
 
 async function loadTree ({ parentId = null, parentPath = null, types, initLoad = false }) {
 async function loadTree ({ parentId = null, parentPath = null, types, initLoad = false }) {
   try {
   try {
+    state.fileList = []
     const resp = await APOLLO_CLIENT.query({
     const resp = await APOLLO_CLIENT.query({
       query: gql`
       query: gql`
         query loadTree (
         query loadTree (
@@ -322,6 +344,18 @@ async function loadTree ({ parentId = null, parentPath = null, types, initLoad =
             }
             }
             break
             break
           }
           }
+          case 'TreeItemPage': {
+            state.fileList.push({
+              id: item.id,
+              type: 'page',
+              title: item.title,
+              pageType: 'markdown',
+              updatedAt: '2022-11-24T18:27:00Z',
+              folderPath: item.folderPath,
+              fileName: item.fileName
+            })
+            break
+          }
         }
         }
       }
       }
       if (newTreeRoots.length > 0) {
       if (newTreeRoots.length > 0) {
@@ -346,6 +380,12 @@ function treeContextAction (nodeId, action) {
   }
   }
 }
 }
 
 
+function selectItem (item) {
+  state.currentFileId = item.id
+  state.title = item.title
+  state.path = item.fileName
+}
+
 function newFolder (parentId) {
 function newFolder (parentId) {
   $q.dialog({
   $q.dialog({
     component: FolderCreateDialog,
     component: FolderCreateDialog,

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

@@ -114,7 +114,7 @@ q-layout.admin(view='hHh Lpr lff')
             q-item-section(avatar)
             q-item-section(avatar)
               q-icon(name='img:/_assets/icons/fluent-bunch-of-keys.svg')
               q-icon(name='img:/_assets/icons/fluent-bunch-of-keys.svg')
             q-item-section {{ t('admin.login.title') }}
             q-item-section {{ t('admin.login.title') }}
-          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/navigation`', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:sites`) || userStore.can(`manage:navigation`)')
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/navigation`', v-ripple, active-class='bg-primary text-white', disabled, v-if='flagsStore.experimental && (userStore.can(`manage:sites`) || userStore.can(`manage:navigation`))')
             q-item-section(avatar)
             q-item-section(avatar)
               q-icon(name='img:/_assets/icons/fluent-tree-structure.svg')
               q-icon(name='img:/_assets/icons/fluent-tree-structure.svg')
             q-item-section {{ t('admin.navigation.title') }}
             q-item-section {{ t('admin.navigation.title') }}

+ 84 - 41
ux/src/layouts/MainLayout.vue

@@ -4,61 +4,99 @@ q-layout(view='hHh Lpr lff')
   q-drawer.bg-sidebar(
   q-drawer.bg-sidebar(
     :modelValue='isSidebarShown'
     :modelValue='isSidebarShown'
     :show-if-above='siteStore.theme.sidebarPosition !== `off`'
     :show-if-above='siteStore.theme.sidebarPosition !== `off`'
-    :width='255'
+    :width='isSidebarMini ? 56 : 255'
     :side='siteStore.theme.sidebarPosition === `right` ? `right` : `left`'
     :side='siteStore.theme.sidebarPosition === `right` ? `right` : `left`'
     )
     )
-    .sidebar-actions.flex.items-stretch
-      q-btn.q-px-sm.col(
+    .sidebar-mini.column.items-stretch(v-if='isSidebarMini')
+      q-btn.q-py-md(
         flat
         flat
-        dense
         icon='las la-globe'
         icon='las la-globe'
-        color='blue-7'
-        text-color='blue-2'
-        :label='commonStore.locale'
-        :aria-label='commonStore.locale'
-        size='sm'
+        color='white'
+        aria-label='Switch Locale'
+        @click=''
         )
         )
-        locale-selector-menu
-      q-separator(vertical)
-      q-btn.q-px-sm.col(
+        locale-selector-menu(anchor='top right' self='top left')
+        q-tooltip(anchor='center right' self='center left') Switch Locale
+      q-btn.q-py-md(
         flat
         flat
-        dense
         icon='las la-sitemap'
         icon='las la-sitemap'
-        color='blue-7'
-        text-color='blue-2'
-        label='Browse'
+        color='white'
         aria-label='Browse'
         aria-label='Browse'
-        size='sm'
         )
         )
-    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'
+        q-tooltip(anchor='center right' self='center left') Browse
+      q-separator.q-my-sm(inset, dark)
+      q-btn.q-py-md(
         flat
         flat
-        @click='siteStore.$patch({ overlay: `NavEdit` })'
+        icon='las la-bookmark'
+        color='white'
+        aria-label='Bookmarks'
         )
         )
-      q-btn.col(
-        v-else
-        icon='las la-dharmachakra'
-        label='Edit Nav'
+        q-tooltip(anchor='center right' self='center left') Bookmarks
+      q-space
+      q-btn.q-py-xs(
         flat
         flat
+        icon='las la-dharmachakra'
+        color='white'
+        aria-label='Edit Nav'
+        size='sm'
         )
         )
         q-menu(
         q-menu(
-          ref='navEditMenu'
-          anchor='top left'
+          ref='navEditMenuMini'
+          anchor='top right'
           self='bottom left'
           self='bottom left'
-          :offset='[0, 10]'
           )
           )
-          nav-edit-menu(:menu-hide-handler='navEditMenu.hide')
-      q-separator(vertical)
-      q-btn.col(
-        icon='las la-bookmark'
-        label='Bookmarks'
-        flat
-        disabled
-      )
+          nav-edit-menu(
+            :menu-hide-handler='navEditMenuMini.hide'
+            :update-position-handler='navEditMenuMini.updatePosition'
+            )
+        q-tooltip(anchor='center right' self='center left') Edit Nav
+    template(v-else)
+      .sidebar-actions.flex.items-stretch
+        q-btn.q-px-sm.col(
+          flat
+          dense
+          icon='las la-globe'
+          color='blue-7'
+          text-color='blue-2'
+          :label='commonStore.locale'
+          :aria-label='commonStore.locale'
+          size='sm'
+          )
+          locale-selector-menu(:offset="[-5, 5]")
+        q-separator(vertical)
+        q-btn.q-px-sm.col(
+          flat
+          dense
+          icon='las la-sitemap'
+          color='blue-7'
+          text-color='blue-2'
+          label='Browse'
+          aria-label='Browse'
+          size='sm'
+          )
+      nav-sidebar
+      q-bar.bg-blue-9.text-white(dense, v-if='userStore.authenticated')
+        q-btn.col(
+          icon='las la-dharmachakra'
+          label='Edit Nav'
+          flat
+          )
+          q-menu(
+            ref='navEditMenu'
+            anchor='top left'
+            self='bottom left'
+            :offset='[0, 10]'
+            )
+            nav-edit-menu(
+              :menu-hide-handler='navEditMenu.hide'
+              :update-position-handler='navEditMenu.updatePosition'
+              )
+        q-separator(vertical)
+        q-btn.col(
+          icon='las la-bookmark'
+          label='Bookmarks'
+          flat
+        )
   q-page-container
   q-page-container
     router-view
     router-view
     q-page-scroller(
     q-page-scroller(
@@ -129,6 +167,7 @@ useMeta({
 // REFS
 // REFS
 
 
 const navEditMenu = ref(null)
 const navEditMenu = ref(null)
+const navEditMenuMini = ref(null)
 
 
 // COMPUTED
 // COMPUTED
 
 
@@ -136,8 +175,8 @@ const isSidebarShown = computed(() => {
   return siteStore.showSideNav && !siteStore.sideNavIsDisabled && !(editorStore.isActive && editorStore.hideSideNav)
   return siteStore.showSideNav && !siteStore.sideNavIsDisabled && !(editorStore.isActive && editorStore.hideSideNav)
 })
 })
 
 
-const isRoot = computed(() => {
-  return pageStore.path === '' || pageStore.path === 'home'
+const isSidebarMini = computed(() => {
+  return ['hide', 'hideExact'].includes(pageStore.navigationMode) || !pageStore.navigationId
 })
 })
 
 
 </script>
 </script>
@@ -149,6 +188,10 @@ const isRoot = computed(() => {
   height: 38px;
   height: 38px;
 }
 }
 
 
+.sidebar-mini {
+  height: 100%;
+}
+
 body.body--dark {
 body.body--dark {
   background-color: $dark-6;
   background-color: $dark-6;
 }
 }

+ 16 - 0
ux/src/pages/AdminGeneral.vue

@@ -142,6 +142,20 @@ q-page.admin-general
       q-card.q-pb-sm.q-mt-md
       q-card.q-pb-sm.q-mt-md
         q-card-section
         q-card-section
           .text-subtitle1 {{t('admin.general.features')}}
           .text-subtitle1 {{t('admin.general.features')}}
+        q-item(tag='label')
+          blueprint-icon(icon='tree-structure')
+          q-item-section
+            q-item-label {{t(`admin.general.allowBrowse`)}}
+            q-item-label(caption) {{t(`admin.general.allowBrowseHint`)}}
+          q-item-section(avatar)
+            q-toggle(
+              v-model='state.config.features.browse'
+              color='primary'
+              checked-icon='las la-check'
+              unchecked-icon='las la-times'
+              :aria-label='t(`admin.general.allowBrowse`)'
+              )
+        q-separator.q-my-sm(inset)
         q-item(tag='label')
         q-item(tag='label')
           blueprint-icon(icon='discussion-forum')
           blueprint-icon(icon='discussion-forum')
           q-item-section
           q-item-section
@@ -631,6 +645,7 @@ async function load () {
             follow
             follow
           }
           }
           features {
           features {
+            browse
             comments
             comments
             contributions
             contributions
             profile
             profile
@@ -702,6 +717,7 @@ async function save () {
             follow: state.config.robots?.follow ?? false
             follow: state.config.robots?.follow ?? false
           },
           },
           features: {
           features: {
+            browse: state.config.features?.browse ?? false,
             comments: state.config.features?.comments ?? false,
             comments: state.config.features?.comments ?? false,
             ratings: (state.config.features?.ratings || 'off') !== 'off',
             ratings: (state.config.features?.ratings || 'off') !== 'off',
             ratingsMode: state.config.features?.ratingsMode ?? 'off',
             ratingsMode: state.config.features?.ratingsMode ?? 'off',

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

@@ -22,6 +22,7 @@ const pagePropsFragment = gql`
     isSearchable
     isSearchable
     locale
     locale
     navigationId
     navigationId
+    navigationMode
     password
     password
     path
     path
     publishEndDate
     publishEndDate
@@ -199,6 +200,7 @@ export const usePageStore = defineStore('page', {
     isSearchable: true,
     isSearchable: true,
     locale: 'en',
     locale: 'en',
     navigationId: null,
     navigationId: null,
+    navigationMode: 'inherit',
     password: '',
     password: '',
     path: '',
     path: '',
     publishEndDate: '',
     publishEndDate: '',
@@ -237,6 +239,9 @@ export const usePageStore = defineStore('page', {
     },
     },
     folderPath: (state) => {
     folderPath: (state) => {
       return initial(state.path.split('/')).join('/')
       return initial(state.path.split('/')).join('/')
+    },
+    isHome: (state) => {
+      return ['', 'home'].includes(state.path)
     }
     }
   },
   },
   actions: {
   actions: {

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

@@ -133,6 +133,7 @@ export const useSiteStore = defineStore('site', {
                   }
                   }
                 }
                 }
                 features {
                 features {
+                  browse
                   profile
                   profile
                   ratingsMode
                   ratingsMode
                   reasonForChange
                   reasonForChange