diff --git a/package.json b/package.json index 382231c..4355d18 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "morgan": "^1.10.0", "multer": "^1.4.2", "nedb": "^1.8.0", + "node-cache": "^5.1.2", "node-fetch": "^2.6.1", "open-graph-scraper": "^4.9.0", "twig": "^1.15.4", diff --git a/src/backend/controllers/pages.ts b/src/backend/controllers/pages.ts index fa92c2b..5329552 100644 --- a/src/backend/controllers/pages.ts +++ b/src/backend/controllers/pages.ts @@ -2,7 +2,8 @@ import Page, { PageData } from '../models/page'; import Alias from '../models/alias'; import PagesOrder from './pagesOrder'; import PageOrder from '../models/pageOrder'; -import HttpException from "../exceptions/httpException"; +import HttpException from '../exceptions/httpException'; +import PagesFlatArray from '../models/pagesFlatArray'; type PageDataFields = keyof PageData; @@ -195,6 +196,7 @@ class Pages { pagesAvailable[index] = null; pagesAvailable = Pages.removeChildren(pagesAvailable, item._id); }); + PagesFlatArray.regenerate(); return pagesAvailable; } @@ -221,6 +223,7 @@ class Pages { alias.save(); } + await PagesFlatArray.regenerate(); return insertedPage; } catch (e) { @@ -264,6 +267,7 @@ class Pages { Alias.markAsDeprecated(previousUri); } } + await PagesFlatArray.regenerate(); return updatedPage; } @@ -286,8 +290,10 @@ class Pages { await alias.destroy(); } + const removedPage = page.destroy(); + await PagesFlatArray.regenerate(); - return page.destroy(); + return removedPage; } /** diff --git a/src/backend/controllers/pagesOrder.ts b/src/backend/controllers/pagesOrder.ts index 846c9ac..0227878 100644 --- a/src/backend/controllers/pagesOrder.ts +++ b/src/backend/controllers/pagesOrder.ts @@ -1,5 +1,6 @@ import PageOrder from '../models/pageOrder'; import Page from '../models/page'; +import PagesFlatArray from '../models/pagesFlatArray'; /** * @class PagesOrder @@ -62,6 +63,7 @@ class PagesOrder { order.push(childId); await order.save(); + await PagesFlatArray.regenerate(); } /** @@ -76,11 +78,13 @@ class PagesOrder { oldParentOrder.remove(targetPageId); await oldParentOrder.save(); + await PagesFlatArray.regenerate(); const newParentOrder = await PageOrder.get(newParentId); newParentOrder.push(targetPageId); await newParentOrder.save(); + await PagesFlatArray.regenerate(); } /** @@ -125,6 +129,7 @@ class PagesOrder { pageOrder.order = Array.from(new Set([...pageOrder.order, ...unordered])); pageOrder.putAbove(currentPageId, putAbovePageId); await pageOrder.save(); + await PagesFlatArray.regenerate(); } /** @@ -138,7 +143,8 @@ class PagesOrder { throw new Error('Page with given id does not contain order'); } - return order.destroy(); + await order.destroy(); + await PagesFlatArray.regenerate(); } } diff --git a/src/backend/models/pageOrder.ts b/src/backend/models/pageOrder.ts index 84c18b5..e66fb9e 100644 --- a/src/backend/models/pageOrder.ts +++ b/src/backend/models/pageOrder.ts @@ -182,7 +182,7 @@ class PageOrder { * * @param {string} pageId - identity of page */ - public getPageBefore(pageId: string): string | null { + public getSubPageBefore(pageId: string): string | null { if (this.order === undefined) { return null; } @@ -204,7 +204,7 @@ class PageOrder { * * @param pageId - identity of page */ - public getPageAfter(pageId: string): string | null { + public getSubPageAfter(pageId: string): string | null { if (this.order === undefined) { return null; } diff --git a/src/backend/models/pagesFlatArray.ts b/src/backend/models/pagesFlatArray.ts new file mode 100644 index 0000000..8388cd2 --- /dev/null +++ b/src/backend/models/pagesFlatArray.ts @@ -0,0 +1,182 @@ +import Page from './page'; +import PageOrder from './pageOrder'; +import NodeCache from 'node-cache'; + +// Create cache for flat array +const cache = new NodeCache({ stdTTL: 120 }); + +const cacheKey = 'pagesFlatArray'; + +/** + * Element for pagesFlatArray + */ +export interface PagesFlatArrayData { + /** + * Page id + */ + id: string; + + /** + * Page parent id + */ + parentId?: string; + + /** + * id of parent with parent id '0' + */ + rootId: string; + + /** + * Page level in sidebar view + */ + level: number; + + /** + * Page title + */ + title: string; + + /** + * Page uri + */ + uri?: string; +} + +/** + * @class PagesFlatArray model - flat array of pages, which are ordered like in sidebar + */ +class PagesFlatArray { + /** + * Returns pages flat array + * + * @param nestingLimit - number of flat array nesting, set null to dismiss the restriction, default nesting 2 + * @returns {Promise>} + */ + public static async get(nestingLimit: number | null = 2): Promise> { + // Get flat array from cache + let arr = cache.get(cacheKey) as Array; + + // Check is flat array consists in cache + if (!arr) { + arr = await this.regenerate(); + } + + if (!nestingLimit) { + return arr; + } + + return arr.filter( (item) => item.level < nestingLimit ); + } + + /** + * Generates new flat array, saves it to cache, returns it + * Calls, when there is no pages flat array data in cache or when page or pageOrder data updates + * + * @returns {Promise>} + */ + public static async regenerate(): Promise> { + const pages = await Page.getAll(); + const pagesOrders = await PageOrder.getAll(); + + let arr = new Array(); + + // Get root order + const rootOrder = pagesOrders.find( order => order.page == '0' ); + + // Check is root order is not empty + if (!rootOrder) { + return []; + } + + for (const pageId of rootOrder.order) { + arr = arr.concat(this.getChildrenFlatArray(pageId, 0, pages, + pagesOrders)); + } + + // Save generated flat array to cache + cache.set(cacheKey, arr); + + return arr; + } + + /** + * Returns previous page + * + * @param pageId - page id + * @returns {Promise} + */ + public static async getPageBefore(pageId: string): Promise { + const arr = await this.get(); + + const pageIndex = arr.findIndex( (item) => item.id == pageId); + + // Check if index is not the first + if (pageIndex && pageIndex > 0) { + // Return previous element from array + return arr[pageIndex - 1]; + } else { + return; + } + } + + /** + * Returns next page + * + * @param pageId - page id + * @returns {Promise} + */ + public static async getPageAfter(pageId: string): Promise { + const arr = await this.get(); + + const pageIndex = arr.findIndex( (item) => item.id == pageId ); + + // Check if index is not the last + if (pageIndex < arr.length -1) { + // Return next element from array + return arr[pageIndex + 1]; + } else { + return; + } + } + + /** + * Returns child pages array + * + * @param pageId - parent page id + * @param level - page level in sidebar + * @param pages - all pages + * @param orders - all page orders + * @returns {Promise>} + */ + private static getChildrenFlatArray(pageId: string, level: number, + pages: Array, orders: Array): Array { + let arr: Array = new Array(); + + const page = pages.find( item => item._id == pageId ); + + // Add element to child array + if (page) { + arr.push( { + id: page._id!, + level: level, + parentId: page._parent, + rootId: '0', + title: page.title!, + uri: page.uri, + } ); + } + + const order = orders.find(item => item.page == pageId); + + if (order) { + for (const childPageId of order.order) { + arr = arr.concat(this.getChildrenFlatArray(childPageId, level + 1, + pages, orders)); + } + } + + return arr; + } +} + +export default PagesFlatArray; diff --git a/src/backend/routes/aliases.ts b/src/backend/routes/aliases.ts index 1dc2195..826d2da 100644 --- a/src/backend/routes/aliases.ts +++ b/src/backend/routes/aliases.ts @@ -3,6 +3,7 @@ import Aliases from '../controllers/aliases'; import Pages from '../controllers/pages'; import Alias from '../models/alias'; import verifyToken from './middlewares/token'; +import PagesFlatArray from '../models/pagesFlatArray'; const router = express.Router(); @@ -32,9 +33,14 @@ router.get('*', verifyToken, async (req: Request, res: Response) => { const pageParent = await page.getParent(); + const previousPage = await PagesFlatArray.getPageBefore(alias.id); + const nextPage = await PagesFlatArray.getPageAfter(alias.id); + res.render('pages/page', { page, pageParent, + previousPage, + nextPage, config: req.app.locals.config, }); } diff --git a/src/backend/routes/api/pages.ts b/src/backend/routes/api/pages.ts index ba240b7..7aef335 100644 --- a/src/backend/routes/api/pages.ts +++ b/src/backend/routes/api/pages.ts @@ -158,8 +158,8 @@ router.delete('/page/:id', async (req: Request, res: Response) => { } const parentPageOrder = await PagesOrder.get(page._parent); - const pageBeforeId = parentPageOrder.getPageBefore(page._id); - const pageAfterId = parentPageOrder.getPageAfter(page._id); + const pageBeforeId = parentPageOrder.getSubPageBefore(page._id); + const pageAfterId = parentPageOrder.getSubPageAfter(page._id); let pageToRedirect; diff --git a/src/backend/routes/pages.ts b/src/backend/routes/pages.ts index fe70f37..37908b0 100644 --- a/src/backend/routes/pages.ts +++ b/src/backend/routes/pages.ts @@ -3,6 +3,7 @@ import Pages from '../controllers/pages'; import PagesOrder from '../controllers/pagesOrder'; import verifyToken from './middlewares/token'; import allowEdit from './middlewares/locals'; +import PagesFlatArray from '../models/pagesFlatArray'; const router = express.Router(); @@ -62,10 +63,15 @@ router.get('/page/:id', verifyToken, async (req: Request, res: Response, next: N const pageParent = await page.parent; + const previousPage = await PagesFlatArray.getPageBefore(pageId); + const nextPage = await PagesFlatArray.getPageAfter(pageId); + res.render('pages/page', { page, pageParent, config: req.app.locals.config, + previousPage, + nextPage, }); } catch (error) { res.status(404); diff --git a/src/backend/views/components/navigator.twig b/src/backend/views/components/navigator.twig new file mode 100644 index 0000000..9997a11 --- /dev/null +++ b/src/backend/views/components/navigator.twig @@ -0,0 +1,50 @@ +{# +Reusable navigaor component. +Available props: + - previousPage + - nextPage + - class: additional class for the navigator + +Usage example: + {% include 'components/navigator.twig' with {previousPage: previousPage, nextPage: nextPage} %} +#} + +{% set mainClass = 'navigator__item' %} + + +{% set tag = 'div' %} + +{% set tag = 'a' %} + + + diff --git a/src/backend/views/pages/page.twig b/src/backend/views/pages/page.twig index e123c4d..58172d3 100644 --- a/src/backend/views/pages/page.twig +++ b/src/backend/views/pages/page.twig @@ -39,8 +39,7 @@ {% endif %} {% endfor %} - + {% include 'components/navigator.twig' with {previousPage: previousPage, nextPage: nextPage} %} {% endblock %} diff --git a/src/frontend/styles/components/navigator.pcss b/src/frontend/styles/components/navigator.pcss new file mode 100644 index 0000000..dd69a8a --- /dev/null +++ b/src/frontend/styles/components/navigator.pcss @@ -0,0 +1,42 @@ +.navigator { + display: flex; +} + +.navigator__item { + margin-top: 35px; + cursor: pointer; + -webkit-user-select: none; + display: flex; + flex-direction: column; + justify-content: space-between; + background-color: var(--color-link-hover); + border-radius: 10px; + padding: 12px 16px 12px 16px; + color: black; + width: max-content; + font-weight: 500; + font-size: 14px; + + &--previous { + align-items: flex-start; + margin-left: 0; + } + + &--next { + align-items: flex-end; + margin-left: auto; + } + + &-direction { + text-transform: capitalize; + color: var(--color-direction-navigation); + font-size: 12px; + font-weight: 400; + } + + &-label { + width: 100%; + } +} + + diff --git a/src/frontend/styles/components/page.pcss b/src/frontend/styles/components/page.pcss index 1caf297..a9135c9 100644 --- a/src/frontend/styles/components/page.pcss +++ b/src/frontend/styles/components/page.pcss @@ -496,3 +496,4 @@ } } } + diff --git a/src/frontend/styles/main.pcss b/src/frontend/styles/main.pcss index 0d79db0..13fafb3 100644 --- a/src/frontend/styles/main.pcss +++ b/src/frontend/styles/main.pcss @@ -10,6 +10,7 @@ @import './components/auth.pcss'; @import './components/button.pcss'; @import './components/sidebar.pcss'; +@import './components/navigator.pcss'; @import './components/table-of-content.pcss'; body { diff --git a/src/frontend/styles/vars.pcss b/src/frontend/styles/vars.pcss index dc7214d..7d5b37e 100644 --- a/src/frontend/styles/vars.pcss +++ b/src/frontend/styles/vars.pcss @@ -1,6 +1,7 @@ :root { --color-text-main: #313649; --color-text-second: #5d6068; + --color-direction-navigation: #717682; --color-line-gray: #E8E8EB; --color-link-active: #2071cc; --color-link-hover: #F3F6F8; @@ -124,7 +125,7 @@ --squircle { border-radius: 8px; - + @supports(-webkit-mask-box-image: url('')){ border-radius: 0; -webkit-mask-box-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 10.3872C0 1.83334 1.83334 0 10.3872 0H13.6128C22.1667 0 24 1.83334 24 10.3872V13.6128C24 22.1667 22.1667 24 13.6128 24H10.3872C1.83334 24 0 22.1667 0 13.6128V10.3872Z' fill='black'/%3E%3C/svg%3E%0A") 48% 41% 37.9% 53.3%;; diff --git a/yarn.lock b/yarn.lock index 4fd0c71..86ff757 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,7 +10,8 @@ "@babel/code-frame@7.12.11": version "7.12.11" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== dependencies: "@babel/highlight" "^7.10.4" @@ -2016,6 +2017,11 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +clone@2.x: + version "2.1.2" + resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -3240,6 +3246,18 @@ growl@1.10.5: version "1.10.5" resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" +handlebars@^4.1.0: + version "4.7.7" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.0" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + has-bigints@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" @@ -3286,10 +3304,6 @@ hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" -html-escaper@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" - htmlparser2@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" @@ -3905,7 +3919,7 @@ make-dir@^1.3.0: dependencies: pify "^3.0.0" -make-dir@^2.0.0, make-dir@^2.1.0: +make-dir@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" dependencies: @@ -4132,9 +4146,10 @@ negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" -neo-async@^2.6.2: +neo-async@^2.6.0, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== nice-try@^1.0.4: version "1.0.5" @@ -4150,6 +4165,13 @@ nise@^5.1.0: just-extend "^4.0.2" path-to-regexp "^1.7.0" +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== + dependencies: + clone "2.x" + node-fetch@^2.6.1: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -5574,9 +5596,10 @@ supports-color@^5.3.0, supports-color@^5.4.0: dependencies: has-flag "^3.0.0" -supports-color@^6.1.0: +supports-color@^6.0.0: version "6.1.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== dependencies: has-flag "^3.0.0" @@ -5805,6 +5828,11 @@ typescript@^4.3.5: version "4.6.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c" +uglify-js@^3.1.4: + version "3.16.3" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.16.3.tgz#94c7a63337ee31227a18d03b8a3041c210fd1f1d" + integrity sha512-uVbFqx9vvLhQg0iBaau9Z75AxWJ8tqM9AV890dIZCLApF4rTcyHwmAvLeEdYRs+BzYWu8Iw81F79ah0EfTXbaw== + uid-safe@2.1.5: version "2.1.5" resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" @@ -6015,6 +6043,11 @@ word-wrap@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"