Caching + Edit Mode UI
This commit is contained in:
parent
1d2893765c
commit
4be54310c4
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
"use strict";function _classCallCheck(e,s){if(!(e instanceof s))throw new TypeError("Cannot call a class as a function")}var _createClass=function(){function e(e,s){for(var t=0;t<s.length;t++){var n=s[t];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}return function(s,t,n){return t&&e(s.prototype,t),n&&e(s,n),s}}(),Alerts=function(){function e(){_classCallCheck(this,e);var s=this;s.mdl=new Vue({el:"#alerts",data:{children:[]},methods:{acknowledge:function(e){s.close(e)}}}),s.uidNext=1}return _createClass(e,[{key:"push",value:function(e){var s=this,t=_.defaults(e,{_uid:s.uidNext,class:"is-info",message:"---",sticky:!1,title:"---"});s.mdl.children.push(t),t.sticky||_.delay(function(){s.close(t._uid)},5e3),s.uidNext++}},{key:"pushError",value:function(e,s){this.push({class:"is-danger",message:s,sticky:!1,title:e})}},{key:"pushSuccess",value:function(e,s){this.push({class:"is-success",message:s,sticky:!1,title:e})}},{key:"close",value:function(e){var s=this,t=_.findIndex(s.mdl.children,["_uid",e]),n=_.nth(s.mdl.children,t);t>=0&&n&&(n.class+=" exit",s.mdl.children.$set(t,n),_.delay(function(){s.mdl.children.$remove(n)},500))}}]),e}();jQuery(document).ready(function(e){e("a").smoothScroll({speed:400,offset:-20});var s=(new Sticky(".stickyscroll"),new Alerts);alertsData&&_.forEach(alertsData,function(e){s.push(e)})});
|
||||
"use strict";function _classCallCheck(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}var _createClass=function(){function e(e,t){for(var n=0;n<t.length;n++){var s=t[n];s.enumerable=s.enumerable||!1,s.configurable=!0,"value"in s&&(s.writable=!0),Object.defineProperty(e,s.key,s)}}return function(t,n,s){return n&&e(t.prototype,n),s&&e(t,s),t}}(),Alerts=function(){function e(){_classCallCheck(this,e);var t=this;t.mdl=new Vue({el:"#alerts",data:{children:[]},methods:{acknowledge:function(e){t.close(e)}}}),t.uidNext=1}return _createClass(e,[{key:"push",value:function(e){var t=this,n=_.defaults(e,{_uid:t.uidNext,class:"is-info",message:"---",sticky:!1,title:"---"});t.mdl.children.push(n),n.sticky||_.delay(function(){t.close(n._uid)},5e3),t.uidNext++}},{key:"pushError",value:function(e,t){this.push({class:"is-danger",message:t,sticky:!1,title:e})}},{key:"pushSuccess",value:function(e,t){this.push({class:"is-success",message:t,sticky:!1,title:e})}},{key:"close",value:function(e){var t=this,n=_.findIndex(t.mdl.children,["_uid",e]),s=_.nth(t.mdl.children,n);n>=0&&s&&(s.class+=" exit",t.mdl.children.$set(n,s),_.delay(function(){t.mdl.children.$remove(s)},500))}}]),e}();jQuery(document).ready(function(e){e("a").smoothScroll({speed:400,offset:-20});var t=(new Sticky(".stickyscroll"),new Alerts);if(alertsData&&_.forEach(alertsData,function(e){t.push(e)}),1===e("#mk-editor").length){new SimpleMDE({autofocus:!0,element:e("#mk-editor").get(0),autoDownloadFontAwesome:!1,placeholder:"Enter Markdown formatted content here...",hideIcons:["heading","quote"],showIcons:["strikethrough","heading-1","heading-2","heading-3","code","table","horizontal-rule"],spellChecker:!1})}});
|
File diff suppressed because one or more lines are too long
@ -2,6 +2,8 @@
|
||||
|
||||
jQuery( document ).ready(function( $ ) {
|
||||
|
||||
// Scroll
|
||||
|
||||
$('a').smoothScroll({
|
||||
speed: 400,
|
||||
offset: -20
|
||||
@ -9,6 +11,8 @@ jQuery( document ).ready(function( $ ) {
|
||||
|
||||
var sticky = new Sticky('.stickyscroll');
|
||||
|
||||
// Alerts
|
||||
|
||||
var alerts = new Alerts();
|
||||
if(alertsData) {
|
||||
_.forEach(alertsData, (alertRow) => {
|
||||
@ -16,4 +20,20 @@ jQuery( document ).ready(function( $ ) {
|
||||
});
|
||||
}
|
||||
|
||||
// Editor
|
||||
|
||||
if($('#mk-editor').length === 1) {
|
||||
|
||||
let mde = new SimpleMDE({
|
||||
autofocus: true,
|
||||
element: $("#mk-editor").get(0),
|
||||
autoDownloadFontAwesome: false,
|
||||
placeholder: 'Enter Markdown formatted content here...',
|
||||
hideIcons: ['heading', 'quote'],
|
||||
showIcons: ['strikethrough', 'heading-1', 'heading-2', 'heading-3', 'code', 'table', 'horizontal-rule'],
|
||||
spellChecker: false
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
});
|
@ -1,13 +1,20 @@
|
||||
//@import './layout/_fonts';
|
||||
@import './layout/_base';
|
||||
|
||||
$warning: #f68b39;
|
||||
$red: #E53935;
|
||||
$orange: #FB8C00;
|
||||
$blue: #039BE5;
|
||||
$turquoise: #00ACC1;
|
||||
$green: #7CB342;
|
||||
|
||||
$warning: $orange;
|
||||
|
||||
@import 'bulma';
|
||||
@import './libs/twemoji-awesome';
|
||||
@import './libs/animate.min.css';
|
||||
|
||||
@import './components/_alerts';
|
||||
@import './components/_editor';
|
||||
|
||||
@import './layout/_header';
|
||||
@import './layout/_footer';
|
||||
|
8
client/scss/components/_editor.scss
Normal file
8
client/scss/components/_editor.scss
Normal file
@ -0,0 +1,8 @@
|
||||
|
||||
.editor-toolbar i.separator {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.editor-toolbar .fa {
|
||||
font-size: 14px;
|
||||
}
|
@ -6,6 +6,10 @@
|
||||
|
||||
}
|
||||
|
||||
.section.is-small {
|
||||
padding: 20px 20px;
|
||||
}
|
||||
|
||||
.mkcontent {
|
||||
|
||||
h1 {
|
||||
@ -26,12 +30,31 @@
|
||||
|
||||
}
|
||||
|
||||
.hljs {
|
||||
a.external-link {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
|
||||
&:before {
|
||||
content: "\f08e";
|
||||
font-family: FontAwesome;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
text-decoration: inherit;
|
||||
color: $grey;
|
||||
font-size: 14px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0;
|
||||
border-radius: 3px;
|
||||
|
||||
> code {
|
||||
box-shadow: inset 0 0 5px 0 $grey-light;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
}
|
||||
@ -54,6 +77,10 @@
|
||||
color: $grey-dark;
|
||||
}
|
||||
|
||||
.twa {
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.content a:not(.button):visited {
|
||||
|
@ -0,0 +1,5 @@
|
||||
|
||||
h2.nav-item {
|
||||
font-size: 150%;
|
||||
color: $orange;
|
||||
}
|
@ -2,9 +2,32 @@
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var _ = require('lodash');
|
||||
|
||||
router.get('/edit/*', (req, res, next) => {
|
||||
res.send('EDIT MODE');
|
||||
|
||||
let safePath = entries.parsePath(_.replace(req.path, '/edit', ''));
|
||||
|
||||
entries.fetchOriginal(safePath, {
|
||||
parseMarkdown: false,
|
||||
parseMeta: true,
|
||||
parseTree: false,
|
||||
includeMarkdown: true,
|
||||
includeParentInfo: false,
|
||||
cache: false
|
||||
}).then((pageData) => {
|
||||
if(pageData) {
|
||||
return res.render('pages/edit', { pageData });
|
||||
} else {
|
||||
throw new Error('Invalid page path.');
|
||||
}
|
||||
}).catch((err) => {
|
||||
res.render('error', {
|
||||
message: err.message,
|
||||
error: {}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
router.get('/new/*', (req, res, next) => {
|
||||
@ -19,13 +42,13 @@ router.get('/*', (req, res, next) => {
|
||||
let safePath = entries.parsePath(req.path);
|
||||
|
||||
entries.fetch(safePath).then((pageData) => {
|
||||
console.log(pageData);
|
||||
if(pageData) {
|
||||
res.render('pages/view', { pageData });
|
||||
return res.render('pages/view', { pageData });
|
||||
} else {
|
||||
next();
|
||||
return next();
|
||||
}
|
||||
}).catch((err) => {
|
||||
winston.error(err);
|
||||
next();
|
||||
});
|
||||
|
||||
|
@ -23,7 +23,8 @@ var paths = {
|
||||
'./node_modules/jquery/dist/jquery.min.js',
|
||||
'./node_modules/vue/dist/vue.min.js',
|
||||
'./node_modules/jquery-smooth-scroll/jquery.smooth-scroll.min.js',
|
||||
'./node_modules/sticky-js/dist/sticky.min.js'
|
||||
'./node_modules/sticky-js/dist/sticky.min.js',
|
||||
'./node_modules/simplemde/dist/simplemde.min.js'
|
||||
],
|
||||
scriptapps: [
|
||||
'./client/js/components/*.js',
|
||||
@ -34,7 +35,8 @@ var paths = {
|
||||
],
|
||||
csslibs: [
|
||||
'./node_modules/font-awesome/css/font-awesome.min.css',
|
||||
'./node_modules/highlight.js/styles/default.css'
|
||||
'./node_modules/highlight.js/styles/default.css',
|
||||
'./node_modules/simplemde/dist/simplemde.min.css'
|
||||
],
|
||||
cssapps: [
|
||||
'./client/scss/app.scss'
|
||||
|
@ -5,7 +5,8 @@ var Promise = require('bluebird'),
|
||||
fs = Promise.promisifyAll(require("fs")),
|
||||
_ = require('lodash'),
|
||||
farmhash = require('farmhash'),
|
||||
msgpack = require('msgpack5')();
|
||||
BSONModule = require('bson'),
|
||||
BSON = new BSONModule.BSONPure.BSON();
|
||||
|
||||
/**
|
||||
* Entries Model
|
||||
@ -32,12 +33,17 @@ module.exports = {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch an entry from cache, otherwise the original
|
||||
*
|
||||
* @param {String} entryPath The entry path
|
||||
* @return {Object} Page Data
|
||||
*/
|
||||
fetch(entryPath) {
|
||||
|
||||
let self = this;
|
||||
|
||||
let fpath = path.join(self._repoPath, entryPath + '.md');
|
||||
let cpath = path.join(self._cachePath, farmhash.fingerprint32(entryPath) + '.bin');
|
||||
let cpath = path.join(self._cachePath, farmhash.fingerprint32(entryPath) + '.bson');
|
||||
|
||||
return fs.statAsync(cpath).then((st) => {
|
||||
return st.isFile();
|
||||
@ -47,10 +53,10 @@ module.exports = {
|
||||
|
||||
if(isCache) {
|
||||
|
||||
console.log('from cache!');
|
||||
// Load from cache
|
||||
|
||||
return fs.readFileAsync(cpath, 'utf8').then((contents) => {
|
||||
return msgpack.decode(contents);
|
||||
return fs.readFileAsync(cpath).then((contents) => {
|
||||
return BSON.deserialize(contents);
|
||||
}).catch((err) => {
|
||||
winston.error('Corrupted cache file. Deleting it...');
|
||||
fs.unlinkSync(cpath);
|
||||
@ -59,38 +65,96 @@ module.exports = {
|
||||
|
||||
} else {
|
||||
|
||||
console.log('original!');
|
||||
// Load original
|
||||
|
||||
// Parse original and cache it
|
||||
return self.fetchOriginal(entryPath);
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches the original document entry
|
||||
*
|
||||
* @param {String} entryPath The entry path
|
||||
* @param {Object} options The options
|
||||
* @return {Object} Page data
|
||||
*/
|
||||
fetchOriginal(entryPath, options) {
|
||||
|
||||
let self = this;
|
||||
|
||||
let fpath = path.join(self._repoPath, entryPath + '.md');
|
||||
let cpath = path.join(self._cachePath, farmhash.fingerprint32(entryPath) + '.bson');
|
||||
|
||||
options = _.defaults(options, {
|
||||
parseMarkdown: true,
|
||||
parseMeta: true,
|
||||
parseTree: true,
|
||||
includeMarkdown: false,
|
||||
includeParentInfo: true,
|
||||
cache: true
|
||||
});
|
||||
|
||||
return fs.statAsync(fpath).then((st) => {
|
||||
if(st.isFile()) {
|
||||
return fs.readFileAsync(fpath, 'utf8').then((contents) => {
|
||||
let pageData = mark.parse(contents);
|
||||
|
||||
// Parse contents
|
||||
|
||||
let pageData = {
|
||||
markdown: (options.includeMarkdown) ? contents : '',
|
||||
html: (options.parseMarkdown) ? mark.parseContent(contents) : '',
|
||||
meta: (options.parseMeta) ? mark.parseMeta(contents) : {},
|
||||
tree: (options.parseTree) ? mark.parseTree(contents) : []
|
||||
};
|
||||
|
||||
if(!pageData.meta.title) {
|
||||
pageData.meta.title = entryPath;
|
||||
pageData.meta.title = _.startCase(entryPath);
|
||||
}
|
||||
let cacheData = msgpack.encode(pageData);
|
||||
return fs.writeFileAsync(cpath, cacheData, { encoding: 'utf8' }).then(() => {
|
||||
return pageData;
|
||||
|
||||
pageData.meta.path = entryPath;
|
||||
|
||||
// Get parent
|
||||
|
||||
let parentPromise = (options.includeParentInfo) ? self.getParentInfo(entryPath).then((parentData) => {
|
||||
return (pageData.parent = parentData);
|
||||
}).catch((err) => {
|
||||
return (pageData.parent = false);
|
||||
}) : Promise.resolve(true);
|
||||
|
||||
return parentPromise.then(() => {
|
||||
|
||||
// Cache to disk
|
||||
|
||||
if(options.cache) {
|
||||
let cacheData = BSON.serialize(pageData, false, false, false);
|
||||
return fs.writeFileAsync(cpath, cacheData).catch((err) => {
|
||||
winston.error('Unable to write to cache! Performance may be affected.');
|
||||
return pageData;
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
|
||||
}).return(pageData);
|
||||
|
||||
});
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse raw url path and make it safe
|
||||
*
|
||||
* @param {String} urlPath The url path
|
||||
* @return {String} Safe entry path
|
||||
*/
|
||||
parsePath(urlPath) {
|
||||
|
||||
let wlist = new RegExp('[^a-z0-9/\-]','g');
|
||||
@ -105,6 +169,47 @@ module.exports = {
|
||||
|
||||
return _.join(urlParts, '/');
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the parent information.
|
||||
*
|
||||
* @param {String} entryPath The entry path
|
||||
* @return {Object|False} The parent information.
|
||||
*/
|
||||
getParentInfo(entryPath) {
|
||||
|
||||
let self = this;
|
||||
|
||||
if(_.includes(entryPath, '/')) {
|
||||
|
||||
let parentParts = _.split(entryPath, '/');
|
||||
let parentPath = _.join(_.initial(parentParts),'/');
|
||||
let parentFile = _.last(parentParts);
|
||||
let fpath = path.join(self._repoPath, parentPath + '.md');
|
||||
|
||||
return fs.statAsync(fpath).then((st) => {
|
||||
if(st.isFile()) {
|
||||
return fs.readFileAsync(fpath, 'utf8').then((contents) => {
|
||||
|
||||
let pageMeta = mark.parseMeta(contents);
|
||||
|
||||
return {
|
||||
path: parentPath,
|
||||
title: (pageMeta.title) ? pageMeta.title : _.startCase(parentFile),
|
||||
subtitle: (pageMeta.subtitle) ? pageMeta.subtitle : false
|
||||
};
|
||||
|
||||
});
|
||||
} else {
|
||||
return Promise.reject(new Error('Parent entry is not a valid file.'));
|
||||
}
|
||||
});
|
||||
|
||||
} else {
|
||||
return Promise.reject(new Error('Parent entry is root.'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
@ -29,7 +29,7 @@ var mkdown = md({
|
||||
return '<pre><code>' + str + '</code></pre>';
|
||||
}
|
||||
}
|
||||
return '<pre class="hljs"><code>' + hljs.highlightAuto(str).value + '</code></pre>';
|
||||
return '<pre><code>' + str + '</code></pre>';
|
||||
}
|
||||
})
|
||||
.use(mdEmoji)
|
||||
@ -175,6 +175,10 @@ module.exports = {
|
||||
html: parseContent(content),
|
||||
tree: parseTree(content)
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
parseContent,
|
||||
parseMeta,
|
||||
parseTree
|
||||
|
||||
};
|
@ -34,6 +34,7 @@
|
||||
"bcryptjs-then": "^1.0.1",
|
||||
"bluebird": "^3.4.1",
|
||||
"body-parser": "^1.15.2",
|
||||
"bson": "^0.5.4",
|
||||
"bulma": "^0.1.2",
|
||||
"cheerio": "^0.22.0",
|
||||
"child-process-promise": "^2.1.3",
|
||||
@ -48,6 +49,7 @@
|
||||
"express-session": "^1.14.0",
|
||||
"express-validator": "^2.20.8",
|
||||
"farmhash": "^1.2.0",
|
||||
"fs-extra": "^0.30.0",
|
||||
"git-wrapper2-promise": "^0.2.9",
|
||||
"highlight.js": "^9.6.0",
|
||||
"i18next": "^3.4.1",
|
||||
@ -69,10 +71,10 @@
|
||||
"markdown-it-toc-and-anchor": "^4.1.1",
|
||||
"moment": "^2.14.1",
|
||||
"moment-timezone": "^0.5.5",
|
||||
"msgpack5": "^3.4.0",
|
||||
"passport": "^0.3.2",
|
||||
"passport-local": "^1.0.0",
|
||||
"pug": "^2.0.0-beta5",
|
||||
"search-index": "^0.8.15",
|
||||
"serve-favicon": "^2.3.0",
|
||||
"simplemde": "^1.11.2",
|
||||
"slug": "^0.9.1",
|
||||
|
@ -1,11 +1,13 @@
|
||||
|
||||
nav.nav.has-shadow.stickyscroll
|
||||
.nav-left
|
||||
block rootNavLeft
|
||||
a.nav-item.is-brand(href='/')
|
||||
img(src='/favicons/android-icon-96x96.png', alt='Wiki')
|
||||
a.nav-item(href='/')
|
||||
h1.title Wiki
|
||||
.nav-center
|
||||
block rootNavCenter
|
||||
p.nav-item
|
||||
input.input(type='text', placeholder='Search...', style= { 'max-width': '300px', width: '33vw' })
|
||||
span.nav-toggle
|
||||
@ -13,6 +15,7 @@ nav.nav.has-shadow.stickyscroll
|
||||
span
|
||||
span
|
||||
.nav-right.nav-menu
|
||||
block rootNavRight
|
||||
a.nav-item(href='#')
|
||||
| History
|
||||
a.nav-item(href='#')
|
||||
|
@ -3,14 +3,22 @@ html
|
||||
head
|
||||
meta(http-equiv='X-UA-Compatible', content='IE=edge')
|
||||
meta(charset='UTF-8')
|
||||
meta(name='viewport', content='width=device-width, initial-scale=1')
|
||||
meta(name='theme-color', content='#009688')
|
||||
meta(name='msapplication-TileColor', content='#009688')
|
||||
meta(name='msapplication-TileImage', content='/favicons/ms-icon-144x144.png')
|
||||
title= appconfig.title
|
||||
|
||||
// Favicon
|
||||
each favsize in [57, 60, 72, 76, 114, 120, 144, 152, 180]
|
||||
link(rel='apple-touch-icon', sizes=favsize + 'x' + favsize, href='/favicons/apple-icon-' + favsize + 'x' + favsize + '.png')
|
||||
link(rel='icon', type='image/png', sizes='192x192', href='/favicons/android-icon-192x192.png')
|
||||
each favsize in [32, 96, 16]
|
||||
link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize href='/images/favicon-' + favsize + 'x' + favsize + '.png')
|
||||
link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize, href='/favicons/favicon-' + favsize + 'x' + favsize + '.png')
|
||||
link(rel='manifest', href='/manifest.json')
|
||||
|
||||
// CSS
|
||||
link(href='https://fonts.googleapis.com/css?family=Open+Sans:400,300,600,700|Inconsolata', rel='stylesheet', type='text/css')
|
||||
link(type='text/css', rel='stylesheet', href='/css/libs.css')
|
||||
link(type='text/css', rel='stylesheet', href='/css/app.css')
|
||||
|
||||
body(class='server-error')
|
||||
|
24
views/pages/edit.pug
Normal file
24
views/pages/edit.pug
Normal file
@ -0,0 +1,24 @@
|
||||
extends ../layout
|
||||
|
||||
block rootNavCenter
|
||||
h2.nav-item= pageData.meta.title
|
||||
|
||||
block rootNavRight
|
||||
a.nav-item(href='#')
|
||||
| History
|
||||
a.nav-item(href='#')
|
||||
| Source
|
||||
span.nav-item
|
||||
a.button.is-danger(href='/' + pageData.meta.path)
|
||||
span.icon
|
||||
i.fa.fa-times
|
||||
span Discard
|
||||
a.button.is-success(href='#', onclick='$(".modal").addClass("is-active");')
|
||||
span.icon
|
||||
i.fa.fa-check
|
||||
span Save Changes
|
||||
|
||||
block content
|
||||
|
||||
section.section.is-small
|
||||
textarea#mk-editor= pageData.markdown
|
@ -8,6 +8,21 @@ mixin tocMenu(ti)
|
||||
ul
|
||||
+tocMenu(node.nodes)
|
||||
|
||||
block rootNavRight
|
||||
a.nav-item(href='#')
|
||||
| History
|
||||
a.nav-item(href='#')
|
||||
| Source
|
||||
span.nav-item
|
||||
a.button(href='/edit/' + pageData.meta.path)
|
||||
span.icon
|
||||
i.fa.fa-edit
|
||||
span Edit
|
||||
a.button.is-primary(href='#', onclick='$(".modal").addClass("is-active");')
|
||||
span.icon
|
||||
i.fa.fa-plus
|
||||
span Create
|
||||
|
||||
block content
|
||||
|
||||
section.section
|
||||
@ -23,8 +38,9 @@ block content
|
||||
ul.menu-list
|
||||
li
|
||||
a(href='/') Home
|
||||
if pageData.parent
|
||||
li
|
||||
a(href='/') Storage
|
||||
a(href='/' + pageData.parent.path)= pageData.parent.title
|
||||
li
|
||||
a(href='/account') Account
|
||||
.box.stickyscroll(data-margin-top=70)
|
||||
|
Loading…
Reference in New Issue
Block a user