diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx index f3b284a93..7268e4b34 100644 --- a/client/homebrew/brewRenderer/brewRenderer.jsx +++ b/client/homebrew/brewRenderer/brewRenderer.jsx @@ -1,7 +1,7 @@ /*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/ require('./brewRenderer.less'); const React = require('react'); -const { useState, useRef, useEffect } = React; +const { useState, useRef, useEffect, useCallback } = React; const _ = require('lodash'); const MarkdownLegacy = require('naturalcrit/markdownLegacy.js'); @@ -49,23 +49,25 @@ let rawPages = []; const BrewRenderer = (props)=>{ props = { - text : '', - style : '', - renderer : 'legacy', - theme : '5ePHB', - lang : '', - errors : [], - currentEditorPage : 0, - themeBundle : {}, + text : '', + style : '', + renderer : 'legacy', + theme : '5ePHB', + lang : '', + errors : [], + currentEditorCursorPageNum : 0, + currentEditorViewPageNum : 0, + currentBrewRendererPageNum : 0, + themeBundle : {}, + onPageChange : ()=>{}, ...props }; const [state, setState] = useState({ - height : PAGE_HEIGHT, - isMounted : false, - visibility : 'hidden', - zoom : 100, - currentPageNumber : 1, + height : PAGE_HEIGHT, + isMounted : false, + visibility : 'hidden', + zoom : 100 }); const mainRef = useRef(null); @@ -87,25 +89,22 @@ const BrewRenderer = (props)=>{ })); }; - const getCurrentPage = (e)=>{ + const updateCurrentPage = useCallback(_.throttle((e)=>{ const { scrollTop, clientHeight, scrollHeight } = e.target; const totalScrollableHeight = scrollHeight - clientHeight; - const currentPageNumber = Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length); + const currentPageNumber = Math.max(Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length), 1); - setState((prevState)=>({ - ...prevState, - currentPageNumber : currentPageNumber || 1 - })); - }; + props.onPageChange(currentPageNumber); + }, 200), []); const isInView = (index)=>{ if(!state.isMounted) return false; - if(index == props.currentEditorPage) //Already rendered before this step + if(index == props.currentEditorCursorPageNum - 1) //Already rendered before this step return false; - if(Math.abs(index - state.currentPageNumber) <= 3) + if(Math.abs(index - props.currentBrewRendererPageNum - 1) <= 3) return true; return false; @@ -142,7 +141,7 @@ const BrewRenderer = (props)=>{ renderedPages.length = 0; // Render currently-edited page first so cross-page effects (variables, links) can propagate out first - renderedPages[props.currentEditorPage] = renderPage(rawPages[props.currentEditorPage], props.currentEditorPage); + renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1); _.forEach(rawPages, (page, index)=>{ if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){ @@ -192,7 +191,7 @@ const BrewRenderer = (props)=>{ <> {/*render dummy page while iFrame is mounting.*/} {!state.isMounted - ?
+ ?
{renderDummyPage(1)}
@@ -205,7 +204,7 @@ const BrewRenderer = (props)=>{
- + {/*render in iFrame so broken code doesn't crash the site.*/} { onClick={()=>{emitClick();}} >
diff --git a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx index aa45bbb8e..cba6a629c 100644 --- a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx +++ b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx @@ -17,7 +17,7 @@ const NotificationPopup = ()=>{
  • Search brews with our new page!
    - We have been working very hard in making this possible, now you can share your work and look at it in the new Vault page! + We have been working very hard in making this possible, now you can share your work and look at it in the new Vault page! All PUBLISHED brews will be available to anyone searching there, by title or author, and filtering by renderer. More features will be coming. diff --git a/client/homebrew/brewRenderer/toolBar/toolBar.jsx b/client/homebrew/brewRenderer/toolBar/toolBar.jsx index d83fa78f9..73b48d778 100644 --- a/client/homebrew/brewRenderer/toolBar/toolBar.jsx +++ b/client/homebrew/brewRenderer/toolBar/toolBar.jsx @@ -56,7 +56,7 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{ } else if(mode == 'fit'){ // find the page with the largest single dim (height or width) so that zoom can be adapted to fit it. - const minDimRatio = [...pages].reduce((minRatio, page) => Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity); + const minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity); desiredZoom = minDimRatio * 100; } @@ -67,9 +67,9 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{ return deltaZoom; }; - return ( + return (
    - + {/*v=====----------------------< Zoom Controls >---------------------=====v*/}
    diff --git a/client/homebrew/editor/snippetbar/snippetbar.jsx b/client/homebrew/editor/snippetbar/snippetbar.jsx index f2680079e..f183e0876 100644 --- a/client/homebrew/editor/snippetbar/snippetbar.jsx +++ b/client/homebrew/editor/snippetbar/snippetbar.jsx @@ -5,6 +5,8 @@ const createClass = require('create-react-class'); const _ = require('lodash'); const cx = require('classnames'); +import { getHistoryItems, historyExists } from '../../utils/versionHistory.js'; + //Import all themes const ThemeSnippets = {}; ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js'); @@ -38,7 +40,8 @@ const Snippetbar = createClass({ unfoldCode : ()=>{}, updateEditorTheme : ()=>{}, cursorPos : {}, - snippetBundle : [] + snippetBundle : [], + updateBrew : ()=>{} }; }, @@ -46,7 +49,8 @@ const Snippetbar = createClass({ return { renderer : this.props.renderer, themeSelector : false, - snippets : [] + snippets : [], + historyExists : false }; }, @@ -59,18 +63,23 @@ const Snippetbar = createClass({ componentDidUpdate : async function(prevProps) { if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) { - const snippets = this.compileSnippets(); this.setState({ - snippets : snippets + snippets : this.compileSnippets() }); - } - }, + }; + if(historyExists(this.props.brew) != this.state.historyExists){ + this.setState({ + historyExists : !this.state.historyExists + }); + }; + + }, mergeCustomizer : function(oldValue, newValue, key) { if(key == 'snippets') { const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme - return result.filter(snip => snip.gen || snip.subsnippets); + return result.filter((snip)=>snip.gen || snip.subsnippets); } }, @@ -138,6 +147,36 @@ const Snippetbar = createClass({ }); }, + replaceContent : function(item){ + return this.props.updateBrew(item); + }, + + renderHistoryItems : function() { + const historyItems = getHistoryItems(this.props.brew); + + return
    + {_.map(historyItems, (item, index)=>{ + if(!item.savedAt) return; + + const saveTime = new Date(item.savedAt); + const diffMs = new Date() - saveTime; + const diffSecs = Math.floor(diffMs / 1000); + + let diffString = `about ${diffSecs} seconds ago`; + + if(diffSecs > 60) diffString = `about ${Math.floor(diffSecs / 60)} minutes ago`; + if(diffSecs > (60 * 60)) diffString = `about ${Math.floor(diffSecs / (60 * 60))} hours ago`; + if(diffSecs > (24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (24 * 60 * 60))} days ago`; + if(diffSecs > (7 * 24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (7 * 24 * 60 * 60))} weeks ago`; + + return
    {this.replaceContent(item);}} > + + v{item.version} : {diffString} +
    ; + })} +
    ; + }, + renderEditorButtons : function(){ if(!this.props.showEditButtons) return; @@ -158,6 +197,10 @@ const Snippetbar = createClass({ } return
    +
    + + {this.state.historyExists && this.renderHistoryItems() } +
    diff --git a/client/homebrew/editor/snippetbar/snippetbar.less b/client/homebrew/editor/snippetbar/snippetbar.less index e0a24fac9..c50d9df4c 100644 --- a/client/homebrew/editor/snippetbar/snippetbar.less +++ b/client/homebrew/editor/snippetbar/snippetbar.less @@ -53,6 +53,21 @@ font-size : 0.75em; color : inherit; } + &.history { + .tooltipLeft('History'); + font-size : 0.75em; + color : grey; + position : relative; + &.active { + color : inherit; + } + &>.dropdown{ + right : -1px; + &>.snippet{ + padding-right : 10px; + } + } + } &.editorTheme { .tooltipLeft('Editor Themes'); font-size : 0.75em; diff --git a/client/homebrew/navbar/error-navitem.jsx b/client/homebrew/navbar/error-navitem.jsx index 5dd5c1eb9..f6788e6d5 100644 --- a/client/homebrew/navbar/error-navitem.jsx +++ b/client/homebrew/navbar/error-navitem.jsx @@ -111,7 +111,7 @@ const ErrorNavItem = createClass({ Looks like there was a problem retreiving the theme, or a theme that it inherits, for this brew. Verify that brew - {response.body.brewId} still exists! + {response.body.brewId} still exists!
    ; } diff --git a/client/homebrew/navbar/navbar.less b/client/homebrew/navbar/navbar.less index 4525a193e..c1cda38c3 100644 --- a/client/homebrew/navbar/navbar.less +++ b/client/homebrew/navbar/navbar.less @@ -35,6 +35,11 @@ display : flex; align-items : center; &:last-child .navItem { border-left : 1px solid #666666; } + + &:has(.brewTitle) { + flex-grow : 1; + min-width : 300px; + } } // "NaturalCrit" logo .navLogo { @@ -69,6 +74,10 @@ .navItem { #backgroundColorsHover; .animate(background-color); + display : flex; + align-items : center; + justify-content : center; + height : 100%; padding : 8px 12px; font-size : 10px; font-weight : 800; @@ -94,39 +103,20 @@ animation-duration : 2s; } } - &.editTitle { // this is not needed at all currently - you used to be able to edit the title via the navbar. - padding : 2px 12px; - input { - width : 250px; - padding : 2px; - margin : 0; - font-family : 'Open Sans', sans-serif; - font-size : 12px; - font-weight : 800; - color : white; - text-align : center; - background-color : transparent; - border : 1px solid @blue; - outline : none; - } - .charCount { - display : inline-block; - margin-left : 8px; - color : #666666; - text-align : right; - vertical-align : bottom; - &.max { color : @red; } - } - } &.brewTitle { - flex-grow : 1; + display : block; + width : 100%; + overflow : hidden; font-size : 12px; font-weight : 800; color : white; text-align : center; - text-transform : initial; - background-color : transparent; + text-overflow : ellipsis; + text-transform : initial; + white-space : nowrap; + background-color : transparent; } + // "The Homebrewery" logo &.homebrewLogo { .animate(color); @@ -240,23 +230,25 @@ } .navDropdownContainer { position : relative; + height : 100%; + .navDropdown { - position: absolute; - top: 28px; - right: 0px; - z-index: 10000; - width: max-content; - min-width:100%; - max-height: calc(100vh - 28px); - overflow: hidden auto; - display: flex; - flex-direction: column; - align-items: flex-end; + position : absolute; + //top: 28px; + right : 0px; + z-index : 10000; + display : flex; + flex-direction : column; + align-items : flex-end; + width : max-content; + min-width : 100%; + max-height : calc(100vh - 28px); + overflow : hidden auto; .navItem { position : relative; display : flex; - justify-content : space-between; align-items : center; + justify-content : space-between; width : 100%; border : 1px solid #888888; border-bottom : 0; @@ -278,10 +270,10 @@ overflow : hidden auto; color : white; text-decoration : none; - background-color : #333333; - border-top : 1px solid #888888; scrollbar-color : #666666 #333333; scrollbar-width : thin; + background-color : #333333; + border-top : 1px solid #888888; .clear { position : absolute; top : 50%; diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 39a6d1931..18cfb2d41 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -1,8 +1,9 @@ /* eslint-disable max-lines */ require('./editPage.less'); const React = require('react'); -const createClass = require('create-react-class'); const _ = require('lodash'); +const createClass = require('create-react-class'); + const request = require('../../utils/request-middleware.js'); const { Meta } = require('vitreum/headtags'); @@ -27,6 +28,8 @@ const Markdown = require('naturalcrit/markdown.js'); const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js'); const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js'); +import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js'; + const googleDriveIcon = require('../../googleDrive.svg'); const SAVE_TIMEOUT = 3000; @@ -41,22 +44,24 @@ const EditPage = createClass({ getInitialState : function() { return { - brew : this.props.brew, - isSaving : false, - isPending : false, - alertTrashedGoogleBrew : this.props.brew.trashed, - alertLoginToTransfer : false, - saveGoogle : this.props.brew.googleId ? true : false, - confirmGoogleTransfer : false, - error : null, - htmlErrors : Markdown.validate(this.props.brew.text), - url : '', - autoSave : true, - autoSaveWarning : false, - unsavedTime : new Date(), - currentEditorPage : 0, - displayLockMessage : this.props.brew.lock || false, - themeBundle : {} + brew : this.props.brew, + isSaving : false, + isPending : false, + alertTrashedGoogleBrew : this.props.brew.trashed, + alertLoginToTransfer : false, + saveGoogle : this.props.brew.googleId ? true : false, + confirmGoogleTransfer : false, + error : null, + htmlErrors : Markdown.validate(this.props.brew.text), + url : '', + autoSave : true, + autoSaveWarning : false, + unsavedTime : new Date(), + currentEditorViewPageNum : 1, + currentEditorCursorPageNum : 1, + currentBrewRendererPageNum : 1, + displayLockMessage : this.props.brew.lock || false, + themeBundle : {} }; }, @@ -113,16 +118,27 @@ const EditPage = createClass({ this.editor.current.update(); }, + handleEditorViewPageChange : function(pageNumber){ + this.setState({ currentEditorViewPageNum: pageNumber }); + }, + + handleEditorCursorPageChange : function(pageNumber){ + this.setState({ currentEditorCursorPageNum: pageNumber }); + }, + + handleBrewRendererPageChange : function(pageNumber){ + this.setState({ currentBrewRendererPageNum: pageNumber }); + }, + handleTextChange : function(text){ //If there are errors, run the validator on every change to give quick feedback let htmlErrors = this.state.htmlErrors; if(htmlErrors.length) htmlErrors = Markdown.validate(text); this.setState((prevState)=>({ - brew : { ...prevState.brew, text: text }, - isPending : true, - htmlErrors : htmlErrors, - currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0 + brew : { ...prevState.brew, text: text }, + isPending : true, + htmlErrors : htmlErrors, }), ()=>{if(this.state.autoSave) this.trySave();}); }, @@ -150,6 +166,16 @@ const EditPage = createClass({ return !_.isEqual(this.state.brew, this.savedBrew); }, + updateBrew : function(newData){ + this.setState((prevState)=>({ + brew : { + ...prevState.brew, + style : newData.style, + text : newData.text + } + })); + }, + trySave : function(immediate=false){ if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT); if(this.hasChanges()){ @@ -202,6 +228,9 @@ const EditPage = createClass({ htmlErrors : Markdown.validate(prevState.brew.text) })); + updateHistory(this.state.brew); + versionHistoryGarbageCollection(); + const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); const brew = this.state.brew; @@ -413,6 +442,12 @@ const EditPage = createClass({ renderer={this.state.brew.renderer} userThemes={this.props.userThemes} snippetBundle={this.state.themeBundle.snippets} + updateBrew={this.updateBrew} + onCursorPageChange={this.handleEditorCursorPageChange} + onViewPageChange={this.handleEditorViewPageChange} + currentEditorViewPageNum={this.state.currentEditorViewPageNum} + currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} + currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} /> diff --git a/client/homebrew/pages/homePage/homePage.jsx b/client/homebrew/pages/homePage/homePage.jsx index d7efcaf14..ac3be81df 100644 --- a/client/homebrew/pages/homePage/homePage.jsx +++ b/client/homebrew/pages/homePage/homePage.jsx @@ -1,7 +1,6 @@ require('./homePage.less'); const React = require('react'); const createClass = require('create-react-class'); -const _ = require('lodash'); const cx = require('classnames'); const request = require('../../utils/request-middleware.js'); const { Meta } = require('vitreum/headtags'); @@ -32,11 +31,13 @@ const HomePage = createClass({ }, getInitialState : function() { return { - brew : this.props.brew, - welcomeText : this.props.brew.text, - error : undefined, - currentEditorPage : 0, - themeBundle : {} + brew : this.props.brew, + welcomeText : this.props.brew.text, + error : undefined, + currentEditorViewPageNum : 1, + currentEditorCursorPageNum : 1, + currentBrewRendererPageNum : 1, + themeBundle : {} }; }, @@ -61,10 +62,22 @@ const HomePage = createClass({ handleSplitMove : function(){ this.editor.current.update(); }, + + handleEditorViewPageChange : function(pageNumber){ + this.setState({ currentEditorViewPageNum: pageNumber }); + }, + + handleEditorCursorPageChange : function(pageNumber){ + this.setState({ currentEditorCursorPageNum: pageNumber }); + }, + + handleBrewRendererPageChange : function(pageNumber){ + this.setState({ currentBrewRendererPageNum: pageNumber }); + }, + handleTextChange : function(text){ this.setState((prevState)=>({ - brew : { ...prevState.brew, text: text }, - currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0 + brew : { ...prevState.brew, text: text }, })); }, renderNavbar : function(){ @@ -97,12 +110,20 @@ const HomePage = createClass({ renderer={this.state.brew.renderer} showEditButtons={false} snippetBundle={this.state.themeBundle.snippets} + onCursorPageChange={this.handleEditorCursorPageChange} + onViewPageChange={this.handleEditorViewPageChange} + currentEditorViewPageNum={this.state.currentEditorViewPageNum} + currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} + currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} /> diff --git a/client/homebrew/pages/newPage/newPage.jsx b/client/homebrew/pages/newPage/newPage.jsx index 5b0f59c00..c147cd474 100644 --- a/client/homebrew/pages/newPage/newPage.jsx +++ b/client/homebrew/pages/newPage/newPage.jsx @@ -39,13 +39,15 @@ const NewPage = createClass({ const brew = this.props.brew; return { - brew : brew, - isSaving : false, - saveGoogle : (global.account && global.account.googleId ? true : false), - error : null, - htmlErrors : Markdown.validate(brew.text), - currentEditorPage : 0, - themeBundle : {} + brew : brew, + isSaving : false, + saveGoogle : (global.account && global.account.googleId ? true : false), + error : null, + htmlErrors : Markdown.validate(brew.text), + currentEditorViewPageNum : 1, + currentEditorCursorPageNum : 1, + currentBrewRendererPageNum : 1, + themeBundle : {} }; }, @@ -108,15 +110,26 @@ const NewPage = createClass({ this.editor.current.update(); }, + handleEditorViewPageChange : function(pageNumber){ + this.setState({ currentEditorViewPageNum: pageNumber }); + }, + + handleEditorCursorPageChange : function(pageNumber){ + this.setState({ currentEditorCursorPageNum: pageNumber }); + }, + + handleBrewRendererPageChange : function(pageNumber){ + this.setState({ currentBrewRendererPageNum: pageNumber }); + }, + handleTextChange : function(text){ //If there are errors, run the validator on every change to give quick feedback let htmlErrors = this.state.htmlErrors; if(htmlErrors.length) htmlErrors = Markdown.validate(text); this.setState((prevState)=>({ - brew : { ...prevState.brew, text: text }, - htmlErrors : htmlErrors, - currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0 + brew : { ...prevState.brew, text: text }, + htmlErrors : htmlErrors, })); localStorage.setItem(BREWKEY, text); }, @@ -221,6 +234,11 @@ const NewPage = createClass({ renderer={this.state.brew.renderer} userThemes={this.props.userThemes} snippetBundle={this.state.themeBundle.snippets} + onCursorPageChange={this.handleEditorCursorPageChange} + onViewPageChange={this.handleEditorViewPageChange} + currentEditorViewPageNum={this.state.currentEditorViewPageNum} + currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} + currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} /> diff --git a/client/homebrew/pages/vaultPage/vaultPage.jsx b/client/homebrew/pages/vaultPage/vaultPage.jsx index a550ec578..bad1fbd57 100644 --- a/client/homebrew/pages/vaultPage/vaultPage.jsx +++ b/client/homebrew/pages/vaultPage/vaultPage.jsx @@ -330,7 +330,7 @@ const VaultPage = (props)=>{ if(error) { const errorText = ErrorIndex()[error.HBErrorCode.toString()] || ''; - + return (

    Error: {errorText}

    diff --git a/client/homebrew/utils/versionHistory.js b/client/homebrew/utils/versionHistory.js new file mode 100644 index 000000000..ad7c6102e --- /dev/null +++ b/client/homebrew/utils/versionHistory.js @@ -0,0 +1,116 @@ +export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY'; +export const HISTORY_SLOTS = 5; + +// History values in minutes +const DEFAULT_HISTORY_SAVE_DELAYS = { + '0' : 0, + '1' : 2, + '2' : 10, + '3' : 60, + '4' : 12 * 60, + '5' : 2 * 24 * 60 +}; + +const DEFAULT_GARBAGE_COLLECT_DELAY = 28 * 24 * 60; + +const HISTORY_SAVE_DELAYS = global.config?.historyData?.HISTORY_SAVE_DELAYS ?? DEFAULT_HISTORY_SAVE_DELAYS; +const GARBAGE_COLLECT_DELAY = global.config?.historyData?.GARBAGE_COLLECT_DELAY ?? DEFAULT_GARBAGE_COLLECT_DELAY; + + + +function getKeyBySlot(brew, slot){ + return `${HISTORY_PREFIX}-${brew.shareId}-${slot}`; +}; + +function getVersionBySlot(brew, slot){ + // Read stored brew data + // - If it exists, parse data to object + // - If it doesn't exist, pass default object + const key = getKeyBySlot(brew, slot); + const storedVersion = localStorage.getItem(key); + const output = storedVersion ? JSON.parse(storedVersion) : { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true }; + return output; +}; + +function updateStoredBrew(brew, slot = 0) { + const archiveBrew = { + title : brew.title, + text : brew.text, + style : brew.style, + version : brew.version, + shareId : brew.shareId, + savedAt : brew?.savedAt || new Date(), + expireAt : new Date() + }; + + archiveBrew.expireAt.setMinutes(archiveBrew.expireAt.getMinutes() + HISTORY_SAVE_DELAYS[slot]); + + const key = getKeyBySlot(brew, slot); + localStorage.setItem(key, JSON.stringify(archiveBrew)); +} + + +export function historyExists(brew){ + return Object.keys(localStorage) + .some((key)=>{ + return key.startsWith(`${HISTORY_PREFIX}-${brew.shareId}`); + }); +} + +export function loadHistory(brew){ + const history = {}; + + // Load data from local storage to History object + for (let i = 1; i <= HISTORY_SLOTS; i++){ + history[i] = getVersionBySlot(brew, i); + }; + + return history; +} + +export function updateHistory(brew) { + const history = loadHistory(brew); + + // Walk each version position + for (let slot = HISTORY_SLOTS; slot > 0; slot--){ + const storedVersion = history[slot]; + + // If slot has expired, update all lower slots and break + if(new Date() >= new Date(storedVersion.expireAt)){ + for (let updateSlot = slot - 1; updateSlot>0; updateSlot--){ + // Move data from updateSlot to updateSlot + 1 + !history[updateSlot]?.noData && updateStoredBrew(history[updateSlot], updateSlot + 1); + }; + + // Update the most recent brew + updateStoredBrew(brew, 1); + + // Break out of data checks because we found an expired value + break; + } + }; +}; + +export function getHistoryItems(brew){ + const historyArray = []; + + for (let i = 1; i <= HISTORY_SLOTS; i++){ + historyArray.push(getVersionBySlot(brew, i)); + } + + return historyArray; +}; + +export function versionHistoryGarbageCollection(){ + Object.keys(localStorage) + .filter((key)=>{ + return key.startsWith(HISTORY_PREFIX); + }) + .forEach((key)=>{ + const collectAt = new Date(JSON.parse(localStorage.getItem(key)).savedAt); + collectAt.setMinutes(collectAt.getMinutes() + GARBAGE_COLLECT_DELAY); + if(new Date() > collectAt){ + localStorage.removeItem(key); + } + }); +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 06c4350ca..16d308bf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "expr-eval": "^2.0.2", "express": "^4.21.0", "express-async-handler": "^1.2.0", - "express-static-gzip": "2.1.7", + "express-static-gzip": "2.1.8", "fs-extra": "11.2.0", "js-yaml": "^4.1.0", "jwt-simple": "^0.5.6", @@ -6362,12 +6362,11 @@ "license": "MIT" }, "node_modules/express-static-gzip": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/express-static-gzip/-/express-static-gzip-2.1.7.tgz", - "integrity": "sha512-QOCZUC+lhPPCjIJKpQGu1Oa61Axg9Mq09Qvit8Of7kzpMuwDeMSqjjQteQS3OVw/GkENBoSBheuQDWPlngImvw==", - "license": "MIT", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/express-static-gzip/-/express-static-gzip-2.1.8.tgz", + "integrity": "sha512-g8tiJuI9Y9Ffy59ehVXvqb0hhP83JwZiLxzanobPaMbkB5qBWA8nuVgd+rcd5qzH3GkgogTALlc0BaADYwnMbQ==", "dependencies": { - "serve-static": "^1.14.1" + "serve-static": "^1.16.2" } }, "node_modules/express/node_modules/cookie": { diff --git a/package.json b/package.json index f760cc096..ff702fda7 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "expr-eval": "^2.0.2", "express": "^4.21.0", "express-async-handler": "^1.2.0", - "express-static-gzip": "2.1.7", + "express-static-gzip": "2.1.8", "fs-extra": "11.2.0", "js-yaml": "^4.1.0", "jwt-simple": "^0.5.6", @@ -138,4 +138,4 @@ "stylelint-config-recommended": "^14.0.1", "supertest": "^7.0.0" } -} +} \ No newline at end of file diff --git a/server/app.js b/server/app.js index 16c145573..f5864caae 100644 --- a/server/app.js +++ b/server/app.js @@ -1,560 +1,561 @@ -/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/ -// Set working directory to project root -process.chdir(`${__dirname}/..`); - -const _ = require('lodash'); -const jwt = require('jwt-simple'); -const express = require('express'); -const yaml = require('js-yaml'); -const app = express(); -const config = require('./config.js'); - -const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js'); -const GoogleActions = require('./googleActions.js'); -const serveCompressedStaticAssets = require('./static-assets.mv.js'); -const sanitizeFilename = require('sanitize-filename'); -const asyncHandler = require('express-async-handler'); -const templateFn = require('./../client/template.js'); - -const { DEFAULT_BREW } = require('./brewDefaults.js'); - -const { splitTextStyleAndMetadata } = require('../shared/helpers.js'); - - -const sanitizeBrew = (brew, accessType)=>{ - brew._id = undefined; - brew.__v = undefined; - if(accessType !== 'edit' && accessType !== 'shareAuthor') { - brew.editId = undefined; - } - return brew; -}; - -app.use('/', serveCompressedStaticAssets(`build`)); -app.use(require('./middleware/content-negotiation.js')); -app.use(require('body-parser').json({ limit: '25mb' })); -app.use(require('cookie-parser')()); -app.use(require('./forcessl.mw.js')); - -//Account Middleware -app.use((req, res, next)=>{ - if(req.cookies && req.cookies.nc_session){ - try { - req.account = jwt.decode(req.cookies.nc_session, config.get('secret')); - //console.log("Just loaded up JWT from cookie:"); - //console.log(req.account); - } catch (e){} - } - - req.config = { - google_client_id : config.get('google_client_id'), - google_client_secret : config.get('google_client_secret') - }; - return next(); -}); - -app.use(homebrewApi); -app.use(require('./admin.api.js')); -app.use(require('./vault.api.js')); - -const HomebrewModel = require('./homebrew.model.js').model; -const welcomeText = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8'); -const welcomeTextLegacy = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8'); -const migrateText = require('fs').readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8'); -const changelogText = require('fs').readFileSync('changelog.md', 'utf8'); -const faqText = require('fs').readFileSync('faq.md', 'utf8'); - -String.prototype.replaceAll = function(s, r){return this.split(s).join(r);}; - -const defaultMetaTags = { - site_name : 'The Homebrewery - Make your Homebrew content look legit!', - title : 'The Homebrewery', - description : 'A NaturalCrit Tool for creating authentic Homebrews using Markdown.', - image : `${config.get('publicUrl')}/thumbnail.png`, - type : 'website' -}; - -//Robots.txt -app.get('/robots.txt', (req, res)=>{ - return res.sendFile(`robots.txt`, { root: process.cwd() }); -}); - -//Home page -app.get('/', (req, res, next)=>{ - req.brew = { - text : welcomeText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Homepage', - description : 'Homepage' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Home page Legacy -app.get('/legacy', (req, res, next)=>{ - req.brew = { - text : welcomeTextLegacy, - renderer : 'legacy', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Homepage (Legacy)', - description : 'Homepage' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Legacy/Other Document -> v3 Migration Guide -app.get('/migrate', (req, res, next)=>{ - req.brew = { - text : migrateText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'v3 Migration Guide', - description : 'A brief guide to converting Legacy documents to the v3 renderer.' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Changelog page -app.get('/changelog', async (req, res, next)=>{ - req.brew = { - title : 'Changelog', - text : changelogText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'Changelog', - description : 'Development changelog.' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//FAQ page -app.get('/faq', async (req, res, next)=>{ - req.brew = { - title : 'FAQ', - text : faqText, - renderer : 'V3', - theme : '5ePHB' - }, - - req.ogMeta = { ...defaultMetaTags, - title : 'FAQ', - description : 'Frequently Asked Questions' - }; - - splitTextStyleAndMetadata(req.brew); - return next(); -}); - -//Source page -app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{ - const { brew } = req; - - const replaceStrings = { '&': '&', '<': '<', '>': '>' }; - let text = brew.text; - for (const replaceStr in replaceStrings) { - text = text.replaceAll(replaceStr, replaceStrings[replaceStr]); - } - text = `
    ${text}
    `; - res.status(200).send(text); -}); - -//Download brew source page -app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{ - const { brew } = req; - sanitizeBrew(brew, 'share'); - const prefix = 'HB - '; - - const encodeRFC3986ValueChars = (str)=>{ - return ( - encodeURIComponent(str) - .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;}) - ); - }; - - let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', ''); - if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; }; - res.set({ - 'Cache-Control' : 'no-cache', - 'Content-Type' : 'text/plain', - 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt` - }); - res.status(200).send(brew.text); -}); - -//Serve brew metadata -app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res) => { - const { brew } = req; - sanitizeBrew(brew, 'share'); - - const fields = [ 'title', 'pageCount', 'description', 'authors', 'lang', - 'published', 'views', 'shareId', 'createdAt', 'updatedAt', - 'lastViewed', 'thumbnail', 'tags' - ]; - - const metadata = fields.reduce((acc, field) => { - if (brew[field] !== undefined) acc[field] = brew[field]; - return acc; - }, {}); - res.status(200).json(metadata); -}); - -//Serve brew styling -app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);}); - -//User Page -app.get('/user/:username', async (req, res, next)=>{ - const ownAccount = req.account && (req.account.username == req.params.username); - - req.ogMeta = { ...defaultMetaTags, - title : `${req.params.username}'s Collection`, - description : 'View my collection of homebrew on the Homebrewery.' - // type : could be 'profile'? - }; - - const fields = [ - 'googleId', - 'title', - 'pageCount', - 'description', - 'authors', - 'lang', - 'published', - 'views', - 'shareId', - 'editId', - 'createdAt', - 'updatedAt', - 'lastViewed', - 'thumbnail', - 'tags' - ]; - - let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields) - .catch((err)=>{ - console.log(err); - }); - - if(ownAccount && req?.account?.googleId){ - const auth = await GoogleActions.authCheck(req.account, res); - let googleBrews = await GoogleActions.listGoogleBrews(auth) - .catch((err)=>{ - console.error(err); - }); - - if(googleBrews && googleBrews.length > 0) { - for (const brew of brews.filter((brew)=>brew.googleId)) { - const match = googleBrews.findIndex((b)=>b.editId === brew.editId); - if(match !== -1) { - brew.googleId = googleBrews[match].googleId; - brew.stubbed = true; - brew.pageCount = googleBrews[match].pageCount; - brew.renderer = googleBrews[match].renderer; - brew.version = googleBrews[match].version; - brew.webViewLink = googleBrews[match].webViewLink; - googleBrews.splice(match, 1); - } - } - - googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] })); - brews = _.concat(brews, googleBrews); - } - } - - req.brews = _.map(brews, (brew)=>{ - // Clean up brew data - brew.title = brew.title?.trim(); - brew.description = brew.description?.trim(); - return sanitizeBrew(brew, ownAccount ? 'edit' : 'share'); - }); - - return next(); -}); - -//Edit Page -app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{ - req.brew = req.brew.toObject ? req.brew.toObject() : req.brew; - - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : req.brew.title || 'Untitled Brew', - description : req.brew.description || 'No description.', - image : req.brew.thumbnail || defaultMetaTags.image, - type : 'article' - }; - - sanitizeBrew(req.brew, 'edit'); - splitTextStyleAndMetadata(req.brew); - res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save. - return next(); -})); - -//New Page from ID -app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{ - sanitizeBrew(req.brew, 'share'); - splitTextStyleAndMetadata(req.brew); - const brew = { - shareId : req.brew.shareId, - title : `CLONE - ${req.brew.title}`, - text : req.brew.text, - style : req.brew.style, - renderer : req.brew.renderer, - theme : req.brew.theme, - tags : req.brew.tags, - }; - req.brew = _.defaults(brew, DEFAULT_BREW); - - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : 'New', - description : 'Start crafting your homebrew on the Homebrewery!' - }; - - return next(); -})); - -//New Page -app.get('/new', asyncHandler(async(req, res, next)=>{ - req.userThemes = await(getUsersBrewThemes(req.account?.username)); - - req.ogMeta = { ...defaultMetaTags, - title : 'New', - description : 'Start crafting your homebrew on the Homebrewery!' - }; - - return next(); -})); - -//Share Page -app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{ - const { brew } = req; - req.ogMeta = { ...defaultMetaTags, - title : req.brew.title || 'Untitled Brew', - description : req.brew.description || 'No description.', - image : req.brew.thumbnail || defaultMetaTags.image, - type : 'article' - }; - - // increase visitor view count, do not include visits by author(s) - if(!brew.authors.includes(req.account?.username)){ - if(req.params.id.length > 12 && !brew._id) { - const googleId = brew.googleId; - const shareId = brew.shareId; - await GoogleActions.increaseView(googleId, shareId, 'share', brew) - .catch((err)=>{next(err);}); - } else { - await HomebrewModel.increaseView({ shareId: brew.shareId }); - } - }; - - brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share'); - splitTextStyleAndMetadata(req.brew); - return next(); -})); - -//Account Page -app.get('/account', asyncHandler(async (req, res, next)=>{ - const data = {}; - data.title = 'Account Information Page'; - - if(!req.account) { - res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"'); - const error = new Error('No valid account'); - error.status = 401; - error.HBErrorCode = '50'; - error.page = data.title; - return next(error); - }; - - let auth; - let googleCount = []; - if(req.account) { - if(req.account.googleId) { - try { - auth = await GoogleActions.authCheck(req.account, res, false); - } catch (e) { - auth = undefined; - console.log('Google auth check failed!'); - console.log(e); - } - if(auth.credentials.access_token) { - try { - googleCount = await GoogleActions.listGoogleBrews(auth); - } catch (e) { - googleCount = undefined; - console.log('List Google files failed!'); - console.log(e); - } - } - } - - const query = { authors: req.account.username, googleId: { $exists: false } }; - const mongoCount = await HomebrewModel.countDocuments(query) - .catch((err)=>{ - mongoCount = 0; - console.log(err); - }); - - data.accountDetails = { - username : req.account.username, - issued : req.account.issued, - googleId : Boolean(req.account.googleId), - authCheck : Boolean(req.account.googleId && auth.credentials.access_token), - mongoCount : mongoCount, - googleCount : googleCount?.length - }; - } - - req.brew = data; - - req.ogMeta = { ...defaultMetaTags, - title : `Account Page`, - description : null - }; - - return next(); -})); - -const nodeEnv = config.get('node_env'); -const isLocalEnvironment = config.get('local_environments').includes(nodeEnv); -// Local only -if(isLocalEnvironment){ - // Login - app.post('/local/login', (req, res)=>{ - const username = req.body.username; - if(!username) return; - - const payload = jwt.encode({ username: username, issued: new Date }, config.get('secret')); - return res.json(payload); - }); -} - -//Vault Page -app.get('/vault', asyncHandler(async(req, res, next)=>{ - req.ogMeta = { ...defaultMetaTags, - title : 'The Vault', - description : 'Search for Brews' - }; - return next(); -})); - -//Send rendered page -app.use(asyncHandler(async (req, res, next)=>{ - if (!req.route) return res.redirect('/'); // Catch-all for invalid routes - - const page = await renderPage(req, res); - if(!page) return; - res.send(page); -})); - -//Render the page -const renderPage = async (req, res)=>{ - // Create configuration object - const configuration = { - local : isLocalEnvironment, - publicUrl : config.get('publicUrl') ?? '', - environment : nodeEnv - }; - const props = { - version : require('./../package.json').version, - url : req.customUrl || req.originalUrl, - brew : req.brew, - brews : req.brews, - googleBrews : req.googleBrews, - account : req.account, - enable_v3 : config.get('enable_v3'), - enable_themes : config.get('enable_themes'), - config : configuration, - ogMeta : req.ogMeta, - userThemes : req.userThemes - }; - const title = req.brew ? req.brew.title : ''; - const page = await templateFn('homebrew', title, props) - .catch((err)=>{ - console.log(err); - }); - return page; -}; - -//v=====----- Error-Handling Middleware -----=====v// -//Format Errors as plain objects so all fields will appear in the string sent -const formatErrors = (key, value)=>{ - if(value instanceof Error) { - const error = {}; - Object.getOwnPropertyNames(value).forEach(function (key) { - error[key] = value[key]; - }); - return error; - } - return value; -}; - -const getPureError = (error)=>{ - return JSON.parse(JSON.stringify(error, formatErrors)); -}; - -app.use(async (err, req, res, next)=>{ - err.originalUrl = req.originalUrl; - console.error('console.log in app.js: ', err); - - if(err.originalUrl?.startsWith('/api/')) { - // console.log('API error'); - res.status(err.status || err.response?.status || 500).send(err); - return; - } - - // console.log('non-API error'); - const status = err.status || err.code || 500; - - req.ogMeta = { ...defaultMetaTags, - title : 'Error Page', - description : 'Something went wrong!' - }; - req.brew = { - ...err, - title : 'Error - Something went wrong!', - text : err.errors?.map((error)=>{return error.message;}).join('\n\n') || err.message || 'Unknown error!', - status : status, - HBErrorCode : err.HBErrorCode ?? '00', - pureError : getPureError(err) - }; - req.customUrl= '/error'; - - const page = await renderPage(req, res); - if(!page) return; - res.send(page); -}); - -app.use((req, res)=>{ - if(!res.headersSent) { - console.error('Headers have not been sent, responding with a server error.', req.url); - res.status(500).send('An error occurred and the server did not send a response. The error has been logged, please note the time this occurred and report this issue.'); - } -}); -//^=====--------------------------------------=====^// - -module.exports = { - app : app -}; +/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/ +// Set working directory to project root +process.chdir(`${__dirname}/..`); + +const _ = require('lodash'); +const jwt = require('jwt-simple'); +const express = require('express'); +const yaml = require('js-yaml'); +const app = express(); +const config = require('./config.js'); + +const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js'); +const GoogleActions = require('./googleActions.js'); +const serveCompressedStaticAssets = require('./static-assets.mv.js'); +const sanitizeFilename = require('sanitize-filename'); +const asyncHandler = require('express-async-handler'); +const templateFn = require('./../client/template.js'); + +const { DEFAULT_BREW } = require('./brewDefaults.js'); + +const { splitTextStyleAndMetadata } = require('../shared/helpers.js'); + + +const sanitizeBrew = (brew, accessType)=>{ + brew._id = undefined; + brew.__v = undefined; + if(accessType !== 'edit' && accessType !== 'shareAuthor') { + brew.editId = undefined; + } + return brew; +}; + +app.use('/', serveCompressedStaticAssets(`build`)); +app.use(require('./middleware/content-negotiation.js')); +app.use(require('body-parser').json({ limit: '25mb' })); +app.use(require('cookie-parser')()); +app.use(require('./forcessl.mw.js')); + +//Account Middleware +app.use((req, res, next)=>{ + if(req.cookies && req.cookies.nc_session){ + try { + req.account = jwt.decode(req.cookies.nc_session, config.get('secret')); + //console.log("Just loaded up JWT from cookie:"); + //console.log(req.account); + } catch (e){} + } + + req.config = { + google_client_id : config.get('google_client_id'), + google_client_secret : config.get('google_client_secret') + }; + return next(); +}); + +app.use(homebrewApi); +app.use(require('./admin.api.js')); +app.use(require('./vault.api.js')); + +const HomebrewModel = require('./homebrew.model.js').model; +const welcomeText = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8'); +const welcomeTextLegacy = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8'); +const migrateText = require('fs').readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8'); +const changelogText = require('fs').readFileSync('changelog.md', 'utf8'); +const faqText = require('fs').readFileSync('faq.md', 'utf8'); + +String.prototype.replaceAll = function(s, r){return this.split(s).join(r);}; + +const defaultMetaTags = { + site_name : 'The Homebrewery - Make your Homebrew content look legit!', + title : 'The Homebrewery', + description : 'A NaturalCrit Tool for creating authentic Homebrews using Markdown.', + image : `${config.get('publicUrl')}/thumbnail.png`, + type : 'website' +}; + +//Robots.txt +app.get('/robots.txt', (req, res)=>{ + return res.sendFile(`robots.txt`, { root: process.cwd() }); +}); + +//Home page +app.get('/', (req, res, next)=>{ + req.brew = { + text : welcomeText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Homepage', + description : 'Homepage' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); +}); + +//Home page Legacy +app.get('/legacy', (req, res, next)=>{ + req.brew = { + text : welcomeTextLegacy, + renderer : 'legacy', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Homepage (Legacy)', + description : 'Homepage' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); +}); + +//Legacy/Other Document -> v3 Migration Guide +app.get('/migrate', (req, res, next)=>{ + req.brew = { + text : migrateText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'v3 Migration Guide', + description : 'A brief guide to converting Legacy documents to the v3 renderer.' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); +}); + +//Changelog page +app.get('/changelog', async (req, res, next)=>{ + req.brew = { + title : 'Changelog', + text : changelogText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'Changelog', + description : 'Development changelog.' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); +}); + +//FAQ page +app.get('/faq', async (req, res, next)=>{ + req.brew = { + title : 'FAQ', + text : faqText, + renderer : 'V3', + theme : '5ePHB' + }, + + req.ogMeta = { ...defaultMetaTags, + title : 'FAQ', + description : 'Frequently Asked Questions' + }; + + splitTextStyleAndMetadata(req.brew); + return next(); +}); + +//Source page +app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + + const replaceStrings = { '&': '&', '<': '<', '>': '>' }; + let text = brew.text; + for (const replaceStr in replaceStrings) { + text = text.replaceAll(replaceStr, replaceStrings[replaceStr]); + } + text = `
    ${text}
    `; + res.status(200).send(text); +}); + +//Download brew source page +app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + sanitizeBrew(brew, 'share'); + const prefix = 'HB - '; + + const encodeRFC3986ValueChars = (str)=>{ + return ( + encodeURIComponent(str) + .replace(/[!'()*]/g, (char)=>{`%${char.charCodeAt(0).toString(16).toUpperCase()}`;}) + ); + }; + + let fileName = sanitizeFilename(`${prefix}${brew.title}`).replaceAll(' ', ''); + if(!fileName || !fileName.length) { fileName = `${prefix}-Untitled-Brew`; }; + res.set({ + 'Cache-Control' : 'no-cache', + 'Content-Type' : 'text/plain', + 'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt` + }); + res.status(200).send(brew.text); +}); + +//Serve brew metadata +app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{ + const { brew } = req; + sanitizeBrew(brew, 'share'); + + const fields = ['title', 'pageCount', 'description', 'authors', 'lang', + 'published', 'views', 'shareId', 'createdAt', 'updatedAt', + 'lastViewed', 'thumbnail', 'tags' + ]; + + const metadata = fields.reduce((acc, field)=>{ + if(brew[field] !== undefined) acc[field] = brew[field]; + return acc; + }, {}); + res.status(200).json(metadata); +}); + +//Serve brew styling +app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);}); + +//User Page +app.get('/user/:username', async (req, res, next)=>{ + const ownAccount = req.account && (req.account.username == req.params.username); + + req.ogMeta = { ...defaultMetaTags, + title : `${req.params.username}'s Collection`, + description : 'View my collection of homebrew on the Homebrewery.' + // type : could be 'profile'? + }; + + const fields = [ + 'googleId', + 'title', + 'pageCount', + 'description', + 'authors', + 'lang', + 'published', + 'views', + 'shareId', + 'editId', + 'createdAt', + 'updatedAt', + 'lastViewed', + 'thumbnail', + 'tags' + ]; + + let brews = await HomebrewModel.getByUser(req.params.username, ownAccount, fields) + .catch((err)=>{ + console.log(err); + }); + + if(ownAccount && req?.account?.googleId){ + const auth = await GoogleActions.authCheck(req.account, res); + let googleBrews = await GoogleActions.listGoogleBrews(auth) + .catch((err)=>{ + console.error(err); + }); + + if(googleBrews && googleBrews.length > 0) { + for (const brew of brews.filter((brew)=>brew.googleId)) { + const match = googleBrews.findIndex((b)=>b.editId === brew.editId); + if(match !== -1) { + brew.googleId = googleBrews[match].googleId; + brew.stubbed = true; + brew.pageCount = googleBrews[match].pageCount; + brew.renderer = googleBrews[match].renderer; + brew.version = googleBrews[match].version; + brew.webViewLink = googleBrews[match].webViewLink; + googleBrews.splice(match, 1); + } + } + + googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] })); + brews = _.concat(brews, googleBrews); + } + } + + req.brews = _.map(brews, (brew)=>{ + // Clean up brew data + brew.title = brew.title?.trim(); + brew.description = brew.description?.trim(); + return sanitizeBrew(brew, ownAccount ? 'edit' : 'share'); + }); + + return next(); +}); + +//Edit Page +app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{ + req.brew = req.brew.toObject ? req.brew.toObject() : req.brew; + + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : req.brew.title || 'Untitled Brew', + description : req.brew.description || 'No description.', + image : req.brew.thumbnail || defaultMetaTags.image, + type : 'article' + }; + + sanitizeBrew(req.brew, 'edit'); + splitTextStyleAndMetadata(req.brew); + res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save. + return next(); +})); + +//New Page from ID +app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{ + sanitizeBrew(req.brew, 'share'); + splitTextStyleAndMetadata(req.brew); + const brew = { + shareId : req.brew.shareId, + title : `CLONE - ${req.brew.title}`, + text : req.brew.text, + style : req.brew.style, + renderer : req.brew.renderer, + theme : req.brew.theme, + tags : req.brew.tags, + }; + req.brew = _.defaults(brew, DEFAULT_BREW); + + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : 'New', + description : 'Start crafting your homebrew on the Homebrewery!' + }; + + return next(); +})); + +//New Page +app.get('/new', asyncHandler(async(req, res, next)=>{ + req.userThemes = await(getUsersBrewThemes(req.account?.username)); + + req.ogMeta = { ...defaultMetaTags, + title : 'New', + description : 'Start crafting your homebrew on the Homebrewery!' + }; + + return next(); +})); + +//Share Page +app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{ + const { brew } = req; + req.ogMeta = { ...defaultMetaTags, + title : req.brew.title || 'Untitled Brew', + description : req.brew.description || 'No description.', + image : req.brew.thumbnail || defaultMetaTags.image, + type : 'article' + }; + + // increase visitor view count, do not include visits by author(s) + if(!brew.authors.includes(req.account?.username)){ + if(req.params.id.length > 12 && !brew._id) { + const googleId = brew.googleId; + const shareId = brew.shareId; + await GoogleActions.increaseView(googleId, shareId, 'share', brew) + .catch((err)=>{next(err);}); + } else { + await HomebrewModel.increaseView({ shareId: brew.shareId }); + } + }; + + brew.authors.includes(req.account?.username) ? sanitizeBrew(req.brew, 'shareAuthor') : sanitizeBrew(req.brew, 'share'); + splitTextStyleAndMetadata(req.brew); + return next(); +})); + +//Account Page +app.get('/account', asyncHandler(async (req, res, next)=>{ + const data = {}; + data.title = 'Account Information Page'; + + if(!req.account) { + res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"'); + const error = new Error('No valid account'); + error.status = 401; + error.HBErrorCode = '50'; + error.page = data.title; + return next(error); + }; + + let auth; + let googleCount = []; + if(req.account) { + if(req.account.googleId) { + try { + auth = await GoogleActions.authCheck(req.account, res, false); + } catch (e) { + auth = undefined; + console.log('Google auth check failed!'); + console.log(e); + } + if(auth.credentials.access_token) { + try { + googleCount = await GoogleActions.listGoogleBrews(auth); + } catch (e) { + googleCount = undefined; + console.log('List Google files failed!'); + console.log(e); + } + } + } + + const query = { authors: req.account.username, googleId: { $exists: false } }; + const mongoCount = await HomebrewModel.countDocuments(query) + .catch((err)=>{ + mongoCount = 0; + console.log(err); + }); + + data.accountDetails = { + username : req.account.username, + issued : req.account.issued, + googleId : Boolean(req.account.googleId), + authCheck : Boolean(req.account.googleId && auth.credentials.access_token), + mongoCount : mongoCount, + googleCount : googleCount?.length + }; + } + + req.brew = data; + + req.ogMeta = { ...defaultMetaTags, + title : `Account Page`, + description : null + }; + + return next(); +})); + +const nodeEnv = config.get('node_env'); +const isLocalEnvironment = config.get('local_environments').includes(nodeEnv); +// Local only +if(isLocalEnvironment){ + // Login + app.post('/local/login', (req, res)=>{ + const username = req.body.username; + if(!username) return; + + const payload = jwt.encode({ username: username, issued: new Date }, config.get('secret')); + return res.json(payload); + }); +} + +//Vault Page +app.get('/vault', asyncHandler(async(req, res, next)=>{ + req.ogMeta = { ...defaultMetaTags, + title : 'The Vault', + description : 'Search for Brews' + }; + return next(); +})); + +//Send rendered page +app.use(asyncHandler(async (req, res, next)=>{ + if(!req.route) return res.redirect('/'); // Catch-all for invalid routes + + const page = await renderPage(req, res); + if(!page) return; + res.send(page); +})); + +//Render the page +const renderPage = async (req, res)=>{ + // Create configuration object + const configuration = { + local : isLocalEnvironment, + publicUrl : config.get('publicUrl') ?? '', + environment : nodeEnv, + history : config.get('historyConfig') ?? {} + }; + const props = { + version : require('./../package.json').version, + url : req.customUrl || req.originalUrl, + brew : req.brew, + brews : req.brews, + googleBrews : req.googleBrews, + account : req.account, + enable_v3 : config.get('enable_v3'), + enable_themes : config.get('enable_themes'), + config : configuration, + ogMeta : req.ogMeta, + userThemes : req.userThemes + }; + const title = req.brew ? req.brew.title : ''; + const page = await templateFn('homebrew', title, props) + .catch((err)=>{ + console.log(err); + }); + return page; +}; + +//v=====----- Error-Handling Middleware -----=====v// +//Format Errors as plain objects so all fields will appear in the string sent +const formatErrors = (key, value)=>{ + if(value instanceof Error) { + const error = {}; + Object.getOwnPropertyNames(value).forEach(function (key) { + error[key] = value[key]; + }); + return error; + } + return value; +}; + +const getPureError = (error)=>{ + return JSON.parse(JSON.stringify(error, formatErrors)); +}; + +app.use(async (err, req, res, next)=>{ + err.originalUrl = req.originalUrl; + console.error(err); + + if(err.originalUrl?.startsWith('/api/')) { + // console.log('API error'); + res.status(err.status || err.response?.status || 500).send(err); + return; + } + + // console.log('non-API error'); + const status = err.status || err.code || 500; + + req.ogMeta = { ...defaultMetaTags, + title : 'Error Page', + description : 'Something went wrong!' + }; + req.brew = { + ...err, + title : 'Error - Something went wrong!', + text : err.errors?.map((error)=>{return error.message;}).join('\n\n') || err.message || 'Unknown error!', + status : status, + HBErrorCode : err.HBErrorCode ?? '00', + pureError : getPureError(err) + }; + req.customUrl= '/error'; + + const page = await renderPage(req, res); + if(!page) return; + res.send(page); +}); + +app.use((req, res)=>{ + if(!res.headersSent) { + console.error('Headers have not been sent, responding with a server error.', req.url); + res.status(500).send('An error occurred and the server did not send a response. The error has been logged, please note the time this occurred and report this issue.'); + } +}); +//^=====--------------------------------------=====^// + +module.exports = { + app : app +}; diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js index 6e7c36641..dd4641c09 100644 --- a/server/homebrew.api.spec.js +++ b/server/homebrew.api.spec.js @@ -934,7 +934,7 @@ brew`); expect(req.brew).toEqual(testBrew); expect(req.brew).toHaveProperty('style', '\nI Have a style!\n'); expect(res.status).toHaveBeenCalledWith(200); - expect(res.send).toHaveBeenCalledWith("\nI Have a style!\n"); + expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n'); expect(res.set).toHaveBeenCalledWith({ 'Cache-Control' : 'no-cache', 'Content-Type' : 'text/css' diff --git a/shared/naturalcrit/codeEditor/codeEditor.jsx b/shared/naturalcrit/codeEditor/codeEditor.jsx index 3186e39f1..fb69b6dcf 100644 --- a/shared/naturalcrit/codeEditor/codeEditor.jsx +++ b/shared/naturalcrit/codeEditor/codeEditor.jsx @@ -397,6 +397,11 @@ const CodeEditor = createClass({ getCursorPosition : function(){ return this.codeMirror.getCursor(); }, + getTopVisibleLine : function(){ + const rect = this.codeMirror.getWrapperElement().getBoundingClientRect(); + const topVisibleLine = this.codeMirror.lineAtHeight(rect.top, 'window'); + return topVisibleLine; + }, updateSize : function(){ this.codeMirror.refresh(); }, diff --git a/shared/naturalcrit/markdown.js b/shared/naturalcrit/markdown.js index 205063641..ef789bdd6 100644 --- a/shared/naturalcrit/markdown.js +++ b/shared/naturalcrit/markdown.js @@ -105,16 +105,16 @@ renderer.link = function (href, title, text) { // Expose `src` attribute as `--HB_src` to make the URL accessible via CSS renderer.image = function (href, title, text) { href = cleanUrl(href); - if (href === null) + if(href === null) return text; let out = `${text}"']/; diff --git a/shared/naturalcrit/splitPane/splitPane.jsx b/shared/naturalcrit/splitPane/splitPane.jsx index 606b22d40..23ae5d321 100644 --- a/shared/naturalcrit/splitPane/splitPane.jsx +++ b/shared/naturalcrit/splitPane/splitPane.jsx @@ -42,6 +42,10 @@ const SplitPane = createClass({ }); } window.addEventListener('resize', this.handleWindowResize); + + // This lives here instead of in the initial render because you cannot touch localStorage until the componant mounts. + const loadLiveScroll = window.localStorage.getItem('liveScroll') === 'true'; + this.setState({ liveScroll: loadLiveScroll }); }, componentWillUnmount : function() { @@ -89,6 +93,11 @@ const SplitPane = createClass({ userSetDividerPos : newSize }); }, + + liveScrollToggle : function() { + window.localStorage.setItem('liveScroll', String(!this.state.liveScroll)); + this.setState({ liveScroll: !this.state.liveScroll }); + }, /* unFocus : function() { if(document.selection){ @@ -120,6 +129,11 @@ const SplitPane = createClass({ onClick={()=>this.setState({ moveBrew: !this.state.moveBrew })} >
    +
    + +
    ; } }, @@ -144,9 +158,10 @@ const SplitPane = createClass({ > {React.cloneElement(this.props.children[0], { ...(this.props.showDividerButtons && { - moveBrew: this.state.moveBrew, - moveSource: this.state.moveSource, - setMoveArrows: this.setMoveArrows, + moveBrew : this.state.moveBrew, + moveSource : this.state.moveSource, + liveScroll : this.state.liveScroll, + setMoveArrows : this.setMoveArrows, }), })} diff --git a/shared/naturalcrit/splitPane/splitPane.less b/shared/naturalcrit/splitPane/splitPane.less index 831b5ce47..e5b3dd7f8 100644 --- a/shared/naturalcrit/splitPane/splitPane.less +++ b/shared/naturalcrit/splitPane/splitPane.less @@ -53,6 +53,15 @@ .tooltipRight('Jump to location in Preview'); top : 60px; } + &.lock{ + .tooltipRight('De-sync Editor and Preview locations.'); + top : 90px; + background: #666; + } + &.unlock{ + .tooltipRight('Sync Editor and Preview locations'); + top : 90px; + } &:hover{ background-color: #666; } diff --git a/themes/V3/5ePHB/snippets.js b/themes/V3/5ePHB/snippets.js index 4daa05c51..c3094abc4 100644 --- a/themes/V3/5ePHB/snippets.js +++ b/themes/V3/5ePHB/snippets.js @@ -27,35 +27,154 @@ module.exports = [ experimental : true, subsnippets : [ { - name : 'Table of Contents', + name : 'Generate Table of Contents', icon : 'fas fa-book', gen : TableOfContentsGen, experimental : true }, { - name : 'Include in ToC up to H3', - icon : 'fas fa-dice-three', + name : 'Table of Contents Individual Inclusion', + icon : 'fas fa-book', + gen : dedent `\n{{tocInclude# CHANGE # to your header level + }}\n`, + subsnippets : [ + { + name : 'Individual Inclusion H1', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH1 \n + }}\n`, + }, + { + name : 'Individual Inclusion H2', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH2 \n + }}\n`, + }, + { + name : 'Individual Inclusion H3', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH3 \n + }}\n`, + }, + { + name : 'Individual Inclusion H4', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH4 \n + }}\n`, + }, + { + name : 'Individual Inclusion H5', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH5 \n + }}\n`, + }, + { + name : 'Individual Inclusion H6', + icon : 'fas fa-book', + gen : dedent `\n{{tocIncludeH6 \n + }}\n`, + } + ] + }, + { + name : 'Table of Contents Range Inclusion', + icon : 'fas fa-book', gen : dedent `\n{{tocDepthH3 }}\n`, + subsnippets : [ + { + name : 'Include in ToC up to H3', + icon : 'fas fa-dice-three', + gen : dedent `\n{{tocDepthH3 + }}\n`, + }, + { + name : 'Include in ToC up to H4', + icon : 'fas fa-dice-four', + gen : dedent `\n{{tocDepthH4 + }}\n`, + }, + { + name : 'Include in ToC up to H5', + icon : 'fas fa-dice-five', + gen : dedent `\n{{tocDepthH5 + }}\n`, + }, + { + name : 'Include in ToC up to H6', + icon : 'fas fa-dice-six', + gen : dedent `\n{{tocDepthH6 + }}\n`, + }, + ] }, { - name : 'Include in ToC up to H4', - icon : 'fas fa-dice-four', - gen : dedent `\n{{tocDepthH4 + name : 'Table of Contents Individual Exclusion', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH1 \n }}\n`, + subsnippets : [ + { + name : 'Individual Exclusion H1', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH1 \n + }}\n`, + }, + { + name : 'Individual Exclusion H2', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH2 \n + }}\n`, + }, + { + name : 'Individual Exclusion H3', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH3 \n + }}\n`, + }, + { + name : 'Individual Exclusion H4', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH4 \n + }}\n`, + }, + { + name : 'Individual Exclusion H5', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH5 \n + }}\n`, + }, + { + name : 'Individual Exclusion H6', + icon : 'fas fa-book', + gen : dedent `\n{{tocExcludeH6 \n + }}\n`, + }, + ] }, + { - name : 'Include in ToC up to H5', - icon : 'fas fa-dice-five', - gen : dedent `\n{{tocDepthH5 - }}\n`, - }, - { - name : 'Include in ToC up to H6', - icon : 'fas fa-dice-six', - gen : dedent `\n{{tocDepthH6 - }}\n`, + name : 'Table of Contents Toggles', + icon : 'fas fa-book', + gen : `{{tocGlobalH4}}\n\n`, + subsnippets : [ + { + name : 'Enable H1-H4 all pages', + icon : 'fas fa-dice-four', + gen : `{{tocGlobalH4}}\n\n`, + }, + { + name : 'Enable H1-H5 all pages', + icon : 'fas fa-dice-five', + gen : `{{tocGlobalH5}}\n\n`, + }, + { + name : 'Enable H1-H6 all pages', + icon : 'fas fa-dice-six', + gen : `{{tocGlobalH6}}\n\n`, + }, + ] } ] }, @@ -94,7 +213,7 @@ module.exports = [ background-image: linear-gradient(-45deg, #322814, #998250, #322814); line-height: 1em; }\n\n` - } + }, ] }, diff --git a/themes/V3/5ePHB/snippets/tableOfContents.gen.js b/themes/V3/5ePHB/snippets/tableOfContents.gen.js index 49a8c168a..44c400762 100644 --- a/themes/V3/5ePHB/snippets/tableOfContents.gen.js +++ b/themes/V3/5ePHB/snippets/tableOfContents.gen.js @@ -4,9 +4,9 @@ const dedent = require('dedent-tabs').default; const mapPages = (pages)=>{ let actualPage = 0; let mappedPage = 0; // Number displayed in footer - let pageMap = []; + const pageMap = []; - pages.forEach(page => { + pages.forEach((page)=>{ actualPage++; const doSkip = page.querySelector('.skipCounting'); const doReset = page.querySelector('.resetCounting'); @@ -24,13 +24,13 @@ const mapPages = (pages)=>{ return pageMap; }; -const getMarkdown = (headings, pageMap) => { +const getMarkdown = (headings, pageMap)=>{ const levelPad = ['- ###', ' - ####', ' -', ' -', ' -', ' -']; - - let allMarkdown = []; - let depthChain = [0]; - headings.forEach(heading => { + const allMarkdown = []; + const depthChain = [0]; + + headings.forEach((heading)=>{ const page = parseInt(heading.closest('.page').id?.replace(/^p/, '')); const mappedPage = pageMap[page].mappedPage; const showPage = pageMap[page].showPage; @@ -42,14 +42,14 @@ const getMarkdown = (headings, pageMap) => { return; //If different header depth than last, remove indents until nearest higher-level header, then indent once - if (depth !== depthChain[depthChain.length -1]) { + if(depth !== depthChain[depthChain.length -1]) { while (depth <= depthChain[depthChain.length - 1]) { depthChain.pop(); } depthChain.push(depth); } - let markdown = `${levelPad[depthChain.length - 2]} [{{ ${title}}}{{ ${mappedPage}}}](#p${page})`; + const markdown = `${levelPad[depthChain.length - 2]} [{{ ${title}}}{{ ${mappedPage}}}](#p${page})`; allMarkdown.push(markdown); }); return allMarkdown.join('\n'); diff --git a/themes/V3/5ePHB/style.less b/themes/V3/5ePHB/style.less index ddffbec2f..5a2b5cf3f 100644 --- a/themes/V3/5ePHB/style.less +++ b/themes/V3/5ePHB/style.less @@ -11,6 +11,7 @@ --HB_Color_CaptionText : #766649; // Brown --HB_Color_WatercolorStain : #BBAD82; // Light brown --HB_Color_Footnotes : #C9AD6A; // Gold + --TOC : 'include'; } .useSansSerif() { @@ -797,7 +798,7 @@ // *****************************/ // Default Exclusions -// Anything not exlcuded is included, default Headers are H1, H2, and H3. +// Anything not excluded is included, default Headers are H1, H2, and H3. h4, h5, h6, @@ -808,12 +809,23 @@ h6, .noToC, .toc { --TOC: exclude; } -.tocDepthH2 :is(h1, h2) {--TOC: include; } -.tocDepthH3 :is(h1, h2, h3) {--TOC: include; } -.tocDepthH4 :is(h1, h2, h3, h4) {--TOC: include; } -.tocDepthH5 :is(h1, h2, h3, h4, h5) {--TOC: include; } -.tocDepthH6 :is(h1, h2, h3, h4, h5, h6) {--TOC: include; } +// Brew level default inclusion changes. +// These add Headers 'back' to inclusion. +.pages:has(.tocGlobalH4) { + h4 {--TOC: include; } +} + +.pages:has(.tocGlobalH5) { + h4, h5 {--TOC: include; } +} + +.pages:has(.tocGlobalH6) { + h4, h5, h6 {--TOC: include; } +} + +// Block level inclusion changes +// These include either a single (include) or a range (depth) .tocIncludeH1 h1 {--TOC: include; } .tocIncludeH2 h2 {--TOC: include; } .tocIncludeH3 h3 {--TOC: include; } @@ -821,6 +833,21 @@ h6, .tocIncludeH5 h5 {--TOC: include; } .tocIncludeH6 h6 {--TOC: include; } +.tocDepthH2 :is(h1, h2) {--TOC: include; } +.tocDepthH3 :is(h1, h2, h3) {--TOC: include; } +.tocDepthH4 :is(h1, h2, h3, h4) {--TOC: include; } +.tocDepthH5 :is(h1, h2, h3, h4, h5) {--TOC: include; } +.tocDepthH6 :is(h1, h2, h3, h4, h5, h6) {--TOC: include; } + +// Block level exclusion changes +// These exclude a single block level +.tocExcludeH1 h1 {--TOC: exclude; } +.tocExcludeH2 h2 {--TOC: exclude; } +.tocExcludeH3 h3 {--TOC: exclude; } +.tocExcludeH4 h4 {--TOC: exclude; } +.tocExcludeH5 h5 {--TOC: exclude; } +.tocExcludeH6 h6 {--TOC: exclude; } + .page:has(.partCover) { --TOC: exclude; & h1 { diff --git a/themes/V3/Blank/snippets.js b/themes/V3/Blank/snippets.js index 464fccc23..e92e757cf 100644 --- a/themes/V3/Blank/snippets.js +++ b/themes/V3/Blank/snippets.js @@ -368,6 +368,11 @@ module.exports = [ icon : 'font MrEavesRemake', gen : dedent`{{font-family:MrEavesRemake Dummy Text}}` }, + { + name : 'Pagella', + icon : 'font Pagella', + gen : dedent`{{font-family:Pagella Dummy Text}}` + }, { name : 'Solbera Imitation', icon : 'font SolberaImitationRemake', @@ -426,22 +431,40 @@ module.exports = [ ] }, - /**************** PAGE *************/ + /**************** LAYOUT *************/ { groupName : 'Print', icon : 'fas fa-print', view : 'style', snippets : [ + { + name : 'A3 Page Size', + icon : 'far fa-file', + gen : dedent`/* A3 Page Size */ + .page { + width : 297mm; + height : 420mm; + }\n\n`, + }, { name : 'A4 Page Size', icon : 'far fa-file', gen : dedent`/* A4 Page Size */ - .page{ + .page { width : 210mm; height : 296.8mm; }\n\n` }, + { + name : 'A5 Page Size', + icon : 'far fa-file', + gen : dedent`/* A5 Page Size */ + .page { + width : 148mm; + height : 210mm; + }\n\n`, + }, { name : 'Square Page Size', icon : 'far fa-file', @@ -453,6 +476,17 @@ module.exports = [ columns : unset; }\n\n` }, + { + name : 'Card Page Size', + icon : 'far fa-file', + gen : dedent`/* Card Size */ + .page { + width : 63.5mm; + height : 88.9mm; + padding : 5mm; + columns : unset; + }\n\n` + }, { name : 'Ink Friendly', icon : 'fas fa-tint', @@ -468,5 +502,5 @@ module.exports = [ }\n\n` }, ] - } + }, ]; diff --git a/themes/V3/Blank/style.less b/themes/V3/Blank/style.less index f03ca90d3..8229baa28 100644 --- a/themes/V3/Blank/style.less +++ b/themes/V3/Blank/style.less @@ -1,3 +1,4 @@ +@import (less) './themes/fonts/Blank/fonts.less'; @import (less) './themes/fonts/5e/fonts.less'; @import (less) './themes/assets/assets.less'; @import (less) './themes/fonts/iconFonts/elderberryInn.less'; diff --git a/themes/fonts/Blank/fonts.less b/themes/fonts/Blank/fonts.less new file mode 100644 index 000000000..4a3d2d1e8 --- /dev/null +++ b/themes/fonts/Blank/fonts.less @@ -0,0 +1,46 @@ +/* +TeX Gyre Pagella +License: + % Copyright 2007--2018 for TeX Gyre extensions by B. Jackowski, + % J.M. Nowacki et al. (on behalf of TeX Users Groups). Vietnamese + % characters were added by Han The Thanh. + % + % This work can be freely used and distributed under + % the GUST Font License (GFL -- see GUST-FONT-LICENSE.txt) + % which is actually an instance of the LaTeX Project Public License + % (LPPL -- see http://www.latex-project.org/lppl.txt ). + % + % This work has the maintenance status "maintained". The Current Maintainer + % of this work is Bogus\l{}aw Jackowski and Janusz M. Nowacki. + % + % This work consists of the files listed + % in the MANIFEST-TeX-Gyre-Pagella.txt file. +*/ + +@font-face { + font-family: Pagella; + src: url('../../../fonts/Blank/texgyrepagella-regular.woff2'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: Pagella; + src: url('../../../fonts/Blank/texgyrepagella-bold.woff2'); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: Pagella; + src: url('../../../fonts/Blank/texgyrepagella-italic.woff2'); + font-weight: normal; + font-style: italic; +} + +@font-face { + font-family: Pagella; + src: url('../../../fonts/Blank/texgyrepagella-bolditalic.woff2'); + font-weight: bold; + font-style: italic; +} diff --git a/themes/fonts/Blank/texgyrepagella-bold.woff2 b/themes/fonts/Blank/texgyrepagella-bold.woff2 new file mode 100644 index 000000000..5256e2d9e Binary files /dev/null and b/themes/fonts/Blank/texgyrepagella-bold.woff2 differ diff --git a/themes/fonts/Blank/texgyrepagella-bolditalic.woff2 b/themes/fonts/Blank/texgyrepagella-bolditalic.woff2 new file mode 100644 index 000000000..7384fcb1a Binary files /dev/null and b/themes/fonts/Blank/texgyrepagella-bolditalic.woff2 differ diff --git a/themes/fonts/Blank/texgyrepagella-italic.woff2 b/themes/fonts/Blank/texgyrepagella-italic.woff2 new file mode 100644 index 000000000..a6d15f26e Binary files /dev/null and b/themes/fonts/Blank/texgyrepagella-italic.woff2 differ diff --git a/themes/fonts/Blank/texgyrepagella-regular.woff2 b/themes/fonts/Blank/texgyrepagella-regular.woff2 new file mode 100644 index 000000000..c37301bca Binary files /dev/null and b/themes/fonts/Blank/texgyrepagella-regular.woff2 differ