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

feat: page TOC + refactor PageDataDialog to composition API

Nicolas Giard 2 жил өмнө
parent
commit
acc3b7369f

+ 13 - 61
server/controllers/common.js

@@ -153,6 +153,11 @@ router.get(['/d', '/d/*'], async (req, res, next) => {
  */
 router.get(['/_edit', '/_edit/*'], async (req, res, next) => {
   const pageArgs = pageHelper.parsePath(req.path, { stripExt: true })
+  const site = await WIKI.db.sites.getSiteByHostname({ hostname: req.hostname })
+
+  if (!site) {
+    throw new Error('INVALID_SITE')
+  }
 
   if (pageArgs.path === '') {
     return res.redirect(`/_edit/home`)
@@ -175,10 +180,10 @@ router.get(['/_edit', '/_edit/*'], async (req, res, next) => {
 
   // -> Get page data from DB
   let page = await WIKI.db.pages.getPageFromDb({
+    siteId: site.id,
     path: pageArgs.path,
     locale: pageArgs.locale,
-    userId: req.user.id,
-    isPrivate: false
+    userId: req.user.id
   })
 
   pageArgs.tags = _.get(page, 'tags', [])
@@ -415,6 +420,11 @@ router.get('/*', async (req, res, next) => {
   const stripExt = _.some(WIKI.data.pageExtensions, ext => _.endsWith(req.path, `.${ext}`))
   const pageArgs = pageHelper.parsePath(req.path, { stripExt })
   const isPage = (stripExt || pageArgs.path.indexOf('.') === -1)
+  const site = await WIKI.db.sites.getSiteByHostname({ hostname: req.hostname })
+
+  if (!site) {
+    throw new Error('INVALID_SITE')
+  }
 
   if (isPage) {
     // if (WIKI.config.lang.namespacing && !pageArgs.explicitLocale) {
@@ -426,6 +436,7 @@ router.get('/*', async (req, res, next) => {
     try {
       // -> Get Page from cache
       const page = await WIKI.db.pages.getPage({
+        siteId: site.id,
         path: pageArgs.path,
         locale: pageArgs.locale,
         userId: req.user.id
@@ -470,67 +481,8 @@ router.get('/*', async (req, res, next) => {
           })
         }
 
-        // -> Build sidebar navigation
-        let sdi = 1
-        const sidebar = (await WIKI.db.navigation.getTree({ cache: true, locale: pageArgs.locale, groups: req.user.groups })).map(n => ({
-          i: `sdi-${sdi++}`,
-          k: n.kind,
-          l: n.label,
-          c: n.icon,
-          y: n.targetType,
-          t: n.target
-        }))
-
-        // -> Build theme code injection
-        const injectCode = {
-          css: '', // WIKI.config.theming.injectCSS,
-          head: '', // WIKI.config.theming.injectHead,
-          body: '' // WIKI.config.theming.injectBody
-        }
-
-        // Handle missing extra field
-        page.extra = page.extra || { css: '', js: '' }
-
-        if (!_.isEmpty(page.extra.css)) {
-          injectCode.css = `${injectCode.css}\n${page.extra.css}`
-        }
-
-        if (!_.isEmpty(page.extra.js)) {
-          injectCode.body = `${injectCode.body}\n${page.extra.js}`
-        }
-
-        // -> Convert page TOC
-        if (!_.isString(page.toc)) {
-          page.toc = JSON.stringify(page.toc)
-        }
-
-        // -> Inject comments variables
-        const commentTmpl = {
-          codeTemplate: '', // WIKI.data.commentProvider.codeTemplate,
-          head: '', // WIKI.data.commentProvider.head,
-          body: '', // WIKI.data.commentProvider.body,
-          main: '' // WIKI.data.commentProvider.main
-        }
-        if (false && WIKI.config.features.featurePageComments && WIKI.data.commentProvider.codeTemplate) {
-          [
-            { key: 'pageUrl', value: `${WIKI.config.host}/i/${page.id}` },
-            { key: 'pageId', value: page.id }
-          ].forEach((cfg) => {
-            commentTmpl.head = _.replace(commentTmpl.head, new RegExp(`{{${cfg.key}}}`, 'g'), cfg.value)
-            commentTmpl.body = _.replace(commentTmpl.body, new RegExp(`{{${cfg.key}}}`, 'g'), cfg.value)
-            commentTmpl.main = _.replace(commentTmpl.main, new RegExp(`{{${cfg.key}}}`, 'g'), cfg.value)
-          })
-        }
-
         // -> Render view
         res.sendFile(path.join(WIKI.ROOTPATH, 'assets/index.html'))
-        // res.render('page', {
-        //   page,
-        //   sidebar,
-        //   injectCode,
-        //   comments: commentTmpl,
-        //   effectivePermissions
-        // })
       } else if (pageArgs.path === 'home') {
         res.redirect('/_welcome')
       } else {

+ 4 - 1
server/graph/resolvers/page.js

@@ -166,7 +166,10 @@ module.exports = {
      */
     async pageByPath (obj, args, context, info) {
       const pageArgs = pageHelper.parsePath(args.path)
-      let page = await WIKI.db.pages.getPageFromDb(pageArgs)
+      let page = await WIKI.db.pages.getPageFromDb({
+        ...pageArgs,
+        siteId: args.siteId
+      })
       if (page) {
         return {
           ...page,

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

@@ -35,6 +35,7 @@ extend type Query {
   ): Page
 
   pageByPath(
+    siteId: UUID!
     path: String!
   ): Page
 
@@ -173,7 +174,7 @@ type Page {
   tags: [PageTag]
   content: String
   render: String
-  toc: String
+  toc: [JSON]
   contentType: String
   createdAt: Date
   updatedAt: Date

+ 1 - 0
server/models/pages.js

@@ -1010,6 +1010,7 @@ module.exports = class Page extends Model {
         .where(queryModeID ? {
           'pages.id': opts
         } : {
+          'pages.siteId': opts.siteId,
           'pages.path': opts.path,
           'pages.localeCode': opts.locale
         })

+ 2 - 2
server/tasks/workers/render-page.js

@@ -62,8 +62,8 @@ module.exports = async ({ payload }) => {
       $('.toc-anchor', el).remove()
 
       _.get(toc, leafPath).push({
-        title: _.trim($(el).text()),
-        anchor: leafSlug,
+        label: _.trim($(el).text()),
+        key: leafSlug.substring(1),
         children: []
       })
     })

+ 58 - 49
ux/src/components/PageDataDialog.vue

@@ -1,7 +1,7 @@
 <template lang="pug">
 q-card.page-data-dialog(style='width: 750px;')
   q-toolbar.bg-primary.text-white.flex
-    .text-subtitle2 {{$t('editor.pageData.title')}}
+    .text-subtitle2 {{t('editor.pageData.title')}}
     q-space
     q-btn(
       icon='las la-times'
@@ -10,13 +10,13 @@ q-card.page-data-dialog(style='width: 750px;')
       v-close-popup
     )
   q-card-section.page-data-dialog-selector
-    //- .text-overline.text-white {{$t('editor.pageData.template')}}
+    //- .text-overline.text-white {{t('editor.pageData.template')}}
     .flex.q-gutter-sm
       q-select(
         dark
-        v-model='templateId'
-        :label='$t(`editor.pageData.template`)'
-        :aria-label='$t(`editor.pageData.template`)'
+        v-model='state.templateId'
+        :label='t(`editor.pageData.template`)'
+        :aria-label='t(`editor.pageData.template`)'
         :options='templates'
         option-value='id'
         map-options
@@ -28,14 +28,14 @@ q-card.page-data-dialog(style='width: 750px;')
       q-btn.acrylic-btn(
         dark
         icon='las la-pen'
-        :label='$t(`common.actions.manage`)'
+        :label='t(`common.actions.manage`)'
         unelevated
         no-caps
         color='deep-orange-9'
         @click='editTemplates'
       )
   q-tabs.alt-card(
-    v-model='mode'
+    v-model='state.mode'
     inline-label
     no-caps
     )
@@ -48,11 +48,11 @@ q-card.page-data-dialog(style='width: 750px;')
       label='YAML'
       )
   q-scroll-area(
-    :thumb-style='thumbStyle'
-    :bar-style='barStyle'
+    :thumb-style='siteStore.thumbStyle'
+    :bar-style='siteStore.barStyle'
     style='height: calc(100% - 50px - 75px - 48px);'
     )
-    q-card-section(v-if='mode === `visual`')
+    q-card-section(v-if='state.mode === `visual`')
       .q-gutter-sm
         q-input(
           label='Attribute Text'
@@ -76,60 +76,69 @@ q-card.page-data-dialog(style='width: 750px;')
             dense
             size='lg'
             )
-    q-no-ssr(v-else, :placeholder='$t(`common.loading`)')
+    q-no-ssr(v-else, :placeholder='t(`common.loading`)')
       codemirror.admin-theme-cm(
         ref='cmData'
-        v-model='content'
+        v-model='state.content'
         :options='{ mode: `text/yaml` }'
       )
 
   q-dialog(
-    v-model='showDataTemplateDialog'
+    v-model='state.showDataTemplateDialog'
     )
     page-data-template-dialog
 </template>
 
-<script>
-import { get } from 'vuex-pathify'
+<script setup>
+import { useI18n } from 'vue-i18n'
+import { useQuasar } from 'quasar'
+import { nextTick, onMounted, reactive, ref, watch } from 'vue'
 
 import PageDataTemplateDialog from './PageDataTemplateDialog.vue'
 
-export default {
-  components: {
-    PageDataTemplateDialog
-  },
-  data () {
-    return {
-      showDataTemplateDialog: false,
-      templateId: '',
-      content: '',
-      mode: 'visual'
-    }
-  },
-  computed: {
-    thumbStyle: get('site/thumbStyle', false),
-    barStyle: get('site/barStyle', false),
-    templates () {
-      return [
-        {
-          id: '',
-          label: 'None',
-          data: []
-        },
-        ...this.$store.get('site/pageDataTemplates'),
-        {
-          id: 'basic',
-          label: 'Basic',
-          data: []
-        }
-      ]
-    }
+import { usePageStore } from 'src/stores/page'
+import { useSiteStore } from 'src/stores/site'
+
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const pageStore = usePageStore()
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  showDataTemplateDialog: false,
+  templateId: '',
+  content: '',
+  mode: 'visual'
+})
+
+const templates = [
+  {
+    id: '',
+    label: 'None',
+    data: []
   },
-  methods: {
-    editTemplates () {
-      this.showDataTemplateDialog = !this.showDataTemplateDialog
-    }
+  ...siteStore.pageDataTemplates,
+  {
+    id: 'basic',
+    label: 'Basic',
+    data: []
   }
+]
+
+// METHODS
+
+function editTemplates () {
+  state.showDataTemplateDialog = !state.showDataTemplateDialog
 }
 </script>
 

+ 199 - 176
ux/src/components/PageDataTemplateDialog.vue

@@ -1,7 +1,7 @@
 <template lang="pug">
 q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
   q-toolbar.bg-primary.text-white
-    .text-subtitle2 {{$t('editor.pageData.manageTemplates')}}
+    .text-subtitle2 {{t('editor.pageData.manageTemplates')}}
     q-space
     q-btn(
       icon='las la-times'
@@ -12,10 +12,10 @@ q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
   q-card-section.page-datatmpl-selector
     .flex.q-gutter-md
       q-select.col(
-        v-model='selectedTemplateId'
-        :options='templates'
+        v-model='state.selectedTemplateId'
+        :options='siteStore.pageDataTemplates'
         standout
-        :label='$t(`editor.pageData.template`)'
+        :label='t(`editor.pageData.template`)'
         dense
         dark
         option-value='id'
@@ -24,23 +24,23 @@ q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
       )
       q-btn(
         icon='las la-plus'
-        :label='$t(`common.actions.new`)'
+        :label='t(`common.actions.new`)'
         unelevated
         color='primary'
         no-caps
         @click='create'
       )
-  .row(v-if='tmpl')
+  .row(v-if='state.tmpl')
     .col-auto.page-datatmpl-sd
       .q-pa-md
         q-btn.acrylic-btn.full-width(
-          :label='$t(`common.actions.howItWorks`)'
+          :label='t(`common.actions.howItWorks`)'
           icon='las la-question-circle'
           flat
           color='pink'
           no-caps
         )
-      q-item-label(header, style='margin-top: 2px;') {{$t('editor.pageData.templateFullRowTypes')}}
+      q-item-label(header, style='margin-top: 2px;') {{t('editor.pageData.templateFullRowTypes')}}
       .q-px-md
         draggable(
           class='q-list rounded-borders'
@@ -49,8 +49,8 @@ q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
           :clone='cloneFieldType'
           :sort='false'
           :animation='150'
-          @start='dragStarted = true'
-          @end='dragStarted = false'
+          @start='state.dragStarted = true'
+          @end='state.dragStarted = false'
           item-key='id'
           )
           template(#item='{element}')
@@ -59,7 +59,7 @@ q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
                 q-icon(:name='element.icon', color='primary')
               q-item-section
                 q-item-label {{element.label}}
-      q-item-label(header) {{$t('editor.pageData.templateKeyValueTypes')}}
+      q-item-label(header) {{t('editor.pageData.templateKeyValueTypes')}}
       .q-px-md.q-pb-md
         draggable(
           class='q-list rounded-borders'
@@ -68,8 +68,8 @@ q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
           :clone='cloneFieldType'
           :sort='false'
           :animation='150'
-          @start='dragStarted = true'
-          @end='dragStarted = false'
+          @start='state.dragStarted = true'
+          @end='state.dragStarted = false'
           item-key='id'
           )
           template(#item='{element}')
@@ -81,21 +81,21 @@ q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
     .col.page-datatmpl-content
       q-scroll-area(
         ref='scrollArea'
-        :thumb-style='thumbStyle'
-        :bar-style='barStyle'
+        :thumb-style='siteStore.thumbStyle'
+        :bar-style='siteStore.barStyle'
         style='height: 100%;'
         )
           .col.page-datatmpl-meta.q-px-md.q-py-md.flex.q-gutter-md
             q-input.col(
               ref='tmplTitleIpt'
-              :label='$t(`editor.pageData.templateTitle`)'
+              :label='t(`editor.pageData.templateTitle`)'
               outlined
               dense
-              v-model='tmpl.label'
+              v-model='state.tmpl.label'
             )
             q-btn.acrylic-btn(
               icon='las la-check'
-              :label='$t(`common.actions.commit`)'
+              :label='t(`common.actions.commit`)'
               no-caps
               flat
               color='positive'
@@ -103,22 +103,22 @@ q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
             )
             q-btn.acrylic-btn(
               icon='las la-trash'
-              :aria-label='$t(`common.actions.delete`)'
+              :aria-label='t(`common.actions.delete`)'
               flat
               color='negative'
               @click='remove'
             )
-          q-item-label(header) {{$t('editor.pageData.templateStructure')}}
+          q-item-label(header) {{t('editor.pageData.templateStructure')}}
           .q-px-md.q-pb-md
-            div(:class='(dragStarted || tmpl.data.length < 1 ? `page-datatmpl-box` : ``)')
-              .text-caption.text-primary.q-pa-md(v-if='tmpl.data.length < 1 && !dragStarted'): em {{$t('editor.pageData.dragDropHint')}}
+            div(:class='(state.dragStarted || state.tmpl.data.length < 1 ? `page-datatmpl-box` : ``)')
+              .text-caption.text-primary.q-pa-md(v-if='state.tmpl.data.length < 1 && !state.dragStarted'): em {{t('editor.pageData.dragDropHint')}}
               draggable(
                 class='q-list rounded-borders'
-                :list='tmpl.data'
+                :list='state.tmpl.data'
                 group='shared'
                 :animation='150'
                 handle='.handle'
-                @end='dragStarted = false'
+                @end='state.dragStarted = false'
                 item-key='id'
                 )
                 template(#item='{element}')
@@ -129,14 +129,14 @@ q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
                       q-icon(:name='element.icon', color='primary')
                     q-item-section
                       q-input(
-                        :label='$t(`editor.pageData.label`)'
+                        :label='t(`editor.pageData.label`)'
                         v-model='element.label'
                         outlined
                         dense
                       )
                     q-item-section(v-if='element.type !== `header`')
                       q-input(
-                        :label='$t(`editor.pageData.uniqueKey`)'
+                        :label='t(`editor.pageData.uniqueKey`)'
                         v-model='element.key'
                         outlined
                         dense
@@ -144,7 +144,7 @@ q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
                     q-item-section(side)
                       q-btn.acrylic-btn(
                         color='negative'
-                        :aria-label='$t(`common.actions.delete`)'
+                        :aria-label='t(`common.actions.delete`)'
                         padding='xs'
                         icon='las la-times'
                         flat
@@ -152,171 +152,194 @@ q-card.page-datatmpl-dialog(style='width: 1100px; max-width: 1100px;')
                       )
           .page-datatmpl-scrollend(ref='scrollAreaEnd')
 
-  .q-pa-md.text-center(v-else-if='templates.length > 0')
-    em.text-grey-6 {{$t('editor.pageData.selectTemplateAbove')}}
+  .q-pa-md.text-center(v-else-if='siteStore.pageDataTemplates.length > 0')
+    em.text-grey-6 {{t('editor.pageData.selectTemplateAbove')}}
   .q-pa-md.text-center(v-else)
-    em.text-grey-6 {{$t('editor.pageData.noTemplate')}}
+    em.text-grey-6 {{t('editor.pageData.noTemplate')}}
 </template>
 
-<script>
-import { get, sync } from 'vuex-pathify'
+<script setup>
 import { v4 as uuid } from 'uuid'
 import { cloneDeep, sortBy } from 'lodash-es'
 import draggable from 'vuedraggable'
+import { useI18n } from 'vue-i18n'
+import { useQuasar } from 'quasar'
+import { nextTick, onMounted, reactive, ref, watch } from 'vue'
 
-export default {
-  props: {
-    editId: {
-      type: String,
-      default: null
-    }
+import { usePageStore } from 'src/stores/page'
+import { useSiteStore } from 'src/stores/site'
+
+// PROPS
+
+const props = defineProps({
+  editId: {
+    type: String,
+    default: null
+  }
+})
+
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const pageStore = usePageStore()
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  selectedTemplateId: null,
+  dragStarted: false,
+  tmpl: null
+})
+
+const inventoryMisc = [
+  {
+    key: 'header',
+    label: t('editor.pageData.fieldTypeHeader'),
+    icon: 'las la-heading'
   },
-  components: {
-    draggable
+  {
+    key: 'image',
+    label: t('editor.pageData.fieldTypeImage'),
+    icon: 'las la-image'
+  }
+]
+
+const inventoryKV = [
+  {
+    key: 'text',
+    label: t('editor.pageData.fieldTypeText'),
+    icon: 'las la-font'
   },
-  data () {
-    return {
-      selectedTemplateId: null,
-      dragStarted: false,
-      tmpl: null
-    }
+  {
+    key: 'number',
+    label: t('editor.pageData.fieldTypeNumber'),
+    icon: 'las la-infinity'
   },
-  computed: {
-    templates: sync('site/pageDataTemplates', false),
-    thumbStyle: get('site/thumbStyle', false),
-    barStyle: get('site/barStyle', false),
-    inventoryMisc () {
-      return [
-        {
-          key: 'header',
-          label: this.$t('editor.pageData.fieldTypeHeader'),
-          icon: 'las la-heading'
-        },
-        {
-          key: 'image',
-          label: this.$t('editor.pageData.fieldTypeImage'),
-          icon: 'las la-image'
-        }
-      ]
-    },
-    inventoryKV () {
-      return [
-        {
-          key: 'text',
-          label: this.$t('editor.pageData.fieldTypeText'),
-          icon: 'las la-font'
-        },
-        {
-          key: 'number',
-          label: this.$t('editor.pageData.fieldTypeNumber'),
-          icon: 'las la-infinity'
-        },
-        {
-          key: 'boolean',
-          label: this.$t('editor.pageData.fieldTypeBoolean'),
-          icon: 'las la-check-square'
-        },
-        {
-          key: 'link',
-          label: this.$t('editor.pageData.fieldTypeLink'),
-          icon: 'las la-link'
-        }
-      ]
-    }
+  {
+    key: 'boolean',
+    label: t('editor.pageData.fieldTypeBoolean'),
+    icon: 'las la-check-square'
   },
-  watch: {
-    dragStarted (newValue) {
-      if (newValue) {
-        this.$nextTick(() => {
-          this.$refs.scrollAreaEnd.scrollIntoView({
-            behavior: 'smooth'
-          })
-        })
-      }
-    },
-    selectedTemplateId (newValue) {
-      this.tmpl = cloneDeep(this.templates.find(t => t.id === this.selectedTemplateId))
+  {
+    key: 'link',
+    label: t('editor.pageData.fieldTypeLink'),
+    icon: 'las la-link'
+  }
+]
+
+// REFS
+
+const scrollAreaEnd = ref(null)
+const tmplTitleIpt = ref(null)
+
+// WATCHERS
+
+watch(() => state.dragStarted, (newValue) => {
+  if (newValue) {
+    nextTick(() => {
+      scrollAreaEnd.value.scrollIntoView({
+        behavior: 'smooth'
+      })
+    })
+  }
+})
+
+watch(() => state.selectedTemplateId, (newValue) => {
+  state.tmpl = cloneDeep(siteStore.pageDataTemplates.find(t => t.id === state.selectedTemplateId))
+})
+
+// METHODS
+
+function cloneFieldType (tp) {
+  return {
+    id: uuid(),
+    type: tp.key,
+    label: '',
+    ...(tp.key !== 'header' ? { key: '' } : {}),
+    icon: tp.icon
+  }
+}
+
+function removeItem (item) {
+  state.tmpl.data = state.tmpl.data.filter(i => i.id !== item.id)
+}
+
+function create () {
+  state.tmpl = {
+    id: uuid(),
+    label: t('editor.pageData.templateUntitled'),
+    data: []
+  }
+  nextTick(() => {
+    tmplTitleIpt.value.focus()
+    nextTick(() => {
+      document.execCommand('selectall')
+    })
+  })
+}
+
+function commit () {
+  try {
+    if (state.tmpl.label.length < 1) {
+      throw new Error(t('editor.pageData.invalidTemplateName'))
+    } else if (state.tmpl.data.length < 1) {
+      throw new Error(t('editor.pageData.emptyTemplateStructure'))
+    } else if (state.tmpl.data.some(f => f.label.length < 1)) {
+      throw new Error(t('editor.pageData.invalidTemplateLabels'))
+    } else if (state.tmpl.data.some(f => f.type !== 'header' && f.key.length < 1)) {
+      throw new Error(t('editor.pageData.invalidTemplateKeys'))
     }
-  },
-  mounted () {
-    if (this.templates.length > 0) {
-      this.tmpl = this.templates[0]
-      this.selectedTemplateId = this.tmpl.id
-    } else {
-      this.create()
+
+    const keys = state.tmpl.data.filter(f => f.type !== 'header').map(f => f.key)
+    if ((new Set(keys)).size !== keys.length) {
+      throw new Error(t('editor.pageData.duplicateTemplateKeys'))
     }
-  },
-  methods: {
-    cloneFieldType (tp) {
-      return {
-        id: uuid(),
-        type: tp.key,
-        label: '',
-        ...(tp.key !== 'header' ? { key: '' } : {}),
-        icon: tp.icon
-      }
-    },
-    removeItem (item) {
-      this.tmpl.data = this.tmpl.data.filter(i => i.id !== item.id)
-    },
-    create () {
-      this.tmpl = {
-        id: uuid(),
-        label: this.$t('editor.pageData.templateUntitled'),
-        data: []
-      }
-      this.$nextTick(() => {
-        this.$refs.tmplTitleIpt.focus()
-        this.$nextTick(() => {
-          document.execCommand('selectall')
-        })
-      })
-    },
-    commit () {
-      try {
-        if (this.tmpl.label.length < 1) {
-          throw new Error(this.$t('editor.pageData.invalidTemplateName'))
-        } else if (this.tmpl.data.length < 1) {
-          throw new Error(this.$t('editor.pageData.emptyTemplateStructure'))
-        } else if (this.tmpl.data.some(f => f.label.length < 1)) {
-          throw new Error(this.$t('editor.pageData.invalidTemplateLabels'))
-        } else if (this.tmpl.data.some(f => f.type !== 'header' && f.key.length < 1)) {
-          throw new Error(this.$t('editor.pageData.invalidTemplateKeys'))
-        }
-
-        const keys = this.tmpl.data.filter(f => f.type !== 'header').map(f => f.key)
-        if ((new Set(keys)).size !== keys.length) {
-          throw new Error(this.$t('editor.pageData.duplicateTemplateKeys'))
-        }
-
-        if (this.templates.some(t => t.id === this.tmpl.id)) {
-          this.templates = sortBy([...this.templates.filter(t => t.id !== this.tmpl.id), cloneDeep(this.tmpl)], 'label')
-        } else {
-          this.templates = sortBy([...this.templates, cloneDeep(this.tmpl)], 'label')
-        }
-        this.selectedTemplateId = this.tmpl.id
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
-      }
-    },
-    remove () {
-      this.$q.dialog({
-        title: this.$t('editor.pageData.templateDeleteConfirmTitle'),
-        message: this.$t('editor.pageData.templateDeleteConfirmText'),
-        cancel: true,
-        persistent: true,
-        color: 'negative'
-      }).onOk(() => {
-        this.templates = this.templates.filter(t => t.id !== this.selectedTemplateId)
-        this.selectedTemplateId = null
-        this.tmpl = null
-      })
+
+    if (siteStore.pageDataTemplates.some(t => t.id === state.tmpl.id)) {
+      siteStore.pageDataTemplates = sortBy([...siteStore.pageDataTemplates.filter(t => t.id !== state.tmpl.id), cloneDeep(state.tmpl)], 'label')
+    } else {
+      siteStore.pageDataTemplates = sortBy([...siteStore.pageDataTemplates, cloneDeep(state.tmpl)], 'label')
     }
+    state.selectedTemplateId = state.tmpl.id
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
   }
 }
+function remove () {
+  $q.dialog({
+    title: t('editor.pageData.templateDeleteConfirmTitle'),
+    message: t('editor.pageData.templateDeleteConfirmText'),
+    cancel: true,
+    persistent: true,
+    color: 'negative'
+  }).onOk(() => {
+    siteStore.pageDataTemplates = siteStore.pageDataTemplates.filter(t => t.id !== state.selectedTemplateId)
+    state.selectedTemplateId = null
+    state.tmpl = null
+  })
+}
+
+// MOUNTED
+
+onMounted(() => {
+  if (siteStore.pageDataTemplates.length > 0) {
+    state.tmpl = siteStore.pageDataTemplates[0]
+    state.selectedTemplateId = state.tmpl.id
+  } else {
+    create()
+  }
+})
 </script>
 
 <style lang="scss">

+ 3 - 2
ux/src/components/PagePropertiesDialog.vue

@@ -270,8 +270,6 @@ q-card.page-properties-dialog
 </template>
 
 <script setup>
-import { usePageStore } from 'src/stores/page'
-import { useSiteStore } from 'src/stores/site'
 import { useI18n } from 'vue-i18n'
 import { useQuasar } from 'quasar'
 import { nextTick, onMounted, reactive, ref, watch } from 'vue'
@@ -280,6 +278,9 @@ import PageRelationDialog from './PageRelationDialog.vue'
 import PageScriptsDialog from './PageScriptsDialog.vue'
 import PageTags from './PageTags.vue'
 
+import { usePageStore } from 'src/stores/page'
+import { useSiteStore } from 'src/stores/site'
+
 // QUASAR
 
 const $q = useQuasar()

+ 20 - 63
ux/src/pages/Index.vue

@@ -141,9 +141,11 @@ q-page.column
           q-icon.q-mr-sm(name='las la-stream', color='grey')
           .text-caption.text-grey-7 Contents
         .q-px-md.q-pb-sm
-          q-tree(
-            :nodes='state.toc'
+          q-tree.page-toc(
+            :nodes='pageStore.toc'
+            icon='las la-caret-right'
             node-key='key'
+            dense
             v-model:expanded='state.tocExpanded'
             v-model:selected='state.tocSelected'
           )
@@ -287,6 +289,7 @@ q-page.column
     transition-show='jump-left'
     transition-hide='jump-right'
     class='floating-sidepanel'
+    no-shake
     )
     component(:is='sideDialogs[state.sideDialogComponent]')
 
@@ -354,54 +357,6 @@ const state = reactive({
   globalDialogComponent: null,
   showTagsEditBtn: false,
   tagEditMode: false,
-  toc: [
-    {
-      key: 'h1-0',
-      label: 'Introduction'
-    },
-    {
-      key: 'h1-1',
-      label: 'Planets',
-      children: [
-        {
-          key: 'h2-0',
-          label: 'Earth',
-          children: [
-            {
-              key: 'h3-0',
-              label: 'Countries',
-              children: [
-                {
-                  key: 'h4-0',
-                  label: 'Cities',
-                  children: [
-                    {
-                      key: 'h5-0',
-                      label: 'Montreal',
-                      children: [
-                        {
-                          key: 'h6-0',
-                          label: 'Districts'
-                        }
-                      ]
-                    }
-                  ]
-                }
-              ]
-            }
-          ]
-        },
-        {
-          key: 'h2-1',
-          label: 'Mars'
-        },
-        {
-          key: 'h2-2',
-          label: 'Jupiter'
-        }
-      ]
-    }
-  ],
   tocExpanded: ['h1-0', 'h1-1'],
   tocSelected: [],
   currentRating: 3
@@ -472,8 +427,8 @@ watch(() => route.path, async (newValue) => {
   }
 }, { immediate: true })
 
-watch(() => state.toc, refreshTocExpanded)
-watch(() => pageStore.tocDepth, refreshTocExpanded)
+watch(() => pageStore.toc, () => { refreshTocExpanded() }, { immediate: true })
+watch(() => pageStore.tocDepth, () => { refreshTocExpanded() })
 
 // METHODS
 
@@ -492,20 +447,22 @@ function savePage () {
   state.showGlobalDialog = true
 }
 
-function refreshTocExpanded (baseToc) {
+function refreshTocExpanded (baseToc, lvl) {
+  console.info(pageStore.tocDepth.min, lvl, pageStore.tocDepth.max)
   const toExpand = []
   let isRootNode = false
   if (!baseToc) {
-    baseToc = state.toc
+    baseToc = pageStore.toc
     isRootNode = true
+    lvl = 1
   }
   if (baseToc.length > 0) {
     for (const node of baseToc) {
-      if (node.key >= `h${pageStore.tocDepth.min}` && node.key <= `h${pageStore.tocDepth.max}`) {
+      if (lvl >= pageStore.tocDepth.min && lvl < pageStore.tocDepth.max) {
         toExpand.push(node.key)
       }
-      if (node.children?.length && node.key < `h${pageStore.tocDepth.max}`) {
-        toExpand.push(...refreshTocExpanded(node.children))
+      if (node.children?.length && lvl < pageStore.tocDepth.max - 1) {
+        toExpand.push(...refreshTocExpanded(node.children, lvl + 1))
       }
     }
   }
@@ -515,12 +472,6 @@ function refreshTocExpanded (baseToc) {
     return toExpand
   }
 }
-
-// MOUNTED
-
-onMounted(() => {
-  refreshTocExpanded()
-})
 </script>
 
 <style lang="scss">
@@ -691,4 +642,10 @@ onMounted(() => {
     background-color: $dark-3;
   }
 }
+
+.page-toc {
+  &.q-tree--dense .q-tree__node {
+    padding-bottom: 5px;
+  }
+}
 </style>

+ 6 - 1
ux/src/stores/page.js

@@ -80,7 +80,8 @@ export const usePageStore = defineStore('page', {
     },
     commentsCount: 0,
     content: '',
-    render: ''
+    render: '',
+    toc: []
   }),
   getters: {},
   actions: {
@@ -93,9 +94,11 @@ export const usePageStore = defineStore('page', {
         const resp = await APOLLO_CLIENT.query({
           query: gql`
             query loadPage (
+              $siteId: UUID!
               $path: String!
             ) {
               pageByPath(
+                siteId: $siteId
                 path: $path
               ) {
                 id
@@ -105,10 +108,12 @@ export const usePageStore = defineStore('page', {
                 locale
                 updatedAt
                 render
+                toc
               }
             }
           `,
           variables: {
+            siteId: siteStore.id,
             path
           },
           fetchPolicy: 'network-only'