Merge branch 'master' into fixLocalBackground

This commit is contained in:
Trevor Buckner
2026-09-23 21:38:42 -04:00
committed by GitHub
33 changed files with 2009 additions and 3707 deletions
+3
View File
@@ -82,6 +82,9 @@ jobs:
- run: - run:
name: Test - HTML sanitization name: Test - HTML sanitization
command: npm run test:safehtml command: npm run test:safehtml
- run:
name: Test - Helpers
command: npm run test:helpers
- run: - run:
name: Test - Coverage name: Test - Coverage
command: npm run test:coverage command: npm run test:coverage
+64 -10
View File
@@ -93,30 +93,83 @@ pre {
## changelog ## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Saturday 4/20/2026 - v3.22.0 ### Monday 9/07/2026 - v3.23.0
{{taskList {{taskList
##### firstmatekidd (new contributor!)
* [x] Add nav buttons to tab order for accesibility
* [x] Add title to brewRenderer iframe for accessibility
* [x] Add aria labels to toolbars
Partially fixes issue [#3032](https://github.com/naturalcrit/homebrewery/issues/3032)
##### ReinaSweet (new contributor!)
* [x] Added security to prevent scripts running in brews
##### Gazook89
* [x] Lazy load brew images for faster first load
* [x] Fix snippet menus getting cut off behind divider bar
Fixes issue [#3840](https://github.com/naturalcrit/homebrewery/issues/3840)
##### Abquintic
* [x] Fix variables not hoisting in later pages
##### 5e-Cleric ##### 5e-Cleric
* [x] Major update to editor framework (Codemirror 6) * [x] Add image preview when hovering over image syntax
Fixes issues [#3511](https://github.com/naturalcrit/homebrewery/issues/3511), [#4590](https://github.com/naturalcrit/homebrewery/issues/4590), [#4563](https://github.com/naturalcrit/homebrewery/issues/4653), [#4655](https://github.com/naturalcrit/homebrewery/issues/4655)
* [x] Fix to Admin page tab names Fixes issue [#2037](https://github.com/naturalcrit/homebrewery/issues/2037)
* [x] Remember editor theme across tabs
Fixes issue [#4858](https://github.com/naturalcrit/homebrewery/issues/4858)
* [x] Add ability to remove co-authors if you are the owner
Fixes part of issue [#4101](https://github.com/naturalcrit/homebrewery/issues/4101)
##### G-Ambatte ##### G-Ambatte
* [x] Fix white page crash on certain browsers * [x] Fix editor panel shrinking when opening dev tools
Fixes issue [#4866](https://github.com/naturalcrit/homebrewery/issues/4866)
* [x] Added security to prevent scripts running in brews
Fixes issue [#4904](https://github.com/naturalcrit/homebrewery/issues/4904)
##### Abquintic, G-Ambatte, Gazook89, 5e-Cleric
* [x] Multiple background fixes and code cleanups
}} }}
### Wednesday 5/13/2026 - v3.22.1
{{taskList
##### Frederlk (new Contributor!)
* [x] Add "share current page" to {{openSans **SHARE :fas_share_nodes:**}} menu
##### Calculuschild, 5e-Cleric
* [x] Fix brews triggering unneeded save when loaded
##### G-Ambatte
* [x] Fix brews with `$` in text getting out of sync on save
##### 5e-Cleric, Gazook89
* [x] Fix various issues with Codemirror 6
Fixes issues [#4771](https://github.com/naturalcrit/homebrewery/issues/4771), [#4783](https://github.com/naturalcrit/homebrewery/issues/4783)
}}
\page
### Saturday 4/04/2026 - v3.21.0 ### Saturday 4/04/2026 - v3.21.0
{{taskList {{taskList
##### Gazook89 ##### Gazook89
* [x] Allow custom {{openSans **:fas_table_list: SNIPPETS**}} to be inserted mid-line * [x] Allow custom {{openSans **:fas_table_list: SNIPPETS**}} dropdown to be inserted mid-line
##### abquintic ##### abquintic
* [x] Move example snippet images out of imgur (for folks without imgur access) * [x] Move example snippet images out of imgur (for folks without imgur access)
##### 5e-Cleric ##### 5e-Cleric
* [x] Add auto-suggest to tag entry input box * [x] Add auto-suggest to tag entry input box
* [x] Replace all example artwork with
* [x] Added tooltips to the {{openSans :fas_circle_info: **Properties**}} menu * [x] Added tooltips to the {{openSans :fas_circle_info: **Properties**}} menu
* [x] Removed {{openSans **SYSTEMS**}} checkboxes from {{openSans :fas_circle_info: **Properties**}} menu; instead {{openSans **TAGS**}} should be used for this purpose * [x] Removed {{openSans **SYSTEMS**}} checkboxes from {{openSans :fas_circle_info: **Properties**}} menu; instead {{openSans **TAGS**}} should be used for this purpose
* [x] Replace all AI-generated art with public domain art * [x] Replace all AI-generated art with public domain art
@@ -131,6 +184,7 @@ Fixes issues [#543](https://github.com/naturalcrit/homebrewery/issues/543), [#24
* [x] Multiple other backend fixes and refactors * [x] Multiple other backend fixes and refactors
}} }}
### Friday 1/11/2026 - v3.20.1 ### Friday 1/11/2026 - v3.20.1
{{taskList {{taskList
@@ -167,7 +221,7 @@ Fixes issue [#4559](https://github.com/naturalcrit/homebrewery/issues/4559)
##### G-Ambatte ##### G-Ambatte
* [x] Fix default save location failing on new documents * [x] Fix default save location failing on new documents
Fixes issue [#4437](https://github.com/naturalcrit/homebrewery/issues/3175) Fixes issue [#4437](https://github.com/naturalcrit/homebrewery/issues/4437)
* [x] Fix usernames with special symbols unable to open userpage * [x] Fix usernames with special symbols unable to open userpage
Fixes issue [#807](https://github.com/naturalcrit/homebrewery/issues/807) Fixes issue [#807](https://github.com/naturalcrit/homebrewery/issues/807)
@@ -326,7 +380,7 @@ Fixes issue [#1729](https://github.com/naturalcrit/homebrewery/issues/1729)
Fixes issue [#4079](https://github.com/naturalcrit/homebrewery/issues/4079) Fixes issue [#4079](https://github.com/naturalcrit/homebrewery/issues/4079)
##### Calculuschild ##### Calculuschild
* [x] `꞉꞉꞉꞉` now produces `<br>` instead of a `<div>` * [x] `꞉꞉꞉꞉` now produces `<br>` instead of a `&lt;div>`
* [x] Fix typos in tables freezing the editor * [x] Fix typos in tables freezing the editor
Fixes issue [#4059](https://github.com/naturalcrit/homebrewery/issues/4059) Fixes issue [#4059](https://github.com/naturalcrit/homebrewery/issues/4059)
+31 -46
View File
@@ -6,7 +6,7 @@ import React, { useState, useRef, useMemo, useEffect } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import MarkdownLegacy from '@shared/markdownLegacy.js'; import MarkdownLegacy from '@shared/markdownLegacy.js';
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
import ErrorBar from './errorBar/errorBar.jsx'; import ErrorBar from './errorBar/errorBar.jsx';
import ToolBar from './toolBar/toolBar.jsx'; import ToolBar from './toolBar/toolBar.jsx';
@@ -23,7 +23,6 @@ import safeHTML from './safeHTML.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m; const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m; const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m;
const PAGE_HEIGHT = 1056;
const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState'; const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
@@ -47,31 +46,25 @@ const BrewPage = (props)=>{
}; };
const pageRef = useRef(null); const pageRef = useRef(null);
const cleanText = safeHTML(props.contents); const cleanText = safeHTML(props.contents);
const pageNum = props.index + 1;
useEffect(()=>{ useEffect(()=>{
if(!pageRef.current) return; if(!pageRef.current) return;
// Observer for tracking pages within the `.pages` div // Observer for tracking which pages are at least 30% visible in the iframe
const visibleObserver = new IntersectionObserver( const visibleObserver = new IntersectionObserver(
(entries)=>{ (entries)=>entries.forEach((entry)=>{
entries.forEach((entry)=>{ props.onVisibilityChange(pageNum, entry.isIntersecting, false); // add/remove page from array of visible pages.
if(entry.isIntersecting) }),
props.onVisibilityChange(props.index + 1, true, false); // add page to array of visible pages.
else
props.onVisibilityChange(props.index + 1, false, false);
});
},
{ threshold: .3, rootMargin: '0px 0px 0px 0px' } // detect when >30% of page is within bounds. { threshold: .3, rootMargin: '0px 0px 0px 0px' } // detect when >30% of page is within bounds.
); );
// Observer for tracking the page at the center of the iframe. // Observer for tracking the page at the center of the iframe.
const centerObserver = new IntersectionObserver( const centerObserver = new IntersectionObserver(
(entries)=>{ (entries)=>entries.forEach((entry)=>{
entries.forEach((entry)=>{ if(entry.isIntersecting)
if(entry.isIntersecting) props.onVisibilityChange(pageNum, true, true); // Set this page as the center page
props.onVisibilityChange(props.index + 1, true, true); // Set this page as the center page }),
});
},
{ threshold: 0, rootMargin: '-50% 0px -50% 0px' } // Detect when the page is at the center { threshold: 0, rootMargin: '-50% 0px -50% 0px' } // Detect when the page is at the center
); );
@@ -92,7 +85,7 @@ const BrewPage = (props)=>{
//v=====--------------------< Brew Renderer Component >-------------------=====v// //v=====--------------------< Brew Renderer Component >-------------------=====v//
let renderedPages = []; let renderedPages = [];
let pageTemplates = []; const pageTemplates = [];
let rawPages = []; let rawPages = [];
const BrewRenderer = (props)=>{ const BrewRenderer = (props)=>{
@@ -100,22 +93,24 @@ const BrewRenderer = (props)=>{
text : '', text : '',
style : '', style : '',
renderer : 'legacy', renderer : 'legacy',
theme : '5ePHB',
lang : '', lang : '',
errors : [], errors : [],
currentEditorCursorPageNum : 1, currentEditorCursorPageNum : 1,
currentEditorViewPageNum : 1,
currentBrewRendererPageNum : 1, currentBrewRendererPageNum : 1,
themeBundle : {}, themeBundle : {},
onPageChange : ()=>{}, onPageChange : ()=>{},
...props ...props
}; };
const pagesRef = useRef(null);
const [visiblePages, setVisiblePages] = useState([]);
const [centerPage , setCenterPage ] = useState(1);
const [headerState , setHeaderState ] = useState(false);
const [state, setState] = useState({ const [state, setState] = useState({
isMounted : false, isMounted : false,
visibility : 'hidden', visibility : 'hidden'
visiblePages : [],
centerPage : 1
}); });
const [displayOptions, setDisplayOptions] = useState({ const [displayOptions, setDisplayOptions] = useState({
@@ -133,12 +128,6 @@ const BrewRenderer = (props)=>{
toolbarState && setDisplayOptions(toolbarState); toolbarState && setDisplayOptions(toolbarState);
}, []); }, []);
const [headerState, setHeaderState] = useState(false);
const mainRef = useRef(null);
const pagesRef = useRef(null);
const urlRef = useRef('');
if(props.renderer == 'legacy') { if(props.renderer == 'legacy') {
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY); rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
} else { } else {
@@ -146,20 +135,16 @@ const BrewRenderer = (props)=>{
} }
const handlePageVisibilityChange = (pageNum, isVisible, isCenter)=>{ const handlePageVisibilityChange = (pageNum, isVisible, isCenter)=>{
setState((prevState)=>{ setVisiblePages((prev)=>{
const updatedVisiblePages = new Set(prevState.visiblePages); const updatedVisiblePages = new Set(prev);
if(!isCenter) isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum);
isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum); return [...updatedVisiblePages].sort((a, b)=>a - b);
return {
...prevState,
visiblePages : [...updatedVisiblePages].sort((a, b)=>a - b),
centerPage : isCenter ? pageNum : prevState.centerPage
};
}); });
if(isCenter) if(isCenter) {
setCenterPage(pageNum);
props.onPageChange(pageNum); props.onPageChange(pageNum);
}
}; };
const isInView = (index)=>{ const isInView = (index)=>{
@@ -327,7 +312,7 @@ const BrewRenderer = (props)=>{
}; };
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]); const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
renderedPages = useMemo(()=>renderPages(), [props.text, displayOptions]); renderedPages = useMemo(()=>renderPages(), [props.text, centerPage, displayOptions]);
return ( return (
<> <>
@@ -341,19 +326,19 @@ const BrewRenderer = (props)=>{
: null} : null}
<ErrorBar errors={props.errors} /> <ErrorBar errors={props.errors} />
<div className='popups' ref={mainRef}> <div className='popups'>
<RenderWarnings /> <RenderWarnings />
<NotificationPopup /> <NotificationPopup />
</div> </div>
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/> <ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={visiblePages.length > 0 ? visiblePages : [centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
{/*render in iFrame so broken code doesn't crash the site.*/} {/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' title="Rendered Brew Content" initialContent={INITIAL_CONTENT} <Frame id='BrewRenderer' title='Rendered Brew Content' initialContent={INITIAL_CONTENT}
style={{ width: '100%', height: '100%', visibility: state.visibility }} style={{ width: '100%', height: '100%', visibility: state.visibility }}
contentDidMount={frameDidMount} contentDidMount={frameDidMount}
onClick={()=>{emitClick();}} onClick={()=>{emitClick();}}
sandbox="allow-same-origin allow-modals allow-top-navigation" sandbox='allow-same-origin allow-modals allow-top-navigation'
> >
<div className='brewRenderer' <div className='brewRenderer'
onKeyDown={handleControlKeys} onKeyDown={handleControlKeys}
@@ -1,7 +1,7 @@
import './notificationPopup.less'; import './notificationPopup.less';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
import Dialog from '@components/dialog.jsx'; import Dialog from '@components/dialog.jsx';
-1
View File
@@ -1,6 +1,5 @@
import 'core-js/es/string/to-well-formed.js'; // Polyfill for older browsers import 'core-js/es/string/to-well-formed.js'; // Polyfill for older browsers
import './homebrew.less'; import './homebrew.less';
import React from 'react';
import { BrowserRouter as Router, Routes, Route, useParams, useSearchParams } from 'react-router'; import { BrowserRouter as Router, Routes, Route, useParams, useSearchParams } from 'react-router';
import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js'; import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js';
+10 -28
View File
@@ -1,46 +1,28 @@
import './navbar.less'; import './navbar.less';
import React from 'react'; import React from 'react';
import createReactClass from 'create-react-class';
import Nav from './nav.jsx'; import Nav from './nav.jsx';
import PatreonNavItem from './patreon.navitem.jsx'; import PatreonNavItem from './patreon.navitem.jsx';
const Navbar = createReactClass({ const Navbar = ({ children })=>{
displayName : 'Navbar', const version = global.version || '0.0.0';
getInitialState : function() {
return {
// showNonChromeWarning: false, // uncomment if needed
ver : global.version || '0.0.0'
};
},
/* return (
renderChromeWarning : function(){ <Nav.base>
if(!this.state.showNonChromeWarning) return;
return <Nav.item className='warning' icon='fa-exclamation-triangle'>
Optimized for Chrome
<div className='dropdown'>
If you are experiencing rendering issues, use Chrome instead
</div>
</Nav.item>
},
*/
render : function(){
return <Nav.base>
<Nav.section> <Nav.section>
<Nav.logo /> <Nav.logo />
<Nav.item href='/' className='homebrewLogo'> <Nav.item href='/' className='homebrewLogo'>
<div>The Homebrewery</div> <div>The Homebrewery</div>
</Nav.item> </Nav.item>
<Nav.item newTab={true} href='/changelog' color='purple' icon='far fa-file-alt'> <Nav.item newTab={true} href='/changelog' color='purple' icon='far fa-file-alt'>
{`v${this.state.ver}`} {`v${version}`}
</Nav.item> </Nav.item>
<PatreonNavItem /> <PatreonNavItem />
{/*this.renderChromeWarning()*/} {/* this.renderChromeWarning() */}
</Nav.section> </Nav.section>
{this.props.children} {children}
</Nav.base>; </Nav.base>
} );
}); };
export default Navbar; export default Navbar;
+48 -177
View File
@@ -2,26 +2,27 @@
import './editPage.less'; import './editPage.less';
// Common imports // Common imports
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useEffectEvent } from 'react';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
import _ from 'lodash'; import _ from 'lodash';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx'
import SplitPane from '@components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from '@navbar/nav.jsx'; import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx'; import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '@navbar/newbrew.navitem.jsx'; import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '@navbar/account.navitem.jsx'; import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx'; import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '@navbar/vault.navitem.jsx'; import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx'; import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems; const { both: RecentNavItem } = RecentNavItems;
@@ -37,19 +38,13 @@ import LockNotification from './lockNotification/lockNotification.jsx';
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js'; import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
import googleDriveIcon from '../../googleDrive.svg'; import googleDriveIcon from '../../googleDrive.svg';
const SAVE_TIMEOUT = 10000;
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
const AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
const BREWKEY = 'HB_newPage_content'; const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style'; const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'HB_newPage_snippets'; const SNIPKEY = 'HB_newPage_snippets';
const METAKEY = 'HB_newPage_meta'; const METAKEY = 'HB_newPage_meta';
const useLocalStorage = false; const useLocalStorage = false;
const neverSaved = false; const sandbox = false;
const EditPage = (props)=>{ const EditPage = (props)=>{
props = { props = {
@@ -58,8 +53,6 @@ const EditPage = (props)=>{
}; };
const [currentBrew, setCurrentBrew] = useState(props.brew); const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving, setIsSaving] = useState(false);
const [lastSavedTime, setLastSavedTime] = useState(new Date());
const [saveGoogle, setSaveGoogle] = useState(!!props.brew.googleId); const [saveGoogle, setSaveGoogle] = useState(!!props.brew.googleId);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text)); const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
@@ -67,87 +60,13 @@ const EditPage = (props)=>{
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle, setThemeBundle] = useState({}); const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [alertTrashedGoogleBrew, setAlertTrashedGoogleBrew] = useState(props.brew.trashed); const [alertTrashedGoogleBrew, setAlertTrashedGoogleBrew] = useState(props.brew.trashed);
const [alertNoGoogleToTransfer, setAlertNoGoogleToTransfer] = useState(false); const [alertNoGoogleToTransfer, setAlertNoGoogleToTransfer] = useState(false);
const [alertOwnershipToTransfer, setAlertOwnershipToTransfer] = useState(false); const [alertOwnershipToTransfer, setAlertOwnershipToTransfer] = useState(false);
const [confirmGoogleTransfer, setConfirmGoogleTransfer] = useState(false); const [confirmGoogleTransfer, setConfirmGoogleTransfer] = useState(false);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
const editorRef = useRef(null); const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const saveTimeout = useRef(null);
const warnUnsavedTimeout = useRef(null);
const trySaveRef = useRef(null); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
useEffect(()=>{
const autoSavePref = JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
setAutoSaveEnabled(autoSavePref);
setWarnUnsavedChanges(!autoSavePref);
setHTMLErrors(hbfm.validate(currentBrew.text));
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
if(e.keyCode === 83) trySaveRef.current(true, true, saveGoogle);
if(e.keyCode === 80) printCurrentBrew();
if([83, 80].includes(e.keyCode)) {
e.stopPropagation();
e.preventDefault();
}
};
document.addEventListener('keydown', handleControlKeys);
window.onbeforeunload = ()=>{
if(unsavedChangesRef.current)
return 'You have unsaved changes!';
};
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
window.onbeforeunload = null;
};
}, []);
useEffect(()=>{
trySaveRef.current = trySave;
unsavedChangesRef.current = unsavedChanges;
});
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange);
if(autoSaveEnabled) trySave(false, hasChange, saveGoogle);
}, [currentBrew]);
const handleSplitMove = ()=>{
editorRef.current?.update();
};
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
if(subfield == 'renderer' || subfield == 'theme')
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
//If there are HTML errors, run the validator on every change to give quick feedback
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
setHTMLErrors(hbfm.validate(value));
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
if(useLocalStorage) {
if(field == 'text') localStorage.setItem(BREWKEY, value);
if(field == 'style') localStorage.setItem(STYLEKEY, value);
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
renderer : value.renderer,
theme : value.theme,
lang : value.lang
}));
}
};
const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({ const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({
...prevBrew, ...prevBrew,
@@ -156,15 +75,9 @@ const EditPage = (props)=>{
snippets : newData.snippets snippets : newData.snippets
})); }));
const resetWarnUnsavedTimer = ()=>{
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
clearTimeout(warnUnsavedTimeout.current);
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
};
const handleGoogleClick = ()=>{ const handleGoogleClick = ()=>{
if(global.account !== currentBrew.authors[0]) { if(currentBrew.authors.length > 0 && global.account?.username !== currentBrew.authors[0]) {
setalertOwnershipToTransfer(true); setAlertOwnershipToTransfer(true);
return; return;
} }
if(!global.account?.googleId) { if(!global.account?.googleId) {
@@ -181,7 +94,7 @@ const EditPage = (props)=>{
setAlertTrashedGoogleBrew(false); setAlertTrashedGoogleBrew(false);
setAlertNoGoogleToTransfer(false); setAlertNoGoogleToTransfer(false);
setConfirmGoogleTransfer(false); setConfirmGoogleTransfer(false);
setalertOwnershipToTransfer(false); setAlertOwnershipToTransfer(false);
}; };
const toggleGoogleStorage = (e)=>{ const toggleGoogleStorage = (e)=>{
@@ -192,25 +105,6 @@ const EditPage = (props)=>{
trySave(true, true, newSaveGoogle); trySave(true, true, newSaveGoogle);
}; };
const trySave = (immediate = false, hasChanges = true, saveToGoogle = false)=>{
clearTimeout(saveTimeout.current);
if(isSaving) return;
if(!hasChanges && !immediate) return;
const newTimeout = immediate ? 0 : SAVE_TIMEOUT;
saveTimeout.current = setTimeout(async ()=>{
setIsSaving(true);
setError(null);
await save(currentBrew, saveToGoogle)
.catch((err)=>{
setError(err);
});
setIsSaving(false);
setLastSavedTime(new Date());
if(!autoSaveEnabled) resetWarnUnsavedTimer();
}, newTimeout);
};
const save = async (brew, saveToGoogle)=>{ const save = async (brew, saveToGoogle)=>{
setHTMLErrors(hbfm.validate(brew.text)); setHTMLErrors(hbfm.validate(brew.text));
@@ -267,12 +161,11 @@ const EditPage = (props)=>{
<Nav.item className='googleDriveStorage' onClick={handleGoogleClick}> <Nav.item className='googleDriveStorage' onClick={handleGoogleClick}>
<img src={googleDriveIcon} className={saveGoogle ? '' : 'inactive'} alt='Google Drive icon' /> <img src={googleDriveIcon} className={saveGoogle ? '' : 'inactive'} alt='Google Drive icon' />
{alertOwnershipToTransfer && ( {alertOwnershipToTransfer && (
<div className='errorContainer'> <div className='errorContainer'>
You must be the Owner to transfer between the Homebrewery and Google Drive! You must be the Owner to transfer between the Homebrewery and Google Drive!
The owner of this file is {currentBrew.authors[0]} The owner of this file is {currentBrew.authors[0]}.
<br></br>
<div className='confirm' onClick={closeAlerts}> Okay </div> <div className='confirm' onClick={closeAlerts}> Okay </div>
</div> </div>
)} )}
@@ -308,60 +201,12 @@ const EditPage = (props)=>{
</Nav.item> </Nav.item>
); );
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
if(unsavedChanges && warnUnsavedChanges) {
resetWarnUnsavedTimer();
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
const text = elapsedTime === 0
? 'Autosave is OFF.'
: `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>{text}</div>
</Nav.item>;
}
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={()=>trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - No unsaved changes, and has never been saved, hide the button
if(neverSaved)
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
const toggleAutoSave = ()=>{
clearTimeout(warnUnsavedTimeout.current);
clearTimeout(saveTimeout.current);
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
setAutoSaveEnabled(!autoSaveEnabled);
setWarnUnsavedChanges(autoSaveEnabled);
};
const renderAutoSaveButton = ()=>( const renderAutoSaveButton = ()=>(
<Nav.item onClick={toggleAutoSave}> <Nav.item onClick={toggleAutoSave}>
Autosave <i className={autoSaveEnabled ? 'fas fa-power-off active' : 'fas fa-power-off'}></i> Autosave <i className={autoSaveEnabled ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
</Nav.item> </Nav.item>
); );
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const renderNavbar = ()=>{ const renderNavbar = ()=>{
return <Navbar> return <Navbar>
<Nav.section> <Nav.section>
@@ -387,6 +232,34 @@ const EditPage = (props)=>{
</Navbar>; </Navbar>;
}; };
const {
handleSplitMove,
handleBrewChange,
toggleAutoSave,
clearError,
renderSaveButton,
autoSaveEnabled,
trySave
} = useCommonEditPageFunctions({
saveGoogle,
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
sandbox,
lastSavedBrew,
editorRef,
save,
});
return ( return (
<div className='editPage sitePage'> <div className='editPage sitePage'>
<Meta name='robots' content='noindex, nofollow' /> <Meta name='robots' content='noindex, nofollow' />
@@ -416,12 +289,10 @@ const EditPage = (props)=>{
text={currentBrew.text} text={currentBrew.text}
style={currentBrew.style} style={currentBrew.style}
renderer={currentBrew.renderer} renderer={currentBrew.renderer}
theme={currentBrew.theme}
themeBundle={themeBundle} themeBundle={themeBundle}
errors={HTMLErrors} errors={HTMLErrors}
lang={currentBrew.lang} lang={currentBrew.lang}
onPageChange={setCurrentBrewRendererPageNum} onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum} currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum} currentBrewRendererPageNum={currentBrewRendererPageNum}
allowPrint={true} allowPrint={true}
@@ -1,7 +1,7 @@
import './errorPage.less'; import './errorPage.less';
import React from 'react'; import React from 'react';
import UIPage from '../basePages/uiPage/uiPage.jsx'; import UIPage from '../basePages/uiPage/uiPage.jsx';
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
import ErrorIndex from './errors/errorIndex.js'; import ErrorIndex from './errors/errorIndex.js';
const ErrorPage = ({ brew })=>{ const ErrorPage = ({ brew })=>{
+60 -137
View File
@@ -2,26 +2,27 @@
import './homePage.less'; import './homePage.less';
// Common imports // Common imports
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useEffectEvent } from 'react';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
import _ from 'lodash'; import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx'
import SplitPane from '@components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from '@navbar/nav.jsx'; import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx'; import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '@navbar/newbrew.navitem.jsx'; import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '@navbar/account.navitem.jsx'; import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx'; import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '@navbar/vault.navitem.jsx'; import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx'; import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems; const { both: RecentNavItem } = RecentNavItems;
@@ -30,149 +31,45 @@ const { both: RecentNavItem } = RecentNavItems;
import Headtags from '@vitreum/headtags.js'; import Headtags from '@vitreum/headtags.js';
const Meta = Headtags.Meta; const Meta = Headtags.Meta;
const BREWKEY = 'homebrewery-new'; const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'homebrewery-new-style'; const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'homebrewery-new-snippets'; const SNIPKEY = 'HB_newPage_snippets';
const METAKEY = 'homebrewery-new-meta'; const METAKEY = 'HB_newPage_meta';
const useLocalStorage = false; const useLocalStorage = false;
const neverSaved = true; const sandbox = true;
const HomePage =(props)=>{ const HomePage =(props)=>{
props = { props = {
brew : DEFAULT_BREW, brew : DEFAULT_BREW,
ver : '0.0.0',
...props ...props
}; };
const [currentBrew, setCurrentBrew] = useState(props.brew); const [currentBrew, setCurrentBrew] = useState(props.brew);
const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false);
const [error, setError] = useState(undefined); const [error, setError] = useState(undefined);
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text)); const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1); const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle, setThemeBundle] = useState({}); const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [autoSaveEnabled, setAutoSaveEnable] = useState(false);
const editorRef = useRef(null); const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const unsavedChangesRef = useRef(unsavedChanges);
useEffect(()=>{ const save = async (brew, saveToGoogle)=>{
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme); const res = await request
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
const handleControlKeys = (e)=>{ .send(brew)
if(!(e.ctrlKey || e.metaKey)) return; .catch((err)=>{
if(e.keyCode === 83) trySaveRef.current(true); console.error('Error Updating Local Brew');
if(e.keyCode === 80) printCurrentBrew(); setError(err);
if([83, 80].includes(e.keyCode)) {
e.stopPropagation();
e.preventDefault();
}
};
document.addEventListener('keydown', handleControlKeys);
window.onbeforeunload = ()=>{
if(unsavedChangesRef.current)
return 'You have unsaved changes!';
};
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
window.onbeforeunload = null;
};
}, []);
useEffect(()=>{
unsavedChangesRef.current = unsavedChanges;
}, [unsavedChanges]);
const save = ()=>{
request.post('/api')
.send(currentBrew)
.end((err, res)=>{
if(err) {
setError(err);
return;
}
const saved = res.body;
window.location = `/edit/${saved.editId}`;
}); });
}; if(!res) return;
useEffect(()=>{ const saved = res.body;
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current); window.onbeforeunload = null;
setUnsavedChanges(hasChange); window.location = `/edit/${saved.editId}`;
if(autoSaveEnabled) trySave(false, hasChange);
}, [currentBrew]);
const handleSplitMove = ()=>{
editorRef.current.update();
};
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
if(subfield == 'renderer' || subfield == 'theme')
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
//If there are HTML errors, run the validator on every change to give quick feedback
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
setHTMLErrors(hbfm.validate(value));
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
if(useLocalStorage) {
if(field == 'text') localStorage.setItem(BREWKEY, value);
if(field == 'style') localStorage.setItem(STYLEKEY, value);
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
renderer : value.renderer,
theme : value.theme,
lang : value.lang
}));
}
};
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
// if(unsavedChanges && warnUnsavedChanges) {
// resetWarnUnsavedTimer();
// const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
// const text = elapsedTime === 0
// ? 'Autosave is OFF.'
// : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
// return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
// Reminder...
// <div className='errorContainer'>{text}</div>
// </Nav.item>;
// }
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={save} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - No unsaved changes, and has never been saved, hide the button
if(neverSaved)
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
}; };
const renderNavbar = ()=>{ const renderNavbar = ()=>{
@@ -191,6 +88,33 @@ const HomePage =(props)=>{
</Navbar>; </Navbar>;
}; };
const {
handleSplitMove,
handleBrewChange,
clearError,
renderSaveButton,
unsavedChanges,
trySave
} = useCommonEditPageFunctions({
saveGoogle,
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
sandbox,
lastSavedBrew,
editorRef,
save,
});
return ( return (
<div className='homePage sitePage'> <div className='homePage sitePage'>
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' /> <Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
@@ -214,15 +138,14 @@ const HomePage =(props)=>{
text={currentBrew.text} text={currentBrew.text}
style={currentBrew.style} style={currentBrew.style}
renderer={currentBrew.renderer} renderer={currentBrew.renderer}
themeBundle={themeBundle}
onPageChange={setCurrentBrewRendererPageNum} onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum} currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum} currentBrewRendererPageNum={currentBrewRendererPageNum}
themeBundle={themeBundle}
/> />
</SplitPane> </SplitPane>
</div> </div>
<div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={save}> <div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={()=>trySave(true, true, saveGoogle)}>
Save current <i className='fas fa-save' /> Save current <i className='fas fa-save' />
</div> </div>
+53 -132
View File
@@ -2,39 +2,41 @@
import './newPage.less'; import './newPage.less';
// Common imports // Common imports
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useEffectEvent } from 'react';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
import _ from 'lodash'; import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js';
import useCommonEditPageFunctions from '../../utils/commonEditPageFunctions.jsx'
import SplitPane from '@components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from '@navbar/nav.jsx'; import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx'; import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '@navbar/newbrew.navitem.jsx'; import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '@navbar/account.navitem.jsx'; import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx'; import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '@navbar/vault.navitem.jsx'; import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx'; import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems; const { both: RecentNavItem } = RecentNavItems;
// Page specific imports // Page specific imports
const BREWKEY = 'HB_newPage_content'; const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style'; const STYLEKEY = 'HB_newPage_style';
const METAKEY = 'HB_newPage_metadata';
const SNIPKEY = 'HB_newPage_snippets'; const SNIPKEY = 'HB_newPage_snippets';
const METAKEY = 'HB_newPage_meta';
const SAVEKEYPREFIX = 'HB_editor_defaultSave_'; const SAVEKEYPREFIX = 'HB_editor_defaultSave_';
const useLocalStorage = true; const useLocalStorage = true;
const neverSaved = true; const sandbox = true;
const NewPage = (props)=>{ const NewPage = (props)=>{
props = { props = {
@@ -43,7 +45,6 @@ const NewPage = (props)=>{
}; };
const [currentBrew, setCurrentBrew] = useState(props.brew); const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving, setIsSaving] = useState(false);
const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false); const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text)); const [HTMLErrors, setHTMLErrors] = useState(hbfm.validate(props.brew.text));
@@ -51,35 +52,12 @@ const NewPage = (props)=>{
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle, setThemeBundle] = useState({}); const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(false);
const editorRef = useRef(null); const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const lastSavedBrew = useRef(_.cloneDeep(props.brew));
// const saveTimeout = useRef(null);
// const warnUnsavedTimeout = useRef(null);
const trySaveRef = useRef(null); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
useEffect(()=>{ useEffect(()=>{
loadBrew(); loadBrew();
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
if(e.keyCode === 83) trySaveRef.current(true);
if(e.keyCode === 80) printCurrentBrew();
if([83, 80].includes(e.keyCode)) {
e.stopPropagation();
e.preventDefault();
}
};
document.addEventListener('keydown', handleControlKeys);
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
};
}, []); }, []);
const loadBrew = ()=>{ const loadBrew = ()=>{
@@ -111,63 +89,22 @@ const NewPage = (props)=>{
window.history.replaceState({}, window.location.title, '/new/'); window.history.replaceState({}, window.location.title, '/new/');
}; };
useEffect(()=>{ const save = async (brew, saveToGoogle)=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current); //Prepare content to send to server
setUnsavedChanges(hasChange); const brewToSave = {
...brew,
if(autoSaveEnabled) trySave(false, hasChange); text : brew.text.normalize('NFC'),
}, [currentBrew]); pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm)) || []).length + 1,
textBin : undefined
useEffect(()=>{ };
trySaveRef.current = trySave;
unsavedChangesRef.current = unsavedChanges;
});
const handleSplitMove = ()=>{
editorRef.current.update();
};
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
if(subfield == 'renderer' || subfield == 'theme')
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
//If there are HTML errors, run the validator on every change to give quick feedback
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
setHTMLErrors(hbfm.validate(value));
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
if(useLocalStorage) {
if(field == 'text') localStorage.setItem(BREWKEY, value);
if(field == 'style') localStorage.setItem(STYLEKEY, value);
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
renderer : value.renderer,
theme : value.theme,
lang : value.lang
}));
}
};
const trySave = async ()=>{
setIsSaving(true);
const updatedBrew = { ...currentBrew };
splitTextStyleAndMetadata(updatedBrew);
const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm;
updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1;
const res = await request const res = await request
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`) .post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(updatedBrew) .send(brewToSave)
.catch((err)=>{ .catch((err)=>{
setIsSaving(false); console.error('Error Updating Local Brew');
setError(err); setError(err);
}); });
setIsSaving(false);
if(!res) return; if(!res) return;
const savedBrew = res.body; const savedBrew = res.body;
@@ -175,49 +112,10 @@ const NewPage = (props)=>{
localStorage.removeItem(BREWKEY); localStorage.removeItem(BREWKEY);
localStorage.removeItem(STYLEKEY); localStorage.removeItem(STYLEKEY);
localStorage.removeItem(METAKEY); localStorage.removeItem(METAKEY);
window.onbeforeunload = null;
window.location = `/edit/${savedBrew.editId}`; window.location = `/edit/${savedBrew.editId}`;
}; };
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
// if(unsavedChanges && warnUnsavedChanges) {
// resetWarnUnsavedTimer();
// const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
// const text = elapsedTime === 0
// ? 'Autosave is OFF.'
// : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
// return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
// Reminder...
// <div className='errorContainer'>{text}</div>
// </Nav.item>;
// }
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={trySave} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - No unsaved changes, and has never been saved, hide the button
if(neverSaved)
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const renderNavbar = ()=>( const renderNavbar = ()=>(
<Navbar> <Navbar>
<Nav.section> <Nav.section>
@@ -238,6 +136,31 @@ const NewPage = (props)=>{
</Navbar> </Navbar>
); );
const {
handleSplitMove,
handleBrewChange,
clearError,
renderSaveButton
} = useCommonEditPageFunctions({
saveGoogle,
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
sandbox,
lastSavedBrew,
editorRef,
save,
});
return ( return (
<div className='newPage sitePage'> <div className='newPage sitePage'>
{renderNavbar()} {renderNavbar()}
@@ -260,12 +183,10 @@ const NewPage = (props)=>{
text={currentBrew.text} text={currentBrew.text}
style={currentBrew.style} style={currentBrew.style}
renderer={currentBrew.renderer} renderer={currentBrew.renderer}
theme={currentBrew.theme}
themeBundle={themeBundle} themeBundle={themeBundle}
errors={HTMLErrors} errors={HTMLErrors}
lang={currentBrew.lang} lang={currentBrew.lang}
onPageChange={setCurrentBrewRendererPageNum} onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum} currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum} currentBrewRendererPageNum={currentBrewRendererPageNum}
allowPrint={true} allowPrint={true}
+41 -14
View File
@@ -12,12 +12,15 @@ const { both: RecentNavItem } = RecentNavItems;
import Account from '@navbar/account.navitem.jsx'; import Account from '@navbar/account.navitem.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import request from '../../utils/request-middleware.js';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
const SharePage = (props)=>{ const SharePage = (props)=>{
const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props; const { disableMeta = false } = props;
const [currentBrew, setCurrentBrew] = useState(props.brew || DEFAULT_BREW_LOAD);
const [themeBundle, setThemeBundle] = useState({}); const [themeBundle, setThemeBundle] = useState({});
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
@@ -35,9 +38,33 @@ const SharePage = (props)=>{
} }
}; };
const fetchUpdatedBrew = async ()=>{
const response = await request
.get(`/api/fetch/${currentBrew.shareId}`)
.catch((error)=>{
console.log('error at fetching updated brew: ', error);
});
if(response.ok && !!response.body.brew) {
setCurrentBrew(response.body.brew);
}
};
useEffect(()=>{ useEffect(()=>{
document.addEventListener('keydown', handleControlKeys); document.addEventListener('keydown', handleControlKeys);
fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme); fetchThemeBundle(undefined, setThemeBundle, currentBrew.renderer, currentBrew.theme);
// listen for changes in the brew version
const eventSource = new EventSource('/stream');
eventSource.addEventListener('message', (evt)=>{
const messageData = JSON.parse(evt.data);
if(messageData.eventType == 'brewUpdated'){
if(messageData.shareId == currentBrew.shareId && messageData.version != currentBrew.version) {
console.log('should fetch brew');
fetchUpdatedBrew();
}
}
});
return ()=>{ return ()=>{
document.removeEventListener('keydown', handleControlKeys); document.removeEventListener('keydown', handleControlKeys);
@@ -45,13 +72,13 @@ const SharePage = (props)=>{
}, []); }, []);
const processShareId = ()=>{ const processShareId = ()=>{
return brew.googleId && !brew.stubbed ? brew.googleId + brew.shareId : brew.shareId; return currentBrew.googleId && !currentBrew.stubbed ? currentBrew.googleId + currentBrew.shareId : currentBrew.shareId;
}; };
const renderEditLink = ()=>{ const renderEditLink = ()=>{
if(!brew.editId) return null; if(!currentBrew.editId) return null;
const editLink = brew.googleId && ! brew.stubbed ? brew.googleId + brew.editId : brew.editId; const editLink = currentBrew.googleId && ! currentBrew.stubbed ? currentBrew.googleId + currentBrew.editId : currentBrew.editId;
return ( return (
<Nav.item color='orange' icon='fas fa-pencil-alt' href={`/edit/${editLink}`}> <Nav.item color='orange' icon='fas fa-pencil-alt' href={`/edit/${editLink}`}>
@@ -62,7 +89,7 @@ const SharePage = (props)=>{
const titleEl = ( const titleEl = (
<Nav.item className='brewTitle' style={disableMeta ? { cursor: 'default' } : {}}> <Nav.item className='brewTitle' style={disableMeta ? { cursor: 'default' } : {}}>
{brew.title} {currentBrew.title}
</Nav.item> </Nav.item>
); );
@@ -71,11 +98,11 @@ const SharePage = (props)=>{
<Meta name='robots' content='noindex, nofollow' /> <Meta name='robots' content='noindex, nofollow' />
<Navbar> <Navbar>
<Nav.section className='titleSection'> <Nav.section className='titleSection'>
{disableMeta ? titleEl : <MetadataNav brew={brew}>{titleEl}</MetadataNav>} {disableMeta ? titleEl : <MetadataNav brew={currentBrew}>{titleEl}</MetadataNav>}
</Nav.section> </Nav.section>
<Nav.section> <Nav.section>
{brew.shareId && ( {currentBrew.shareId && (
<> <>
<PrintNavItem /> <PrintNavItem />
<Nav.dropdown> <Nav.dropdown>
@@ -108,18 +135,18 @@ const SharePage = (props)=>{
</Nav.dropdown> </Nav.dropdown>
</> </>
)} )}
<RecentNavItem brew={brew} storageKey='view' /> <RecentNavItem brew={currentBrew} storageKey='view' />
<Account /> <Account />
</Nav.section> </Nav.section>
</Navbar> </Navbar>
<div className='content'> <div className='content'>
<BrewRenderer <BrewRenderer
text={brew.text} text={currentBrew.text}
style={brew.style} style={currentBrew.style}
lang={brew.lang} lang={currentBrew.lang}
renderer={brew.renderer} renderer={currentBrew.renderer}
theme={brew.theme} theme={currentBrew.theme}
themeBundle={themeBundle} themeBundle={themeBundle}
onPageChange={handleBrewRendererPageChange} onPageChange={handleBrewRendererPageChange}
currentBrewRendererPageNum={currentBrewRendererPageNum} currentBrewRendererPageNum={currentBrewRendererPageNum}
@@ -0,0 +1,184 @@
import React, { useState, useEffect, useEffectEvent, useRef } from 'react';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import _ from 'lodash';
import Nav from '@navbar/nav.jsx';
const AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
const SAVE_TIMEOUT = 10000; //Autosave 10 seconds after last change
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
export default function useCommonEditPageFunctions(dependencies) {
const {
saveGoogle,
setError,
setThemeBundle,
HTMLErrors,
setHTMLErrors,
currentBrew,
setCurrentBrew,
useLocalStorage,
BREWKEY,
STYLEKEY,
SNIPKEY,
METAKEY,
hbfm,
sandbox,
lastSavedBrew,
editorRef,
save,
} = dependencies;
const [isSaving , setIsSaving] = useState(false);
const [lastSavedTime , setLastSavedTime] = useState(new Date());
const [autoSaveEnabled , setAutoSaveEnabled] = useState(!sandbox);
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
const [unsavedChanges , setUnsavedChanges] = useState(false);
const unsavedChangesRef = useRef(unsavedChanges); // onBeforeUnload lives outside React and needs ref to unsavedChanges
const warnUnsavedTimeout = useRef(null); // timers live outside React and need ref to consistently track time
const saveTimeout = useRef(null);
//==--------- Page setup ----------==//
useEffect(()=>{
const autoSavePref = !sandbox && JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
setAutoSaveEnabled(autoSavePref);
setWarnUnsavedChanges(!autoSavePref);
setHTMLErrors(hbfm.validate(currentBrew.text));
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
if(e.keyCode === 83) trySave(true, true, saveGoogle);
if(e.keyCode === 80) printCurrentBrew();
if([83, 80].includes(e.keyCode)) {
e.stopPropagation();
e.preventDefault();
}
};
document.addEventListener('keydown', handleControlKeys);
window.onbeforeunload = ()=>{
if(unsavedChangesRef.current)
return 'You have unsaved changes!';
};
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
window.onBeforeUnload = null;
};
}, []);
//======----- Check for unsaved changes and autosave if enabled -----======
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange);
unsavedChangesRef.current = hasChange;
if(autoSaveEnabled) trySave(false, hasChange, saveGoogle);
}, [currentBrew]);
const resetWarnUnsavedTimer = ()=>{
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
clearTimeout(warnUnsavedTimeout.current);
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
};
const handleSplitMove = ()=>{
editorRef.current.update();
};
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
if(subfield == 'renderer' || subfield == 'theme')
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
//If there are HTML errors, run the validator on every change to give quick feedback
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
setHTMLErrors(hbfm.validate(value));
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
if(useLocalStorage) {
if(field == 'text') localStorage.setItem(BREWKEY, value);
if(field == 'style') localStorage.setItem(STYLEKEY, value);
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
renderer : value.renderer,
theme : value.theme,
lang : value.lang
}));
}
};
const toggleAutoSave = ()=>{
clearTimeout(warnUnsavedTimeout.current);
clearTimeout(saveTimeout.current);
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
setAutoSaveEnabled(!autoSaveEnabled);
setWarnUnsavedChanges(autoSaveEnabled);
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const trySave = useEffectEvent((forceSave = false, hasChanges = true, saveToGoogle = false)=>{
clearTimeout(saveTimeout.current);
if(isSaving) return;
if(!forceSave && !hasChanges) return;
const newTimeout = forceSave ? 0 : SAVE_TIMEOUT;
saveTimeout.current = setTimeout(async ()=>{
setIsSaving(true);
setError(null);
await save(currentBrew, saveToGoogle)
.catch((err)=>{
setError(err);
});
setIsSaving(false);
setLastSavedTime(new Date());
if(!autoSaveEnabled) resetWarnUnsavedTimer();
}, newTimeout);
});
const renderSaveButton = ()=>{
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
if(unsavedChanges && warnUnsavedChanges) {
resetWarnUnsavedTimer();
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
const text = elapsedTime === 0
? `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}.`
: `Autosave is OFF${sandbox ? ' for this sandbox page' : ''}, and you haven't saved for ${elapsedTime} minutes.`;
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>{text}</div>
</Nav.item>;
}
if(unsavedChanges)
return <Nav.item className='save' onClick={()=>trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now</Nav.item>;
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
if(sandbox)
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
return <Nav.item className='save saved'>saved</Nav.item>;
};
return {
handleSplitMove,
handleBrewChange,
toggleAutoSave,
clearError,
trySave,
renderSaveButton,
autoSaveEnabled,
unsavedChanges,
}
}
+883 -2734
View File
File diff suppressed because it is too large Load Diff
+28 -27
View File
@@ -1,7 +1,7 @@
{ {
"name": "homebrewery", "name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown", "description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.22.0", "version": "3.23.0",
"type": "module", "type": "module",
"engines": { "engines": {
"npm": ">=10.8 <12", "npm": ">=10.8 <12",
@@ -39,6 +39,7 @@
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace", "test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
"test:route": "jest tests/routes/static-pages.test.js --verbose", "test:route": "jest tests/routes/static-pages.test.js --verbose",
"test:safehtml": "jest tests/html/safeHTML.test.js --verbose", "test:safehtml": "jest tests/html/safeHTML.test.js --verbose",
"test:helpers": "jest tests/html/helpers.test.js --verbose",
"phb": "node --experimental-require-module scripts/phb.js", "phb": "node --experimental-require-module scripts/phb.js",
"prod": "set NODE_ENV=production && npm run build", "prod": "set NODE_ENV=production && npm run build",
"postinstall": "npm run build", "postinstall": "npm run build",
@@ -86,13 +87,13 @@
] ]
}, },
"dependencies": { "dependencies": {
"@babel/core": "^8.0.1", "@babel/core": "^8.0.6",
"@babel/plugin-transform-runtime": "^8.0.1", "@babel/plugin-transform-runtime": "^8.0.6",
"@babel/preset-env": "^8.0.2", "@babel/preset-env": "^8.0.6",
"@babel/preset-react": "^8.0.1", "@babel/preset-react": "^8.0.1",
"@babel/runtime": "^8.0.0", "@babel/runtime": "^8.0.5",
"@codemirror/autocomplete": "^6.20.3", "@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.0", "@codemirror/commands": "^6.11.1",
"@codemirror/highlight": "^0.19.8", "@codemirror/highlight": "^0.19.8",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-javascript": "^6.2.5",
@@ -100,17 +101,17 @@
"@codemirror/language": "^6.12.2", "@codemirror/language": "^6.12.2",
"@codemirror/language-data": "^6.5.2", "@codemirror/language-data": "^6.5.2",
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.7.5",
"@codemirror/view": "^6.43.9", "@codemirror/view": "^6.43.12",
"@dmsnell/diff-match-patch": "^1.1.0", "@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^21.0.0", "@googleapis/drive": "^26.0.0",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@oddbird/css-anchor-positioning": "^0.10.2", "@oddbird/css-anchor-positioning": "^0.10.2",
"@sanity/diff-match-patch": "^3.2.0", "@sanity/diff-match-patch": "^3.2.0",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
"body-parser": "^2.3.0", "body-parser": "^2.3.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror-5-themes": "^1.5.1", "codemirror-5-themes": "^1.5.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"core-js": "^3.50.0", "core-js": "^3.50.0",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -118,58 +119,58 @@
"dedent": "^1.7.2", "dedent": "^1.7.2",
"express": "^5.1.0", "express": "^5.1.0",
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.1", "express-static-gzip": "3.0.2",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"fs-extra": "^11.3.5", "fs-extra": "^11.3.5",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"hbmarkedwrapper": "^1.0.0",
"idb-keyval": "^6.2.5", "idb-keyval": "^6.2.5",
"js-yaml": "^5.3.0", "js-yaml": "^5.4.2",
"jwt-simple": "^0.5.6", "jwt-simple": "^0.5.6",
"less": "^4.8.1", "less": "^4.9.1",
"lodash": "^4.18.1", "lodash": "^4.18.1",
"marked": "15.0.12", "marked": "15.0.12",
"marked-alignment-paragraphs": "^1.0.0", "marked-alignment-paragraphs": "^1.0.0",
"marked-definition-lists": "^1.0.1", "marked-definition-lists": "^1.0.1",
"marked-diagrams-markdeep": "^1.0.1", "marked-diagrams-markdeep": "^1.0.1",
"marked-emoji": "^2.0.3", "marked-emoji": "^3.0.0",
"marked-extended-tables": "^2.0.1", "marked-extended-tables": "^2.0.1",
"marked-gfm-heading-id": "^4.1.4", "marked-gfm-heading-id": "^4.1.4",
"marked-hbfm": "^1.0.1",
"marked-nonbreaking-spaces": "^1.0.1", "marked-nonbreaking-spaces": "^1.0.1",
"marked-smartypants-lite": "^1.0.3", "marked-smartypants-lite": "^1.0.3",
"marked-subsuper-text": "^1.0.4", "marked-subsuper-text": "^1.0.4",
"marked-variables": "^1.0.5", "marked-variables": "^1.0.5",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.31.0",
"mongoose": "^9.9.3", "mongoose": "^9.10.1",
"nanoid": "6.0.1", "nanoid": "6.0.1",
"nconf": "^0.13.0", "nconf": "^0.13.0",
"node": "^26.7.0", "node": "^26.9.0",
"prettier": "^3.8.1", "prettier": "^3.9.8",
"react": "^19.2.7", "react": "^19.3.0",
"react-dom": "^19.2.7", "react-dom": "^19.3.0",
"react-frame-component": "^5.3.2", "react-frame-component": "^5.3.2",
"react-router": "^8.3.0", "react-router": "^8.4.0",
"sanitize-filename": "1.6.4", "sanitize-filename": "1.6.4",
"superagent": "^10.2.1" "superagent": "^10.2.1"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^5.3.0", "@stylistic/stylelint-plugin": "^5.3.0",
"babel-jest": "^30.4.1", "babel-jest": "^30.5.2",
"babel-plugin-transform-import-meta": "^3.0.0", "babel-plugin-transform-import-meta": "^3.0.0",
"eslint": "9.7", "eslint": "9.7",
"eslint-plugin-jest": "^29.15.1", "eslint-plugin-jest": "^29.15.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^16.4.0", "globals": "^16.4.0",
"jest": "^30.4.2", "jest": "^30.5.2",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"jsdom": "^30.0.1", "jsdom": "^30.1.0",
"jsdom-global": "^3.0.2", "jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^17.11.1", "stylelint": "^17.15.0",
"stylelint-config-recess-order": "^7.7.0", "stylelint-config-recess-order": "^7.7.0",
"stylelint-config-recommended": "^18.0.0", "stylelint-config-recommended": "^18.0.0",
"supertest": "^7.1.4", "supertest": "^7.1.4",
"vite": "^8.2.1" "vite": "^8.3.0"
} }
} }
+38 -350
View File
@@ -1,4 +1,4 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/ /*eslint max-lines: ["warn", {"max": 400, "skipBlankLines": true, "skipComments": true}]*/
// Set working directory to project root // Set working directory to project root
import { dirname } from 'path'; import { dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -14,25 +14,25 @@ import express from 'express';
import config from './config.js'; import config from './config.js';
import path from 'path'; import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
import api from './homebrew.api.js'; import api from './homebrew.api.js';
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = api; const { homebrewApi, getBrew, getCSS } = api;
import adminApi from './admin.api.js'; import adminApi from './admin.api.js';
import vaultApi from './vault.api.js'; import vaultApi from './vault.api.js';
import GoogleActions from './googleActions.js'; import pageRoutes from './page-routes.js';
import serveCompressedStaticAssets from './static-assets.mv.js'; import serveCompressedStaticAssets from './static-assets.mv.js';
import sanitizeFilename from 'sanitize-filename';
import asyncHandler from 'express-async-handler'; import asyncHandler from 'express-async-handler';
import { model as HomebrewModel } from './homebrew.model.js'; import { model as HomebrewModel } from './homebrew.model.js';
import { DEFAULT_BREW } from './brewDefaults.js';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
//==== Middleware Imports ====// //==== Middleware Imports ====//
import contentNegotiation from './middleware/content-negotiation.js'; import contentNegotiation from './middleware/content-negotiation.js';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import forceSSL from './forcessl.mw.js'; import forceSSL from './forcessl.mw.js';
import Stream from './eventStreamSource.js';
import dbCheck from './middleware/dbCheck.js'; import dbCheck from './middleware/dbCheck.js';
import cors from 'cors'; import cors from 'cors';
@@ -116,12 +116,6 @@ export default async function createApp(vite) {
app.use(adminApi(vite)); app.use(adminApi(vite));
app.use(vaultApi); app.use(vaultApi);
const welcomeText = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
const welcomeTextLegacy = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
const migrateText = fs.readFileSync('./client/homebrew/pages/homePage/migrate.md', 'utf8');
const changelogText = fs.readFileSync('changelog.md', 'utf8');
const faqText = fs.readFileSync('faq.md', 'utf8');
String.prototype.replaceAll = function(s, r){return this.split(s).join(r);}; String.prototype.replaceAll = function(s, r){return this.split(s).join(r);};
const defaultMetaTags = { const defaultMetaTags = {
@@ -132,133 +126,25 @@ export default async function createApp(vite) {
type : 'website' type : 'website'
}; };
app.use(pageRoutes({
defaultMetaTags,
HomebrewModel,
sanitizeBrew,
}));
//Robots.txt //Robots.txt
app.get('/robots.txt', (req, res)=>{ app.get('/robots.txt', (req, res)=>{
return res.sendFile(`robots.txt`, { root: process.cwd() }); return res.sendFile(`robots.txt`, { root: process.cwd() });
}); });
//serve brew for sharepage rerender
//Home page app.get('/api/fetch/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res) => {
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 { brew } = req;
brew.authors.includes(req.account?.username)
const replaceStrings = { '&': '&amp;', '<': '&lt;', '>': '&gt;' }; ? sanitizeBrew(brew, 'shareAuthor')
let text = brew.text; : sanitizeBrew(brew, 'share');
for (const replaceStr in replaceStrings) { splitTextStyleAndMetadata(brew);
text = text.replaceAll(replaceStr, replaceStrings[replaceStr]); res.json({ brew });
} }));
text = `<code><pre style="white-space: pre-wrap;">${text}</pre></code>`;
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 //Serve brew metadata
app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{ app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{
@@ -280,78 +166,6 @@ export default async function createApp(vite) {
//Serve brew styling //Serve brew styling
app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);}); app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);});
//User Page
app.get('/user/:username', dbCheck, 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);
});
brews.forEach((brew)=>brew.stubbed = true); //All brews from MongoDB are "stubbed"
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 stub matches file from Google, use Google metadata over stub metadata
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.pageCount = googleBrews[match].pageCount;
brew.renderer = googleBrews[match].renderer;
brew.version = googleBrews[match].version;
brew.webViewLink = googleBrews[match].webViewLink;
googleBrews.splice(match, 1);
}
}
//Remaining unstubbed google brews display current user as author
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();
});
//Change author name on brews //Change author name on brews
app.put('/api/user/rename', dbCheck, async (req, res)=>{ app.put('/api/user/rename', dbCheck, async (req, res)=>{
const { username, newUsername } = req.body; const { username, newUsername } = req.body;
@@ -380,143 +194,26 @@ export default async function createApp(vite) {
} }
}); });
//Edit Page // Create Event Stream source for pages to listen to
app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{ app.get('/stream', (req, res)=>{
req.brew = req.brew.toObject ? req.brew.toObject() : req.brew; res.writeHead(200, {
'Content-Type' : 'text/event-stream',
'Cache-Control' : 'no-cache',
'Connection' : 'keep-alive',
'Content-Encoding' : 'none'
});
req.userThemes = await(getUsersBrewThemes(req.account?.username)); Stream.on('sendUpdate', (event, data)=>{
console.log('Event:', event, '\nData:', data);
res.write(`data: ${JSON.stringify({ ...data, eventType: event })}\n\n`);
});
});
req.ogMeta = { ...defaultMetaTags, // After Stream starts, send initStream event
title : req.brew.title || 'Untitled Brew', setTimeout(()=>{
description : req.brew.description || 'No description.', Stream.emit('sendUpdate', 'initStream', { time: new Date });
image : req.brew.thumbnail || defaultMetaTags.image, }, 1000);
locale : req.brew.lang,
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,
snippets : req.brew.snippets
};
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', dbCheck, asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
const { brew } = req;
req.ogMeta = { ...defaultMetaTags,
title : `${req.brew.title || 'Untitled Brew'} - ${req.brew.authors[0] || 'No author.'}`,
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', dbCheck, 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) {
auth = await GoogleActions.authCheck(req.account, res, false);
googleCount = await GoogleActions.listGoogleBrews(auth)
.catch((err)=>{
console.error(err);
});
}
const query = { authors: req.account.username, googleId: { $exists: false } };
const mongoCount = await HomebrewModel.countDocuments(query)
.catch((err)=>{
console.log(err);
return 0;
});
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();
}));
// Local only // Local only
if(isLocalEnvironment){ if(isLocalEnvironment){
@@ -534,15 +231,6 @@ export default async function createApp(vite) {
app.use('/staticImages', express.static(config.get('hb_images') && fs.existsSync(config.get('hb_images')) ? config.get('hb_images') :'staticImages')); app.use('/staticImages', express.static(config.get('hb_images') && fs.existsSync(config.get('hb_images')) ? config.get('hb_images') :'staticImages'));
app.use('/staticFonts', express.static(config.get('hb_fonts') && fs.existsSync(config.get('hb_fonts')) ? config.get('hb_fonts'):'staticFonts')); app.use('/staticFonts', express.static(config.get('hb_fonts') && fs.existsSync(config.get('hb_fonts')) ? config.get('hb_fonts'):'staticFonts'));
//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 //Send rendered page
app.use(asyncHandler(async (req, res, next)=>{ app.use(asyncHandler(async (req, res, next)=>{
if(!req.route) return res.redirect('/'); // Catch-all for invalid routes if(!req.route) return res.redirect('/'); // Catch-all for invalid routes
+9
View File
@@ -0,0 +1,9 @@
import { EventEmitter } from 'events';
const Stream = new EventEmitter;
export default {
emit : function(event) {return Stream.emit(event, ...([...arguments].slice(1)));}, // Arguments doesn't work for arrow functions
on : (event, listener)=>{return Stream.on(event, listener);},
off : (event, listener)=>{return Stream.off(event, listener);}
};
+8 -5
View File
@@ -4,7 +4,7 @@ import { model as HomebrewModel } from './homebrew.model.js';
import express from 'express'; import express from 'express';
import zlib from 'zlib'; import zlib from 'zlib';
import GoogleActions from './googleActions.js'; import GoogleActions from './googleActions.js';
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import asyncHandler from 'express-async-handler'; import asyncHandler from 'express-async-handler';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
@@ -21,6 +21,8 @@ const router = express.Router();
import { DEFAULT_BREW, DEFAULT_BREW_LOAD } from './brewDefaults.js'; import { DEFAULT_BREW, DEFAULT_BREW_LOAD } from './brewDefaults.js';
import Themes from '../themes/themes.json' with { type: 'json' }; import Themes from '../themes/themes.json' with { type: 'json' };
import Stream from './eventStreamSource.js';
const isStaticTheme = (renderer, themeName)=>{ const isStaticTheme = (renderer, themeName)=>{
return Themes[renderer]?.[themeName] !== undefined; return Themes[renderer]?.[themeName] !== undefined;
}; };
@@ -168,8 +170,7 @@ const api = {
const googleBrew = await GoogleActions.getGoogleBrew(oAuth2Client, googleId, id, accessType) const googleBrew = await GoogleActions.getGoogleBrew(oAuth2Client, googleId, id, accessType)
.catch((googleError)=>{ .catch((googleError)=>{
const reason = googleError.errors?.[0].reason; if(googleError.code === 404 || googleError.status === 404)
if(reason == 'notFound')
throw { ...googleError, HBErrorCode: '02', authors: stub?.authors, account: req.account?.username }; throw { ...googleError, HBErrorCode: '02', authors: stub?.authors, account: req.account?.username };
else else
throw { ...googleError, HBErrorCode: '01' }; throw { ...googleError, HBErrorCode: '01' };
@@ -501,6 +502,8 @@ const api = {
saved.textBin = undefined; // Remove textBin from the saved object to save bandwidth saved.textBin = undefined; // Remove textBin from the saved object to save bandwidth
Stream.emit('sendUpdate', 'brewUpdated', { time: new Date, shareId: brew.shareId, version: brew.version });
res.status(200).send(saved); res.status(200).send(saved);
}, },
deleteGoogleBrew : async (account, id, editId, res)=>{ deleteGoogleBrew : async (account, id, editId, res)=>{
@@ -574,9 +577,9 @@ const api = {
router.use(dbCheck); router.use(dbCheck);
router.post('/api', checkClientVersion, asyncHandler(api.newBrew)); router.post('/api', checkClientVersion, asyncHandler(api.newBrew));
router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew)); router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew)); //alt endpoint, unused
router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew)); router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew));
router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew)); router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew)); //alt endpoint, unused
router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew)); router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle)); router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
+380
View File
@@ -0,0 +1,380 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
// page-routes.js
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(`${__dirname}/..`);
import _ from 'lodash';
import express from 'express';
import asyncHandler from 'express-async-handler';
import fs from 'fs';
//==== Middleware Imports ====//
import dbCheck from './middleware/dbCheck.js';
import sanitizeFilename from 'sanitize-filename';
import { DEFAULT_BREW } from './brewDefaults.js';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
import GoogleActions from './googleActions.js';
import api from './homebrew.api.js';
const { getBrew, getUsersBrewThemes } = api;
const welcomeText = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
const welcomeTextLegacy = fs.readFileSync('./client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
const migrateText = fs.readFileSync('./client/homebrew/pages/homePage/migrate.md', 'utf8');
const changelogText = fs.readFileSync('changelog.md', 'utf8');
const faqText = fs.readFileSync('faq.md', 'utf8');
export default function pageRoutes({
defaultMetaTags,
HomebrewModel,
sanitizeBrew,
}) {
const app = express.Router();
//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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
let text = brew.text;
for (const replaceStr in replaceStrings) {
text = text.replaceAll(replaceStr, replaceStrings[replaceStr]);
}
text = `<code><pre style="white-space: pre-wrap;">${text}</pre></code>`;
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);
});
//User Page
app.get('/user/:username', dbCheck, 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);
});
brews.forEach((brew)=>brew.stubbed = true); //All brews from MongoDB are "stubbed"
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 stub matches file from Google, use Google metadata over stub metadata
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.pageCount = googleBrews[match].pageCount;
brew.renderer = googleBrews[match].renderer;
brew.version = googleBrews[match].version;
brew.webViewLink = googleBrews[match].webViewLink;
googleBrews.splice(match, 1);
}
}
//Remaining unstubbed google brews display current user as author
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,
locale : req.brew.lang,
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,
snippets : req.brew.snippets
};
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', dbCheck, asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
const { brew } = req;
req.ogMeta = { ...defaultMetaTags,
title : `${req.brew.title || 'Untitled Brew'} - ${req.brew.authors[0] || 'No author.'}`,
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', dbCheck, 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) {
auth = await GoogleActions.authCheck(req.account, res, false);
googleCount = await GoogleActions.listGoogleBrews(auth)
.catch((err)=>{
console.error(err);
});
}
const query = { authors: req.account.username, googleId: { $exists: false } };
const mongoCount = await HomebrewModel.countDocuments(query)
.catch((err)=>{
console.log(err);
return 0;
});
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();
}));
//Vault Page
app.get('/vault', asyncHandler(async(req, res, next)=>{
req.ogMeta = { ...defaultMetaTags,
title : 'The Vault',
description : 'Search for Brews'
};
return next();
}));
return app;
}
+3 -2
View File
@@ -146,7 +146,7 @@ const printCurrentBrew = async ()=>{
} }
}; };
const fetchThemeBundle = async (setError, setThemeBundle, renderer, theme)=>{ const fetchThemeBundle = async (setError = ()=>{}, setThemeBundle = ()=>{}, renderer, theme)=>{
if(!renderer || !theme) return; if(!renderer || !theme) return;
const res = await request const res = await request
.get(`/api/theme/${renderer}/${theme}`) .get(`/api/theme/${renderer}/${theme}`)
@@ -229,5 +229,6 @@ export {
printCurrentBrew, printCurrentBrew,
fetchThemeBundle, fetchThemeBundle,
brewSnippetsToJSON, brewSnippetsToJSON,
debugTextMismatch debugTextMismatch,
yamlSnippetsToText
}; };
+114
View File
@@ -0,0 +1,114 @@
import {
fetchThemeBundle,
brewSnippetsToJSON,
debugTextMismatch,
yamlSnippetsToText,
} from '../../shared/helpers.js';
import dedent from 'dedent';
// Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake.
String.prototype.trimReturns = function(){
return this.replace(/\r?\n|\r/g, '');
};
const emoji = 'df_d12_2';
const brewSnippetsThemeTest = [
{
name : 'Test Theme',
snippets : dedent `
\snippet First Theme Snippet
I am the first theme snippet!
\snippet Second Theme Snippet
I am the second theme Snippet!`,
}
];
const brewSnippetsBrewTest = dedent`
\snippet First Brew Snippet
I am the first brew snippet!
\snippet Second Brew Snippet
I am the second brew Snippet!`;
describe(`brewSnippetsToJSON`, ()=>{
it('converts raw brew snippets without theme snippets to JSON', function() {
const testMenuObject = {
groupName : 'Brew Snippets',
icon : 'fas fa-th-list',
view : 'text',
snippets : [{
name : 'Test Snippets JSON without theme snippets',
subsnippets : [
{
gen : 'I am the first brew snippet!\n',
name : 'First Brew Snippet'
}, {
gen : 'I am the second brew Snippet!',
name: 'Second Brew Snippet'
}
]}]
};
const rendered = brewSnippetsToJSON(`Test Snippets JSON without theme snippets`, brewSnippetsBrewTest, null, true);
expect(rendered).toStrictEqual(testMenuObject);
});
it('converts raw brew snippets with theme snippets to JSON', function() {
const testMenuObject = {
groupName : 'Brew Snippets',
icon : 'fas fa-th-list',
view : 'text',
snippets : [{
gen : '',
icon : '',
name : 'Test Theme',
subsnippets : [
{
gen : 'I am the first theme snippet!\n',
icon : '',
name : 'First Theme Snippet',
},
{
gen : 'I am the second theme Snippet!',
icon : '',
name : 'Second Theme Snippet',
},
]},
{
name : 'Test Snippets JSON with theme snippets',
subsnippets : [
{
gen : 'I am the first brew snippet!\n',
name : 'First Brew Snippet'
},
{
gen : 'I am the second brew Snippet!',
name: 'Second Brew Snippet'
}
]
}]};
const rendered = brewSnippetsToJSON(`Test Snippets JSON with theme snippets`, brewSnippetsBrewTest, brewSnippetsThemeTest, true);
expect(rendered).toStrictEqual(testMenuObject);
});
});
describe(`YAMLSnippetsToText`, ()=>{
it('converts brew snippet YAML to a string ', function() {
const brewSnippetsYAML = [{
subsnippets : [
{
gen : 'I am the first brew snippet!\n',
name : 'First Brew Snippet'
}, {
gen : 'I am the second brew Snippet!',
name: 'Second Brew Snippet'
}
]
}];
const rendered = yamlSnippetsToText(brewSnippetsYAML);
expect(rendered).toBe(`${brewSnippetsBrewTest}\n`);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
test('Processes the markdown within an HTML block if its just a class wrapper', function() { test('Processes the markdown within an HTML block if its just a class wrapper', function() {
const source = '<div>*Bold text*</div>'; const source = '<div>*Bold text*</div>';
+1 -1
View File
@@ -1,6 +1,6 @@
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
describe('Inline Definition Lists', ()=>{ describe('Inline Definition Lists', ()=>{
test('No Term 1 Definition', function() { test('No Term 1 Definition', function() {
+1 -1
View File
@@ -1,4 +1,4 @@
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
import dedent from 'dedent'; import dedent from 'dedent';
// Marked.js adds line returns after closing tags on some default tokens. // Marked.js adds line returns after closing tags on some default tokens.
+1 -1
View File
@@ -1,6 +1,6 @@
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
describe('Hard Breaks', ()=>{ describe('Hard Breaks', ()=>{
test('Single Break', function() { test('Single Break', function() {
+1 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import dedent from 'dedent'; import dedent from 'dedent';
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
// Marked.js adds line returns after closing tags on some default tokens. // Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake. // This removes those line returns for comparison sake.
+1 -1
View File
@@ -1,6 +1,6 @@
import {hbfm} from 'hbmarkedwrapper'; import {hbfm} from 'marked-hbfm';
describe('Non-Breaking Spaces Interactions', ()=>{ describe('Non-Breaking Spaces Interactions', ()=>{
test('I am actually a single-line definition list!', function() { test('I am actually a single-line definition list!', function() {
@@ -1,6 +1,6 @@
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
describe('Justification', ()=>{ describe('Justification', ()=>{
test('Left Justify', function() { test('Left Justify', function() {
+1 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import dedent from 'dedent'; import dedent from 'dedent';
import { hbfm } from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
// Marked.js adds line returns after closing tags on some default tokens. // Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake. // This removes those line returns for comparison sake.
+2
View File
@@ -14,6 +14,8 @@
.note table tbody tr:nth-child(odd) { background : #FFFFFF; } .note table tbody tr:nth-child(odd) { background : #FFFFFF; }
/* DROP CAP */ /* DROP CAP */
p.first-letter::first-letter,
p.drop-cap::first-letter,
h1 + p::first-letter { h1 + p::first-letter {
color : black; color : black;
background-image : unset; background-image : unset;
+2
View File
@@ -30,6 +30,8 @@ export default [
name : 'Tweak Drop Cap', name : 'Tweak Drop Cap',
icon : 'fas fa-sliders-h', icon : 'fas fa-sliders-h',
gen : dedent`/* Drop Cap settings */ gen : dedent`/* Drop Cap settings */
.page p.first-letter::first-letter,
.page p.drop-cap::first-letter,
.page h1 + p::first-letter { .page h1 + p::first-letter {
font-family: SolberaImitationRemake; font-family: SolberaImitationRemake;
font-size: 3.5cm; font-size: 3.5cm;
+17 -15
View File
@@ -85,23 +85,25 @@
line-height : 1em; line-height : 1em;
-webkit-column-span : all; -webkit-column-span : all;
-moz-column-span : all; -moz-column-span : all;
& + p::first-letter {
float : left;
padding-bottom : 2px;
padding-left : 40px; //Allow background color to extend into margins
margin-top : -0.3cm;
margin-bottom : -20px;
margin-left : -40px;
font-family : 'SolberaImitationRemake';
font-size : 3.5cm;
line-height : 1em;
color : rgba(0, 0, 0, 0);
background-image : linear-gradient(-45deg, #322814, #998250, #322814);
-webkit-background-clip : text;
background-clip : text;
}
& + p::first-line { font-variant : small-caps; } & + p::first-line { font-variant : small-caps; }
} }
p.first-letter::first-letter,
p.drop-cap::first-letter,
h1 + p::first-letter {
float : left;
padding-bottom : 2px;
padding-left : 40px; //Allow background color to extend into margins
margin-top : -0.3cm;
margin-bottom : -20px;
margin-left : -40px;
font-family : 'SolberaImitationRemake';
font-size : 3.5cm;
line-height : 1em;
color : rgba(0, 0, 0, 0);
background-image : linear-gradient(-45deg, #322814, #998250, #322814);
-webkit-background-clip : text;
background-clip : text;
}
h2 { h2 {
//margin-top : 0px; //Font is misaligned. Shift up slightly //margin-top : 0px; //Font is misaligned. Shift up slightly
//margin-bottom : 0.05cm; //margin-bottom : 0.05cm;
+1 -1
View File
@@ -1,4 +1,4 @@
import hbfm from 'hbmarkedwrapper'; import { hbfm } from 'marked-hbfm';
export default { export default {
createFooterFunc : function(headerSize=1){ createFooterFunc : function(headerSize=1){
+15 -13
View File
@@ -80,21 +80,23 @@
font-size : 0.89cm; font-size : 0.89cm;
font-variant : small-caps; font-variant : small-caps;
line-height : 1em; line-height : 1em;
& + p::first-letter {
float : left;
padding-top : 0.3em;
padding-bottom : 2px;
padding-left : 40px; //Allow background color to extend into margins
margin-top : -0.3cm;
margin-right : 0.1em;
margin-bottom : -20px;
margin-left : -40px;
font-family : 'FrederickaTheGreat';
font-size : 1.9em;
line-height : 1em;
}
& + p::first-line { font-variant : small-caps; } & + p::first-line { font-variant : small-caps; }
} }
p.first-letter::first-letter,
p.drop-cap::first-letter,
h1 + p::first-letter {
float : left;
padding-top : 0.3em;
padding-bottom : 2px;
padding-left : 40px; //Allow background color to extend into margins
margin-top : -0.3cm;
margin-right : 0.1em;
margin-bottom : -20px;
margin-left : -40px;
font-family : 'FrederickaTheGreat';
font-size : 1.9em;
line-height : 1em;
}
h2 { h2 {
font-size : 0.62cm; font-size : 0.62cm;
line-height : 0.988em; //Font is misaligned. Shift up slightly line-height : 0.988em; //Font is misaligned. Shift up slightly