feat: visualize pages (dendograms)
This commit is contained in:
@@ -152,12 +152,13 @@ const router = new VueRouter({
|
|||||||
{ path: '/locale', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-locale.vue') },
|
{ path: '/locale', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-locale.vue') },
|
||||||
{ path: '/navigation', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-navigation.vue') },
|
{ path: '/navigation', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-navigation.vue') },
|
||||||
{ path: '/pages', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-pages.vue') },
|
{ path: '/pages', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-pages.vue') },
|
||||||
{ path: '/pages/:id', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-pages-edit.vue') },
|
{ path: '/pages/:id(\\d+)', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-pages-edit.vue') },
|
||||||
|
{ path: '/pages/visualize', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-pages-visualize.vue') },
|
||||||
{ path: '/theme', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-theme.vue') },
|
{ path: '/theme', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-theme.vue') },
|
||||||
{ path: '/groups', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-groups.vue') },
|
{ path: '/groups', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-groups.vue') },
|
||||||
{ path: '/groups/:id', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-groups-edit.vue') },
|
{ path: '/groups/:id(\\d+)', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-groups-edit.vue') },
|
||||||
{ path: '/users', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-users.vue') },
|
{ path: '/users', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-users.vue') },
|
||||||
{ path: '/users/:id', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-users-edit.vue') },
|
{ path: '/users/:id(\\d+)', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-users-edit.vue') },
|
||||||
{ path: '/analytics', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-analytics.vue') },
|
{ path: '/analytics', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-analytics.vue') },
|
||||||
{ path: '/auth', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-auth.vue') },
|
{ path: '/auth', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-auth.vue') },
|
||||||
{ path: '/rendering', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-rendering.vue') },
|
{ path: '/rendering', component: () => import(/* webpackChunkName: "admin" */ './admin/admin-rendering.vue') },
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
<template lang='pug'>
|
||||||
|
v-container(fluid, grid-list-lg)
|
||||||
|
v-layout(row wrap)
|
||||||
|
v-flex(xs12)
|
||||||
|
.admin-header
|
||||||
|
img.animated.fadeInUp(src='/svg/icon-venn-diagram.svg', alt='Visualize Pages', style='width: 80px;')
|
||||||
|
.admin-header-title
|
||||||
|
.headline.blue--text.text--darken-2.animated.fadeInLeft Visualize Pages
|
||||||
|
.subtitle-1.grey--text.animated.fadeInLeft.wait-p2s Dendrogram representation of your pages
|
||||||
|
v-spacer
|
||||||
|
v-select.mx-5.animated.fadeInDown.wait-p1s(
|
||||||
|
v-if='locales.length > 0'
|
||||||
|
v-model='currentLocale'
|
||||||
|
:items='locales'
|
||||||
|
style='flex: 0 1 120px;'
|
||||||
|
solo
|
||||||
|
dense
|
||||||
|
hide-details
|
||||||
|
item-value='code'
|
||||||
|
item-text='name'
|
||||||
|
)
|
||||||
|
v-btn-toggle.animated.fadeInDown(v-model='graphMode', color='primary', dense, rounded)
|
||||||
|
v-btn.px-5(value='htree')
|
||||||
|
v-icon(left, :color='graphMode === `htree` ? `primary` : `grey darken-3`') mdi-sitemap
|
||||||
|
span.text-none Hierarchical Tree
|
||||||
|
v-btn.px-5(value='hradial')
|
||||||
|
v-icon(left, :color='graphMode === `hradial` ? `primary` : `grey darken-3`') mdi-chart-donut-variant
|
||||||
|
span.text-none Hierarchical Radial
|
||||||
|
v-btn.px-5(value='rradial')
|
||||||
|
v-icon(left, :color='graphMode === `rradial` ? `primary` : `grey darken-3`') mdi-blur-radial
|
||||||
|
span.text-none Relational Radial
|
||||||
|
v-chip.ml-3(x-small) Beta
|
||||||
|
.admin-pages-visualize-svg.pa-10(ref='svgContainer')
|
||||||
|
v-alert(v-if='pages.length < 1', outlined, type='warning', style='max-width: 650px; margin: 0 auto;') Looks like there's no data yet to graph!
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import _ from 'lodash'
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
|
||||||
|
/* global siteConfig, siteLangs */
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
graphMode: 'htree',
|
||||||
|
width: 800,
|
||||||
|
radius: 400,
|
||||||
|
pages: [],
|
||||||
|
locales: siteLangs,
|
||||||
|
currentLocale: siteConfig.lang
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
pages () {
|
||||||
|
this.redraw()
|
||||||
|
},
|
||||||
|
graphMode () {
|
||||||
|
this.redraw()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
bilink (root) {
|
||||||
|
const map = new Map(root.leaves().map(d => [d.data.path, d]))
|
||||||
|
for (const d of root.leaves()) {
|
||||||
|
d.incoming = []
|
||||||
|
d.outgoing = []
|
||||||
|
d.data.links.forEach(i => {
|
||||||
|
const relNode = map.get(i)
|
||||||
|
if (relNode) {
|
||||||
|
d.outgoing.push([d, relNode])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const d of root.leaves()) {
|
||||||
|
for (const o of d.outgoing) {
|
||||||
|
if (o[1]) {
|
||||||
|
o[1].incoming.push(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
},
|
||||||
|
hierarchy (data, rootOnly = false) {
|
||||||
|
let result = []
|
||||||
|
let level = { result }
|
||||||
|
const map = new Map(data.map(d => [d.path, d]))
|
||||||
|
data.forEach(d => {
|
||||||
|
const pathParts = d.path.split('/')
|
||||||
|
pathParts.reduce((r, part, i) => {
|
||||||
|
const curPath = _.take(pathParts, i + 1).join('/')
|
||||||
|
if (!r[part]) {
|
||||||
|
r[part] = { result: [] }
|
||||||
|
const page = map.get(curPath)
|
||||||
|
r.result.push(page ? {
|
||||||
|
...d,
|
||||||
|
children: r[part].result
|
||||||
|
} : {
|
||||||
|
title: part,
|
||||||
|
links: [],
|
||||||
|
path: curPath,
|
||||||
|
children: r[part].result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return r[part]
|
||||||
|
}, level)
|
||||||
|
})
|
||||||
|
|
||||||
|
return rootOnly ? _.head(result) || { children: [] } : {
|
||||||
|
children: result
|
||||||
|
}
|
||||||
|
},
|
||||||
|
drawRelations () {
|
||||||
|
const data = this.hierarchy(this.pages)
|
||||||
|
|
||||||
|
const line = d3.lineRadial()
|
||||||
|
.curve(d3.curveBundle.beta(0.85))
|
||||||
|
.radius(d => d.y)
|
||||||
|
.angle(d => d.x)
|
||||||
|
|
||||||
|
const tree = d3.cluster()
|
||||||
|
.size([2 * Math.PI, this.radius - 100])
|
||||||
|
|
||||||
|
const root = tree(this.bilink(d3.hierarchy(data)
|
||||||
|
.sort((a, b) => d3.ascending(a.height, b.height) || d3.ascending(a.data.title, b.data.title))))
|
||||||
|
|
||||||
|
const svg = d3.create('svg')
|
||||||
|
.attr('viewBox', [-this.width / 2, -this.width / 2, this.width, this.width])
|
||||||
|
|
||||||
|
svg.append('g')
|
||||||
|
.attr('font-family', 'sans-serif')
|
||||||
|
.attr('font-size', 10)
|
||||||
|
.selectAll('g')
|
||||||
|
.data(root.leaves())
|
||||||
|
.join('g')
|
||||||
|
.attr('transform', d => `rotate(${d.x * 180 / Math.PI - 90}) translate(${d.y},0)`)
|
||||||
|
.append('text')
|
||||||
|
.attr('dy', '0.31em')
|
||||||
|
.attr('x', d => d.x < Math.PI ? 6 : -6)
|
||||||
|
.attr('text-anchor', d => d.x < Math.PI ? 'start' : 'end')
|
||||||
|
.attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
|
||||||
|
.attr('fill', this.$vuetify.theme.dark ? 'white' : '')
|
||||||
|
.text(d => d.data.title)
|
||||||
|
.each(function(d) { d.text = this })
|
||||||
|
.on('mouseover', overed)
|
||||||
|
.on('mouseout', outed)
|
||||||
|
.call(text => text.append('title').text(d => `${d.data.path}
|
||||||
|
${d.outgoing.length} outgoing
|
||||||
|
${d.incoming.length} incoming`))
|
||||||
|
|
||||||
|
const link = svg.append('g')
|
||||||
|
.attr('stroke', '#CCC')
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.selectAll('path')
|
||||||
|
.data(root.leaves().flatMap(leaf => leaf.outgoing))
|
||||||
|
.join('path')
|
||||||
|
.style('mix-blend-mode', 'multiply')
|
||||||
|
.attr('d', ([i, o]) => line(i.path(o)))
|
||||||
|
.each(function(d) { d.path = this })
|
||||||
|
|
||||||
|
function overed(d) {
|
||||||
|
link.style('mix-blend-mode', null)
|
||||||
|
d3.select(this).attr('font-weight', 'bold')
|
||||||
|
d3.selectAll(d.incoming.map(d => d.path)).attr('stroke', '#2196F3').raise()
|
||||||
|
d3.selectAll(d.incoming.map(([d]) => d.text)).attr('fill', '#2196F3').attr('font-weight', 'bold')
|
||||||
|
d3.selectAll(d.outgoing.map(d => d.path)).attr('stroke', '#E91E63').raise()
|
||||||
|
d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr('fill', '#E91E63').attr('font-weight', 'bold')
|
||||||
|
}
|
||||||
|
|
||||||
|
function outed(d) {
|
||||||
|
link.style('mix-blend-mode', 'multiply')
|
||||||
|
d3.select(this).attr('font-weight', null)
|
||||||
|
d3.selectAll(d.incoming.map(d => d.path)).attr('stroke', null)
|
||||||
|
d3.selectAll(d.incoming.map(([d]) => d.text)).attr('fill', null).attr('font-weight', null)
|
||||||
|
d3.selectAll(d.outgoing.map(d => d.path)).attr('stroke', null)
|
||||||
|
d3.selectAll(d.outgoing.map(([, d]) => d.text)).attr('fill', null).attr('font-weight', null)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$refs.svgContainer.appendChild(svg.node())
|
||||||
|
},
|
||||||
|
drawTree () {
|
||||||
|
const data = this.hierarchy(this.pages, true)
|
||||||
|
|
||||||
|
const treeRoot = d3.hierarchy(data)
|
||||||
|
treeRoot.dx = 10
|
||||||
|
treeRoot.dy = this.width / (treeRoot.height + 1)
|
||||||
|
const root = d3.tree().nodeSize([treeRoot.dx, treeRoot.dy])(treeRoot)
|
||||||
|
|
||||||
|
let x0 = Infinity
|
||||||
|
let x1 = -x0
|
||||||
|
root.each(d => {
|
||||||
|
if (d.x > x1) x1 = d.x
|
||||||
|
if (d.x < x0) x0 = d.x
|
||||||
|
})
|
||||||
|
|
||||||
|
const svg = d3.create('svg')
|
||||||
|
.attr('viewBox', [0, 0, this.width, x1 - x0 + root.dx * 2])
|
||||||
|
|
||||||
|
const g = svg.append('g')
|
||||||
|
.attr('font-family', 'sans-serif')
|
||||||
|
.attr('font-size', 10)
|
||||||
|
.attr('transform', `translate(${root.dy / 3},${root.dx - x0})`)
|
||||||
|
|
||||||
|
g.append('g')
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', this.$vuetify.theme.dark ? '#999' : '#555')
|
||||||
|
.attr('stroke-opacity', 0.4)
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
.selectAll('path')
|
||||||
|
.data(root.links())
|
||||||
|
.join('path')
|
||||||
|
.attr('d', d3.linkHorizontal()
|
||||||
|
.x(d => d.y)
|
||||||
|
.y(d => d.x))
|
||||||
|
|
||||||
|
const node = g.append('g')
|
||||||
|
.attr('stroke-linejoin', 'round')
|
||||||
|
.attr('stroke-width', 3)
|
||||||
|
.selectAll('g')
|
||||||
|
.data(root.descendants())
|
||||||
|
.join('g')
|
||||||
|
.attr('transform', d => `translate(${d.y},${d.x})`)
|
||||||
|
|
||||||
|
node.append('circle')
|
||||||
|
.attr('fill', d => d.children ? '#555' : '#999')
|
||||||
|
.attr('r', 2.5)
|
||||||
|
|
||||||
|
node.append('text')
|
||||||
|
.attr('dy', '0.31em')
|
||||||
|
.attr('x', d => d.children ? -6 : 6)
|
||||||
|
.attr('text-anchor', d => d.children ? 'end' : 'start')
|
||||||
|
.attr('fill', this.$vuetify.theme.dark ? 'white' : '')
|
||||||
|
.text(d => d.data.title)
|
||||||
|
.clone(true).lower()
|
||||||
|
.attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
|
||||||
|
|
||||||
|
this.$refs.svgContainer.appendChild(svg.node())
|
||||||
|
},
|
||||||
|
drawRadialTree () {
|
||||||
|
const data = this.hierarchy(this.pages)
|
||||||
|
|
||||||
|
const tree = d3.tree()
|
||||||
|
.size([2 * Math.PI, this.radius])
|
||||||
|
.separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth)
|
||||||
|
|
||||||
|
const root = tree(d3.hierarchy(data)
|
||||||
|
.sort((a, b) => d3.ascending(a.data.title, b.data.title)))
|
||||||
|
|
||||||
|
const svg = d3.create('svg')
|
||||||
|
.style('font', '10px sans-serif')
|
||||||
|
|
||||||
|
svg.append('g')
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', this.$vuetify.theme.dark ? 'white' : '#555')
|
||||||
|
.attr('stroke-opacity', 0.4)
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
.selectAll('path')
|
||||||
|
.data(root.links())
|
||||||
|
.join('path')
|
||||||
|
.attr('d', d3.linkRadial()
|
||||||
|
.angle(d => d.x)
|
||||||
|
.radius(d => d.y))
|
||||||
|
|
||||||
|
const node = svg.append('g')
|
||||||
|
.attr('stroke-linejoin', 'round')
|
||||||
|
.attr('stroke-width', 3)
|
||||||
|
.selectAll('g')
|
||||||
|
.data(root.descendants().reverse())
|
||||||
|
.join('g')
|
||||||
|
.attr('transform', d => `
|
||||||
|
rotate(${d.x * 180 / Math.PI - 90})
|
||||||
|
translate(${d.y},0)
|
||||||
|
`)
|
||||||
|
|
||||||
|
node.append('circle')
|
||||||
|
.attr('fill', d => d.children ? '#555' : '#999')
|
||||||
|
.attr('r', 2.5)
|
||||||
|
|
||||||
|
node.append('text')
|
||||||
|
.attr('dy', '0.31em')
|
||||||
|
/* eslint-disable no-mixed-operators */
|
||||||
|
.attr('x', d => d.x < Math.PI === !d.children ? 6 : -6)
|
||||||
|
.attr('text-anchor', d => d.x < Math.PI === !d.children ? 'start' : 'end')
|
||||||
|
.attr('transform', d => d.x >= Math.PI ? 'rotate(180)' : null)
|
||||||
|
/* eslint-enable no-mixed-operators */
|
||||||
|
.attr('fill', this.$vuetify.theme.dark ? 'white' : '')
|
||||||
|
.text(d => d.data.title)
|
||||||
|
.clone(true).lower()
|
||||||
|
.attr('stroke', this.$vuetify.theme.dark ? '#222' : 'white')
|
||||||
|
|
||||||
|
this.$refs.svgContainer.appendChild(svg.node())
|
||||||
|
|
||||||
|
function autoBox() {
|
||||||
|
const {x, y, width, height} = this.getBBox()
|
||||||
|
return [x, y, width, height]
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.attr('viewBox', autoBox)
|
||||||
|
},
|
||||||
|
redraw () {
|
||||||
|
while (this.$refs.svgContainer.firstChild) {
|
||||||
|
this.$refs.svgContainer.firstChild.remove()
|
||||||
|
}
|
||||||
|
if (this.pages.length > 0) {
|
||||||
|
switch (this.graphMode) {
|
||||||
|
case 'rradial':
|
||||||
|
this.drawRelations()
|
||||||
|
break
|
||||||
|
case 'htree':
|
||||||
|
this.drawTree()
|
||||||
|
break
|
||||||
|
case 'hradial':
|
||||||
|
this.drawRadialTree()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
apollo: {
|
||||||
|
pages: {
|
||||||
|
query: gql`
|
||||||
|
query ($locale: String!) {
|
||||||
|
pages {
|
||||||
|
links(locale: $locale) {
|
||||||
|
id
|
||||||
|
path
|
||||||
|
title
|
||||||
|
links
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
variables () {
|
||||||
|
return {
|
||||||
|
locale: this.currentLocale
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetchPolicy: 'network-only',
|
||||||
|
update: (data) => data.pages.links,
|
||||||
|
watchLoading (isLoading) {
|
||||||
|
this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-pages-refresh')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang='scss'>
|
||||||
|
.admin-pages-visualize-svg {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
v-btn.animated.fadeInDown.mx-3(color='primary', outlined, large, @click='recyclebin', disabled)
|
v-btn.animated.fadeInDown.mx-3(color='primary', outlined, large, @click='recyclebin', disabled)
|
||||||
v-icon(left) mdi-delete-outline
|
v-icon(left) mdi-delete-outline
|
||||||
span Recycle Bin
|
span Recycle Bin
|
||||||
v-btn.animated.fadeInDown(color='primary', depressed, large, @click='newpage', disabled)
|
v-btn.animated.fadeInDown(color='primary', depressed, large, to='pages/visualize')
|
||||||
v-icon(left) mdi-plus
|
v-icon(left) mdi-graph
|
||||||
span New Page
|
span Visualize
|
||||||
v-card.wiki-form.mt-3.animated.fadeInUp
|
v-card.wiki-form.mt-3.animated.fadeInUp
|
||||||
v-toolbar(flat, :color='$vuetify.theme.dark ? `grey darken-3-d5` : `grey lighten-5`', height='80')
|
v-toolbar(flat, :color='$vuetify.theme.dark ? `grey darken-3-d5` : `grey lighten-5`', height='80')
|
||||||
v-spacer
|
v-spacer
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
v-tabs(color='white', background-color='blue darken-1', dark, centered)
|
v-tabs(color='white', background-color='blue darken-1', dark, centered)
|
||||||
v-tab {{$t('editor:props.info')}}
|
v-tab {{$t('editor:props.info')}}
|
||||||
v-tab {{$t('editor:props.scheduling')}}
|
v-tab {{$t('editor:props.scheduling')}}
|
||||||
|
v-tab(disabled) {{$t('editor:props.scripts')}}
|
||||||
v-tab {{$t('editor:props.social')}}
|
v-tab {{$t('editor:props.social')}}
|
||||||
v-tab-item
|
v-tab-item
|
||||||
v-card-text.pt-5
|
v-card-text.pt-5
|
||||||
@@ -177,6 +178,25 @@
|
|||||||
@click='$refs.menuPublishEnd.save(publishEndDate)'
|
@click='$refs.menuPublishEnd.save(publishEndDate)'
|
||||||
) {{$t('common:actions.ok')}}
|
) {{$t('common:actions.ok')}}
|
||||||
|
|
||||||
|
v-tab-item
|
||||||
|
v-card-text
|
||||||
|
.overline.pb-3 {{$t('editor:props.js')}}
|
||||||
|
v-textarea(
|
||||||
|
outlined
|
||||||
|
rows='5'
|
||||||
|
:hint='$t(`editor:props.jsHint`)'
|
||||||
|
persistent-hint
|
||||||
|
)
|
||||||
|
v-divider
|
||||||
|
v-card-text.grey.pt-5(:class='darkMode ? `darken-3-d3` : `lighten-5`')
|
||||||
|
.overline.pb-3 {{$t('editor:props.css')}}
|
||||||
|
v-textarea(
|
||||||
|
outlined
|
||||||
|
rows='5'
|
||||||
|
:hint='$t(`editor:props.cssHint`)'
|
||||||
|
persistent-hint
|
||||||
|
)
|
||||||
|
|
||||||
v-tab-item
|
v-tab-item
|
||||||
v-card-text
|
v-card-text
|
||||||
.overline.pb-5 {{$t('editor:props.socialFeatures')}} #[v-chip.ml-3(label, color='grey', small, outlined).white--text coming soon]
|
.overline.pb-5 {{$t('editor:props.socialFeatures')}} #[v-chip.ml-3(label, color='grey', small, outlined).white--text coming soon]
|
||||||
|
|||||||
@@ -215,6 +215,7 @@
|
|||||||
"core-js": "3.6.1",
|
"core-js": "3.6.1",
|
||||||
"css-loader": "3.4.0",
|
"css-loader": "3.4.0",
|
||||||
"cssnano": "4.1.10",
|
"cssnano": "4.1.10",
|
||||||
|
"d3": "5.15.0",
|
||||||
"duplicate-package-checker-webpack-plugin": "3.0.0",
|
"duplicate-package-checker-webpack-plugin": "3.0.0",
|
||||||
"epic-spinners": "1.1.0",
|
"epic-spinners": "1.1.0",
|
||||||
"eslint": "6.8.0",
|
"eslint": "6.8.0",
|
||||||
|
|||||||
@@ -137,8 +137,12 @@ module.exports = {
|
|||||||
async tree (obj, args, context, info) {
|
async tree (obj, args, context, info) {
|
||||||
let results = []
|
let results = []
|
||||||
let conds = {
|
let conds = {
|
||||||
localeCode: args.locale,
|
localeCode: args.locale
|
||||||
parent: (args.parent < 1) ? null : args.parent
|
}
|
||||||
|
if (args.parent) {
|
||||||
|
conds.parent = (args.parent < 1) ? null : args.parent
|
||||||
|
} else if (args.path) {
|
||||||
|
// conds.parent = (args.parent < 1) ? null : args.parent
|
||||||
}
|
}
|
||||||
switch (args.mode) {
|
switch (args.mode) {
|
||||||
case 'FOLDERS':
|
case 'FOLDERS':
|
||||||
@@ -162,6 +166,44 @@ module.exports = {
|
|||||||
parent: r.parent || 0,
|
parent: r.parent || 0,
|
||||||
locale: r.localeCode
|
locale: r.localeCode
|
||||||
}))
|
}))
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* FETCH PAGE LINKS
|
||||||
|
*/
|
||||||
|
async links (obj, args, context, info) {
|
||||||
|
let results = []
|
||||||
|
|
||||||
|
results = await WIKI.models.knex('pages')
|
||||||
|
.column({ id: 'pages.id' }, { path: 'pages.path' }, 'title', { link: 'pageLinks.path' }, { locale: 'pageLinks.localeCode' })
|
||||||
|
.fullOuterJoin('pageLinks', 'pages.id', 'pageLinks.pageId')
|
||||||
|
.where({
|
||||||
|
'pages.localeCode': args.locale
|
||||||
|
})
|
||||||
|
|
||||||
|
return _.reduce(results, (result, val) => {
|
||||||
|
// -> Check if user has access to source and linked page
|
||||||
|
if (
|
||||||
|
!WIKI.auth.checkAccess(context.req.user, ['read:pages'], { path: val.path, locale: args.locale }) ||
|
||||||
|
!WIKI.auth.checkAccess(context.req.user, ['read:pages'], { path: val.link, locale: val.locale })
|
||||||
|
) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntry = _.findIndex(result, ['id', val.id])
|
||||||
|
if (existingEntry >= 0) {
|
||||||
|
if (val.link) {
|
||||||
|
result[existingEntry].links.push(`${val.locale}/${val.link}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push({
|
||||||
|
id: val.id,
|
||||||
|
title: val.title,
|
||||||
|
path: `${args.locale}/${val.path}`,
|
||||||
|
links: val.link ? [`${val.locale}/${val.link}`] : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
PageMutation: {
|
PageMutation: {
|
||||||
|
|||||||
@@ -42,10 +42,16 @@ type PageQuery {
|
|||||||
tags: [PageTag]! @auth(requires: ["manage:system", "read:pages"])
|
tags: [PageTag]! @auth(requires: ["manage:system", "read:pages"])
|
||||||
|
|
||||||
tree(
|
tree(
|
||||||
parent: Int!
|
path: String
|
||||||
|
parent: Int
|
||||||
mode: PageTreeMode!
|
mode: PageTreeMode!
|
||||||
locale: String!
|
locale: String!
|
||||||
|
includeParents: Boolean
|
||||||
): [PageTreeItem] @auth(requires: ["manage:system", "read:pages"])
|
): [PageTreeItem] @auth(requires: ["manage:system", "read:pages"])
|
||||||
|
|
||||||
|
links(
|
||||||
|
locale: String!
|
||||||
|
): [PageLinkItem] @auth(requires: ["manage:system", "read:pages"])
|
||||||
}
|
}
|
||||||
|
|
||||||
# -----------------------------------------------
|
# -----------------------------------------------
|
||||||
@@ -209,6 +215,13 @@ type PageTreeItem {
|
|||||||
locale: String!
|
locale: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PageLinkItem {
|
||||||
|
id: Int!
|
||||||
|
path: String!
|
||||||
|
title: String!
|
||||||
|
links: [String]!
|
||||||
|
}
|
||||||
|
|
||||||
enum PageOrderBy {
|
enum PageOrderBy {
|
||||||
CREATED
|
CREATED
|
||||||
ID
|
ID
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const crypto = require('crypto')
|
|||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
|
||||||
const localeSegmentRegex = /^[A-Z]{2}(-[A-Z]{2})?$/i
|
const localeSegmentRegex = /^[A-Z]{2}(-[A-Z]{2})?$/i
|
||||||
const localeFolderRegex = /^([a-z]{2}(?:-[a-z]{2})?)\/?(.*)/i
|
const localeFolderRegex = /^([a-z]{2}(?:-[a-z]{2})?\/)?(.*)/i
|
||||||
|
|
||||||
const contentToExt = {
|
const contentToExt = {
|
||||||
markdown: 'md',
|
markdown: 'md',
|
||||||
@@ -125,7 +125,7 @@ module.exports = {
|
|||||||
const result = localeFolderRegex.exec(meta.path)
|
const result = localeFolderRegex.exec(meta.path)
|
||||||
if (result[1]) {
|
if (result[1]) {
|
||||||
meta = {
|
meta = {
|
||||||
locale: result[1],
|
locale: result[1].replace('/', ''),
|
||||||
path: result[2]
|
path: result[2]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async processPage ({ user, fullPath, relPath, contentType, moduleName }) {
|
async processPage ({ user, fullPath, relPath, contentType, moduleName }) {
|
||||||
const contentPath = pageHelper.getPagePath(relPath)
|
const normalizedRelPath = relPath.replace(/\\/g, '/')
|
||||||
|
const contentPath = pageHelper.getPagePath(normalizedRelPath)
|
||||||
const itemContents = await fs.readFile(path.join(fullPath, relPath), 'utf8')
|
const itemContents = await fs.readFile(path.join(fullPath, relPath), 'utf8')
|
||||||
const pageData = WIKI.models.pages.parseMetadata(itemContents, contentType)
|
const pageData = WIKI.models.pages.parseMetadata(itemContents, contentType)
|
||||||
const currentPage = await WIKI.models.pages.getPageFromDb({
|
const currentPage = await WIKI.models.pages.getPageFromDb({
|
||||||
@@ -82,7 +83,7 @@ module.exports = {
|
|||||||
const newTags = !_.isNil(pageData.tags) ? _.get(pageData, 'tags', '').split(', ') : false
|
const newTags = !_.isNil(pageData.tags) ? _.get(pageData, 'tags', '').split(', ') : false
|
||||||
if (currentPage) {
|
if (currentPage) {
|
||||||
// Already in the DB, can mark as modified
|
// Already in the DB, can mark as modified
|
||||||
WIKI.logger.info(`(STORAGE/${moduleName}) Page marked as modified: ${relPath}`)
|
WIKI.logger.info(`(STORAGE/${moduleName}) Page marked as modified: ${normalizedRelPath}`)
|
||||||
await WIKI.models.pages.updatePage({
|
await WIKI.models.pages.updatePage({
|
||||||
id: currentPage.id,
|
id: currentPage.id,
|
||||||
title: _.get(pageData, 'title', currentPage.title),
|
title: _.get(pageData, 'title', currentPage.title),
|
||||||
@@ -96,7 +97,7 @@ module.exports = {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Not in the DB, can mark as new
|
// Not in the DB, can mark as new
|
||||||
WIKI.logger.info(`(STORAGE/${moduleName}) Page marked as new: ${relPath}`)
|
WIKI.logger.info(`(STORAGE/${moduleName}) Page marked as new: ${normalizedRelPath}`)
|
||||||
const pageEditor = await WIKI.models.editors.getDefaultEditor(contentType)
|
const pageEditor = await WIKI.models.editors.getDefaultEditor(contentType)
|
||||||
await WIKI.models.pages.createPage({
|
await WIKI.models.pages.createPage({
|
||||||
path: contentPath.path,
|
path: contentPath.path,
|
||||||
|
|||||||
Reference in New Issue
Block a user