Merge branch 'master' into experimentalMarkedDiagrams

This commit is contained in:
G.Ambatte
2026-03-26 18:47:51 +13:00
163 changed files with 4796 additions and 2350 deletions
+29 -1
View File
@@ -83,12 +83,40 @@ pre {
.page .exampleTable td,th {
border:1px dashed #00000030;
}
.page .df {
font-size: 2em;
vertical-align: middle;
}
```
## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Friday 11/14/2025 - v13.20.0
### Friday 1/11/2026 - v3.20.1
{{taskList
##### calculuschild
* [x] Add D100 "ball" dice icons `:d100:` :df_d100_05:
##### G-Ambatte
* [x] Fix transparent edge on back cover image
Fixes issue [#4551](https://github.com/naturalcrit/homebrewery/issues/4551)
* [x] Fix "Out of sync" error when document contains extended unicode characters
Fixes issue [#4583](https://github.com/naturalcrit/homebrewery/issues/4583)
##### 5e-Cleric
* [x] Fix page count error on Vault
* [x] Fix cover page footnote set to all-caps
Fixes issue [#4559](https://github.com/naturalcrit/homebrewery/issues/4559)
}}
### Friday 11/14/2025 - v3.20.0
{{taskList
##### calculuschild
+3 -3
View File
@@ -1,7 +1,7 @@
import './admin.less';
import React, { useEffect, useState } from 'react';
const BrewUtils = require('./brewUtils/brewUtils.jsx');
const NotificationUtils = require('./notificationUtils/notificationUtils.jsx');
import BrewUtils from './brewUtils/brewUtils.jsx';
import NotificationUtils from './notificationUtils/notificationUtils.jsx';
import AuthorUtils from './authorUtils/authorUtils.jsx';
import LockTools from './lockTools/lockTools.jsx';
@@ -49,4 +49,4 @@ const Admin = ()=>{
);
};
module.exports = Admin;
export default Admin;
+6 -8
View File
@@ -1,11 +1,9 @@
@import 'naturalcrit/styles/reset.less';
@import 'naturalcrit/styles/elements.less';
@import 'naturalcrit/styles/animations.less';
@import 'naturalcrit/styles/colors.less';
@import 'naturalcrit/styles/tooltip.less';
@import './themes/fonts/iconFonts/fontAwesome.less';
@import 'font-awesome/css/font-awesome.css';
@import '@sharedStyles/reset.less';
@import '@sharedStyles/elements.less';
@import '@sharedStyles/animations.less';
@import '@sharedStyles/colors.less';
@import '@sharedStyles/tooltip.less';
@import '@themes/fonts/iconFonts/fontAwesome.less';
html,body, #reactContainer, .naturalCrit { min-height : 100%; }
@@ -84,4 +84,4 @@ const authorLookup = ()=>{
);
};
module.exports = authorLookup;
export default authorLookup;
+1 -1
View File
@@ -10,4 +10,4 @@ const authorUtils = ()=>{
);
};
module.exports = authorUtils;
export default authorUtils;
@@ -1,9 +1,8 @@
const React = require('react');
const createClass = require('create-react-class');
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
const request = require('superagent');
const BrewCleanup = createClass({
const BrewCleanup = createReactClass({
displayName : 'BrewCleanup',
getDefaultProps(){
return {};
@@ -69,4 +68,4 @@ const BrewCleanup = createClass({
}
});
module.exports = BrewCleanup;
export default BrewCleanup;
@@ -1,8 +1,8 @@
const React = require('react');
const createClass = require('create-react-class');
const request = require('superagent');
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
const BrewCompress = createClass({
const BrewCompress = createReactClass({
displayName : 'BrewCompress',
getDefaultProps(){
return {};
@@ -85,4 +85,4 @@ const BrewCompress = createClass({
}
});
module.exports = BrewCompress;
export default BrewCompress;
@@ -1,12 +1,11 @@
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
import cx from 'classnames';
const request = require('superagent');
const Moment = require('moment');
import Moment from 'moment';
const BrewLookup = createClass({
const BrewLookup = createReactClass({
getDefaultProps() {
return {};
},
@@ -110,4 +109,4 @@ const BrewLookup = createClass({
}
});
module.exports = BrewLookup;
export default BrewLookup;
+13 -15
View File
@@ -1,15 +1,14 @@
const React = require('react');
const createClass = require('create-react-class');
require('./brewUtils.less');
import React from 'react';
import './brewUtils.less';
const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
const BrewLookup = require('./brewLookup/brewLookup.jsx');
const BrewCompress = require ('./brewCompress/brewCompress.jsx');
const Stats = require('./stats/stats.jsx');
import BrewCleanup from './brewCleanup/brewCleanup.jsx';
import BrewLookup from './brewLookup/brewLookup.jsx';
import BrewCompress from './brewCompress/brewCompress.jsx';
import Stats from './stats/stats.jsx';
const BrewUtils = createClass({
render : function(){
return <>
const BrewUtils = ()=>{
return (
<>
<Stats />
<hr />
<BrewLookup />
@@ -17,8 +16,7 @@ const BrewUtils = createClass({
<BrewCleanup />
<hr />
<BrewCompress />
</>;
}
});
module.exports = BrewUtils;
</>
);
};
export default BrewUtils;
+2
View File
@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.brewUtil {
.result {
margin-top : 20px;
+5 -6
View File
@@ -1,9 +1,8 @@
const React = require('react');
const createClass = require('create-react-class');
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
const request = require('superagent');
const Stats = createClass({
const Stats = createReactClass({
displayName : 'Stats',
getDefaultProps(){
return {};
@@ -43,4 +42,4 @@ const Stats = createClass({
}
});
module.exports = Stats;
export default Stats;
+8 -8
View File
@@ -1,11 +1,11 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
require('./lockTools.less');
const React = require('react');
const createClass = require('create-react-class');
import './lockTools.less';
import React from 'react';
import createReactClass from 'create-react-class';
import request from '../../homebrew/utils/request-middleware.js';
const LockTools = createClass({
const LockTools = createReactClass({
displayName : 'LockTools',
getInitialState : function() {
return {
@@ -55,7 +55,7 @@ const LockTools = createClass({
}
});
const LockBrew = createClass({
const LockBrew = createReactClass({
displayName : 'LockBrew',
getInitialState : function() {
// Default values
@@ -183,7 +183,7 @@ const LockBrew = createClass({
}
});
const LockTable = createClass({
const LockTable = createReactClass({
displayName : 'LockTable',
getDefaultProps : function() {
return {
@@ -273,7 +273,7 @@ const LockTable = createClass({
}
});
const LockLookup = createClass({
const LockLookup = createReactClass({
displayName : 'LockLookup',
getDefaultProps : function() {
return {
@@ -339,4 +339,4 @@ const LockLookup = createClass({
}
});
module.exports = LockTools;
export default LockTools;
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from 'react-dom/client';
import Admin from './admin.jsx';
const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById('reactRoot')).render(<Admin {...props} />);
@@ -1,7 +1,6 @@
require('./notificationAdd.less');
const React = require('react');
const { useState, useRef } = require('react');
const request = require('superagent');
import './notificationAdd.less';
import React, { useState, useRef } from 'react';
import request from 'superagent';
const NotificationAdd = ()=>{
const [notificationResult, setNotificationResult] = useState(null);
@@ -106,4 +105,4 @@ const NotificationAdd = ()=>{
);
};
module.exports = NotificationAdd;
export default NotificationAdd;
@@ -1,9 +1,7 @@
require('./notificationLookup.less');
const React = require('react');
const { useState } = require('react');
const request = require('superagent');
const Moment = require('moment');
import './notificationLookup.less';
import React, { useState } from 'react';
import request from 'superagent';
import Moment from 'moment';
const NotificationDetail = ({ notification, onDelete })=>(
<>
@@ -102,4 +100,4 @@ const NotificationLookup = ()=>{
);
};
module.exports = NotificationLookup;
export default NotificationLookup;
@@ -1,7 +1,6 @@
const React = require('react');
const NotificationLookup = require('./notificationLookup/notificationLookup.jsx');
const NotificationAdd = require('./notificationAdd/notificationAdd.jsx');
import React from 'react';
import NotificationLookup from './notificationLookup/notificationLookup.jsx';
import NotificationAdd from './notificationAdd/notificationAdd.jsx';
const NotificationUtils = ()=>{
return (
@@ -12,4 +11,4 @@ const NotificationUtils = ()=>{
);
};
module.exports = NotificationUtils;
export default NotificationUtils;
@@ -1,7 +1,7 @@
import diceFont from 'themes/fonts/iconFonts/diceFont.js';
import elderberryInn from 'themes/fonts/iconFonts/elderberryInn.js';
import fontAwesome from 'themes/fonts/iconFonts/fontAwesome.js';
import gameIcons from 'themes/fonts/iconFonts/gameIcons.js';
import diceFont from '@themes/fonts/iconFonts/diceFont.js';
import elderberryInn from '@themes/fonts/iconFonts/elderberryInn.js';
import fontAwesome from '@themes/fonts/iconFonts/fontAwesome.js';
import gameIcons from '@themes/fonts/iconFonts/gameIcons.js';
const emojis = {
...diceFont,
@@ -79,6 +79,6 @@ const showAutocompleteEmoji = function(CodeMirror, editor) {
});
};
module.exports = {
export default {
showAutocompleteEmoji
};
+2 -2
View File
@@ -38,11 +38,11 @@ const autoCloseCurlyBraces = function(CodeMirror, cm, typingClosingBrace) {
}
};
module.exports = {
export default {
autoCloseCurlyBraces : function(CodeMirror, codeMirror) {
const map = { name: 'autoCloseCurlyBraces' };
map[`'{'`] = function(cm) { return autoCloseCurlyBraces(CodeMirror, cm); };
map[`'}'`] = function(cm) { return autoCloseCurlyBraces(CodeMirror, cm, true); };
codeMirror.addKeyMap(map);
codeMirror?.addKeyMap(map);
}
};
+147 -122
View File
@@ -1,55 +1,18 @@
/* eslint-disable max-lines */
require('./codeEditor.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const closeTag = require('./close-tag');
const autoCompleteEmoji = require('./autocompleteEmoji');
import './codeEditor.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import closeTag from './close-tag';
import autoCompleteEmoji from './autocompleteEmoji';
let CodeMirror;
if(typeof window !== 'undefined'){
CodeMirror = require('codemirror');
//Language Modes
require('codemirror/mode/gfm/gfm.js'); //Github flavoured markdown
require('codemirror/mode/css/css.js');
require('codemirror/mode/javascript/javascript.js');
//Addons
//Code folding
require('codemirror/addon/fold/foldcode.js');
require('codemirror/addon/fold/foldgutter.js');
//Search and replace
require('codemirror/addon/search/search.js');
require('codemirror/addon/search/searchcursor.js');
require('codemirror/addon/search/jump-to-line.js');
require('codemirror/addon/search/match-highlighter.js');
require('codemirror/addon/search/matchesonscrollbar.js');
require('codemirror/addon/dialog/dialog.js');
//Trailing space highlighting
// require('codemirror/addon/edit/trailingspace.js');
//Active line highlighting
// require('codemirror/addon/selection/active-line.js');
//Scroll past last line
require('codemirror/addon/scroll/scrollpastend.js');
//Auto-closing
//XML code folding is a requirement of the auto-closing tag feature and is not enabled
require('codemirror/addon/fold/xml-fold.js');
require('codemirror/addon/edit/closetag.js');
//Autocompletion
require('codemirror/addon/hint/show-hint.js');
const foldPagesCode = require('./fold-pages');
foldPagesCode.registerHomebreweryHelper(CodeMirror);
const foldCSSCode = require('./fold-css');
foldCSSCode.registerHomebreweryHelper(CodeMirror);
}
const CodeEditor = createClass({
const CodeEditor = createReactClass({
displayName : 'CodeEditor',
getDefaultProps : function() {
return {
language : '',
tab : 'brewText',
value : '',
wrap : true,
onChange : ()=>{},
@@ -66,23 +29,54 @@ const CodeEditor = createClass({
editor : React.createRef(null),
componentDidMount : function() {
async componentDidMount() {
CodeMirror = (await import('codemirror')).default;
this.CodeMirror = CodeMirror;
await import('codemirror/mode/gfm/gfm.js');
await import('codemirror/mode/css/css.js');
await import('codemirror/mode/javascript/javascript.js');
// addons
await import('codemirror/addon/fold/foldcode.js');
await import('codemirror/addon/fold/foldgutter.js');
await import('codemirror/addon/fold/xml-fold.js');
await import('codemirror/addon/search/search.js');
await import('codemirror/addon/search/searchcursor.js');
await import('codemirror/addon/search/jump-to-line.js');
await import('codemirror/addon/search/match-highlighter.js');
await import('codemirror/addon/search/matchesonscrollbar.js');
await import('codemirror/addon/dialog/dialog.js');
await import('codemirror/addon/scroll/scrollpastend.js');
await import('codemirror/addon/edit/closetag.js');
await import('codemirror/addon/hint/show-hint.js');
// import 'codemirror/addon/selection/active-line.js';
// import 'codemirror/addon/edit/trailingspace.js';
// register helpers dynamically as well
const foldPagesCode = (await import('./fold-pages')).default;
const foldCSSCode = (await import('./fold-css')).default;
foldPagesCode.registerHomebreweryHelper(CodeMirror);
foldCSSCode.registerHomebreweryHelper(CodeMirror);
this.buildEditor();
const newDoc = CodeMirror.Doc(this.props.value, this.props.language);
this.codeMirror.swapDoc(newDoc);
const newDoc = CodeMirror?.Doc(this.props.value, this.props.language);
this.codeMirror?.swapDoc(newDoc);
},
componentDidUpdate : function(prevProps) {
if(prevProps.view !== this.props.view){ //view changed; swap documents
let newDoc;
if(!this.state.docs[this.props.view]) {
newDoc = CodeMirror.Doc(this.props.value, this.props.language);
newDoc = CodeMirror?.Doc(this.props.value, this.props.language);
} else {
newDoc = this.state.docs[this.props.view];
}
const oldDoc = { [prevProps.view]: this.codeMirror.swapDoc(newDoc) };
const oldDoc = { [prevProps.view]: this.codeMirror?.swapDoc(newDoc) };
this.setState((prevState)=>({
docs : _.merge({}, prevState.docs, oldDoc)
@@ -90,17 +84,17 @@ const CodeEditor = createClass({
this.props.rerenderParent();
} else if(this.codeMirror?.getValue() != this.props.value) { //update editor contents if brew.text is changed from outside
this.codeMirror.setValue(this.props.value);
this.codeMirror?.setValue(this.props.value);
}
if(this.props.enableFolding) {
this.codeMirror.setOption('foldOptions', this.foldOptions(this.codeMirror));
this.codeMirror?.setOption('foldOptions', this.foldOptions(this.codeMirror));
} else {
this.codeMirror.setOption('foldOptions', false);
this.codeMirror?.setOption('foldOptions', false);
}
if(prevProps.editorTheme !== this.props.editorTheme){
this.codeMirror.setOption('theme', this.props.editorTheme);
this.codeMirror?.setOption('theme', this.props.editorTheme);
}
},
@@ -188,11 +182,27 @@ const CodeEditor = createClass({
closeTag.autoCloseCurlyBraces(CodeMirror, this.codeMirror);
autoCompleteEmoji.showAutocompleteEmoji(CodeMirror, this.codeMirror);
// Note: codeMirror passes a copy of itself in this callback. cm === this.codeMirror. Either one works.
this.codeMirror.on('change', (cm)=>{this.props.onChange(cm.getValue());});
// Note: codeMirror passes a copy of itself in this callback. cm === this.codeMirror?. Either one works.
this.codeMirror?.on('change', (cm)=>{this.props.onChange(cm.getValue());});
this.updateSize();
},
// Use for GFM tabs that use common hot-keys
isGFM : function() {
if((this.isGFM()) || (this.props.tab === 'brewSnippets')) return true;
return false;
},
isBrewText : function() {
if(this.isGFM()) return true;
return false;
},
isBrewSnippets : function() {
if(this.props.tab === 'brewSnippets') return true;
return false;
},
indent : function () {
const cm = this.codeMirror;
if(cm.somethingSelected()) {
@@ -203,84 +213,94 @@ const CodeEditor = createClass({
},
dedent : function () {
this.codeMirror.execCommand('indentLess');
this.codeMirror?.execCommand('indentLess');
},
makeHeader : function (number) {
const selection = this.codeMirror.getSelection();
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection();
const header = Array(number).fill('#').join('');
this.codeMirror.replaceSelection(`${header} ${selection}`, 'around');
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line, ch: cursor.ch + selection.length + number + 1 });
this.codeMirror?.replaceSelection(`${header} ${selection}`, 'around');
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch + selection.length + number + 1 });
},
makeBold : function() {
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 2) === '**' && selection.slice(-2) === '**';
this.codeMirror.replaceSelection(t ? selection.slice(2, -2) : `**${selection}**`, 'around');
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 2) === '**' && selection.slice(-2) === '**';
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `**${selection}**`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
}
},
makeItalic : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 1) === '*' && selection.slice(-1) === '*';
this.codeMirror.replaceSelection(t ? selection.slice(1, -1) : `*${selection}*`, 'around');
this.codeMirror?.replaceSelection(t ? selection.slice(1, -1) : `*${selection}*`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
}
},
makeSuper : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 1) === '^' && selection.slice(-1) === '^';
this.codeMirror.replaceSelection(t ? selection.slice(1, -1) : `^${selection}^`, 'around');
this.codeMirror?.replaceSelection(t ? selection.slice(1, -1) : `^${selection}^`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
}
},
makeSub : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 2) === '^^' && selection.slice(-2) === '^^';
this.codeMirror.replaceSelection(t ? selection.slice(2, -2) : `^^${selection}^^`, 'around');
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `^^${selection}^^`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
}
},
makeNbsp : function() {
this.codeMirror.replaceSelection('&nbsp;', 'end');
if(!this.isGFM()) return;
this.codeMirror?.replaceSelection('&nbsp;', 'end');
},
makeSpace : function() {
const selection = this.codeMirror.getSelection();
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection();
const t = selection.slice(0, 8) === '{{width:' && selection.slice(0 -4) === '% }}';
if(t){
const percent = parseInt(selection.slice(8, -4)) + 10;
this.codeMirror.replaceSelection(percent < 90 ? `{{width:${percent}% }}` : '{{width:100% }}', 'around');
this.codeMirror?.replaceSelection(percent < 90 ? `{{width:${percent}% }}` : '{{width:100% }}', 'around');
} else {
this.codeMirror.replaceSelection(`{{width:10% }}`, 'around');
this.codeMirror?.replaceSelection(`{{width:10% }}`, 'around');
}
},
removeSpace : function() {
const selection = this.codeMirror.getSelection();
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection();
const t = selection.slice(0, 8) === '{{width:' && selection.slice(0 -4) === '% }}';
if(t){
const percent = parseInt(selection.slice(8, -4)) - 10;
this.codeMirror.replaceSelection(percent > 10 ? `{{width:${percent}% }}` : '', 'around');
this.codeMirror?.replaceSelection(percent > 10 ? `{{width:${percent}% }}` : '', 'around');
}
},
newColumn : function() {
this.codeMirror.replaceSelection('\n\\column\n\n', 'end');
if(!this.isGFM()) return;
this.codeMirror?.replaceSelection('\n\\column\n\n', 'end');
},
newPage : function() {
this.codeMirror.replaceSelection('\n\\page\n\n', 'end');
if(!this.isGFM()) return;
this.codeMirror?.replaceSelection('\n\\page\n\n', 'end');
},
injectText : function(injectText, overwrite=true) {
@@ -293,29 +313,32 @@ const CodeEditor = createClass({
},
makeUnderline : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 3) === '<u>' && selection.slice(-4) === '</u>';
this.codeMirror.replaceSelection(t ? selection.slice(3, -4) : `<u>${selection}</u>`, 'around');
this.codeMirror?.replaceSelection(t ? selection.slice(3, -4) : `<u>${selection}</u>`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line, ch: cursor.ch - 4 });
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 4 });
}
},
makeSpan : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 2) === '{{' && selection.slice(-2) === '}}';
this.codeMirror.replaceSelection(t ? selection.slice(2, -2) : `{{ ${selection}}}`, 'around');
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `{{ ${selection}}}`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
}
},
makeDiv : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror.getSelection(), t = selection.slice(0, 2) === '{{' && selection.slice(-2) === '}}';
this.codeMirror.replaceSelection(t ? selection.slice(2, -2) : `{{\n${selection}\n}}`, 'around');
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `{{\n${selection}\n}}`, 'around');
if(selection.length === 0){
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line - 1, ch: cursor.ch }); // set to -2? if wanting to enter classes etc. if so, get rid of first \n when replacing selection
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line - 1, ch: cursor.ch }); // set to -2? if wanting to enter classes etc. if so, get rid of first \n when replacing selection
}
},
@@ -323,8 +346,8 @@ const CodeEditor = createClass({
let regex;
let cursorPos;
let newComment;
const selection = this.codeMirror.getSelection();
if(this.props.language === 'gfm'){
const selection = this.codeMirror?.getSelection();
if(this.isGFM()){
regex = /^\s*(<!--\s?)(.*?)(\s?-->)\s*$/gs;
cursorPos = 4;
newComment = `<!-- ${selection} -->`;
@@ -333,44 +356,46 @@ const CodeEditor = createClass({
cursorPos = 3;
newComment = `/* ${selection} */`;
}
this.codeMirror.replaceSelection(regex.test(selection) == true ? selection.replace(regex, '$2') : newComment, 'around');
this.codeMirror?.replaceSelection(regex.test(selection) == true ? selection.replace(regex, '$2') : newComment, 'around');
if(selection.length === 0){
const cursor = this.codeMirror.getCursor();
this.codeMirror.setCursor({ line: cursor.line, ch: cursor.ch - cursorPos });
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - cursorPos });
};
},
makeLink : function() {
if(!this.isGFM()) return;
const isLink = /^\[(.*)\]\((.*)\)$/;
const selection = this.codeMirror.getSelection().trim();
const selection = this.codeMirror?.getSelection().trim();
let match;
if(match = isLink.exec(selection)){
const altText = match[1];
const url = match[2];
this.codeMirror.replaceSelection(`${altText} ${url}`);
const cursor = this.codeMirror.getCursor();
this.codeMirror.setSelection({ line: cursor.line, ch: cursor.ch - url.length }, { line: cursor.line, ch: cursor.ch });
this.codeMirror?.replaceSelection(`${altText} ${url}`);
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setSelection({ line: cursor.line, ch: cursor.ch - url.length }, { line: cursor.line, ch: cursor.ch });
} else {
this.codeMirror.replaceSelection(`[${selection || 'alt text'}](url)`);
const cursor = this.codeMirror.getCursor();
this.codeMirror.setSelection({ line: cursor.line, ch: cursor.ch - 4 }, { line: cursor.line, ch: cursor.ch - 1 });
this.codeMirror?.replaceSelection(`[${selection || 'alt text'}](url)`);
const cursor = this.codeMirror?.getCursor();
this.codeMirror?.setSelection({ line: cursor.line, ch: cursor.ch - 4 }, { line: cursor.line, ch: cursor.ch - 1 });
}
},
makeList : function(listType) {
if(!this.isGFM()) return;
const selectionStart = this.codeMirror.getCursor('from'), selectionEnd = this.codeMirror.getCursor('to');
this.codeMirror.setSelection(
this.codeMirror?.setSelection(
{ line: selectionStart.line, ch: 0 },
{ line: selectionEnd.line, ch: this.codeMirror.getLine(selectionEnd.line).length }
{ line: selectionEnd.line, ch: this.codeMirror?.getLine(selectionEnd.line).length }
);
const newSelection = this.codeMirror.getSelection();
const newSelection = this.codeMirror?.getSelection();
const regex = /^\d+\.\s|^-\s/gm;
if(newSelection.match(regex) != null){ // if selection IS A LIST
this.codeMirror.replaceSelection(newSelection.replace(regex, ''), 'around');
this.codeMirror?.replaceSelection(newSelection.replace(regex, ''), 'around');
} else { // if selection IS NOT A LIST
listType == 'UL' ? this.codeMirror.replaceSelection(newSelection.replace(/^/gm, `- `), 'around') :
this.codeMirror.replaceSelection(newSelection.replace(/^/gm, (()=>{
listType == 'UL' ? this.codeMirror?.replaceSelection(newSelection.replace(/^/gm, `- `), 'around') :
this.codeMirror?.replaceSelection(newSelection.replace(/^/gm, (()=>{
let n = 1;
return ()=>{
return `${n++}. `;
@@ -380,39 +405,39 @@ const CodeEditor = createClass({
},
foldAllCode : function() {
this.codeMirror.execCommand('foldAll');
this.codeMirror?.execCommand('foldAll');
},
unfoldAllCode : function() {
this.codeMirror.execCommand('unfoldAll');
this.codeMirror?.execCommand('unfoldAll');
},
//=-- Externally used -==//
setCursorPosition : function(line, char){
setTimeout(()=>{
this.codeMirror.focus();
this.codeMirror.doc.setCursor(line, char);
this.codeMirror?.focus();
this.codeMirror?.doc.setCursor(line, char);
}, 10);
},
getCursorPosition : function(){
return this.codeMirror.getCursor();
return this.codeMirror?.getCursor();
},
getTopVisibleLine : function(){
const rect = this.codeMirror.getWrapperElement().getBoundingClientRect();
const topVisibleLine = this.codeMirror.lineAtHeight(rect.top, 'window');
const rect = this.codeMirror?.getWrapperElement().getBoundingClientRect();
const topVisibleLine = this.codeMirror?.lineAtHeight(rect.top, 'window');
return topVisibleLine;
},
updateSize : function(){
this.codeMirror.refresh();
this.codeMirror?.refresh();
},
redo : function(){
return this.codeMirror.redo();
return this.codeMirror?.redo();
},
undo : function(){
return this.codeMirror.undo();
return this.codeMirror?.undo();
},
historySize : function(){
return this.codeMirror.doc.historySize();
return this.codeMirror?.doc.historySize();
},
foldOptions : function(cm){
@@ -426,7 +451,7 @@ const CodeEditor = createClass({
let foldPreviewText = '';
while (currentLine <= to.line && text.length <= maxLength) {
const currentText = this.codeMirror.getLine(currentLine);
const currentText = this.codeMirror?.getLine(currentLine);
currentLine++;
if(currentText[0] == '#'){
foldPreviewText = currentText;
@@ -461,5 +486,5 @@ const CodeEditor = createClass({
}
});
module.exports = CodeEditor;
export default CodeEditor;
+4 -4
View File
@@ -5,10 +5,10 @@
@import (less) 'codemirror/addon/hint/show-hint.css';
//Icon fonts included so they can appear in emoji autosuggest dropdown
@import (less) './themes/fonts/iconFonts/diceFont.less';
@import (less) './themes/fonts/iconFonts/elderberryInn.less';
@import (less) './themes/fonts/iconFonts/gameIcons.less';
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
@import (less) '@themes/fonts/iconFonts/diceFont.less';
@import (less) '@themes/fonts/iconFonts/elderberryInn.less';
@import (less) '@themes/fonts/iconFonts/gameIcons.less';
@import (less) '@themes/fonts/iconFonts/fontAwesome.less';
@keyframes sourceMoveAnimation {
50% { color : white;background-color : red;}
+1 -1
View File
@@ -1,4 +1,4 @@
module.exports = {
export default {
registerHomebreweryHelper : function(CodeMirror) {
CodeMirror.registerHelper('fold', 'homebrewerycss', function(cm, start) {
+1 -1
View File
@@ -1,4 +1,4 @@
module.exports = {
export default {
registerHomebreweryHelper : function(CodeMirror) {
CodeMirror.registerHelper('fold', 'homebrewery', function(cm, start) {
const matcher = /^\\page.*/;
+21 -9
View File
@@ -1,9 +1,9 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
require('./combobox.less');
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import './combobox.less';
const Combobox = createClass({
const Combobox = createReactClass({
displayName : 'Combobox',
getDefaultProps : function() {
return {
@@ -11,14 +11,17 @@ const Combobox = createClass({
trigger : 'hover',
default : '',
placeholder : '',
tooltip : '',
autoSuggest : {
clearAutoSuggestOnClick : true,
suggestMethod : 'includes',
filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter
},
valuePatterns : /.+/
};
},
getInitialState : function() {
this.dropdownRef = React.createRef();
return {
showDropdown : false,
value : '',
@@ -39,7 +42,7 @@ const Combobox = createClass({
},
handleClickOutside : function(e){
// Close dropdown when clicked outside
if(this.refs.dropdown && !this.refs.dropdown.contains(e.target)) {
if(this.dropdownRef.current && !this.dropdownRef.current.contains(e.target)) {
this.handleDropdown(false);
}
},
@@ -69,11 +72,14 @@ const Combobox = createClass({
return (
<div className='dropdown-input item'
onMouseEnter={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(true);} : undefined}
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}>
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}
{...(this.props.tooltip ? { 'data-tooltip-right': this.props.tooltip } : {})}>
<input
type='text'
onChange={(e)=>this.handleInput(e)}
value={this.state.value || ''}
title=''
pattern={this.props.valuePatterns}
placeholder={this.props.placeholder}
onBlur={(e)=>{
if(!e.target.checkValidity()){
@@ -82,6 +88,12 @@ const Combobox = createClass({
});
}
}}
onKeyDown={(e)=>{
if(e.key === 'Enter') {
e.preventDefault();
this.props.onEntry(e);
}
}}
/>
<i className='fas fa-caret-down'/>
</div>
@@ -117,7 +129,7 @@ const Combobox = createClass({
});
return (
<div className={`dropdown-container ${this.props.className}`}
ref='dropdown'
ref={this.dropdownRef}
onMouseLeave={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(false);} : undefined}>
{this.renderTextInput()}
{this.renderDropdown(dropdownChildren)}
@@ -126,4 +138,4 @@ const Combobox = createClass({
}
});
module.exports = Combobox;
export default Combobox;
+1
View File
@@ -10,6 +10,7 @@
position : absolute;
z-index : 100;
width : 100%;
height : max-content;
max-height : 200px;
overflow-y : auto;
background-color : white;
@@ -1,11 +1,11 @@
require('./renderWarnings.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
import './renderWarnings.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import Dialog from '../dialog.jsx';
const RenderWarnings = createClass({
const RenderWarnings = createReactClass({
displayName : 'RenderWarnings',
getInitialState : function() {
return {
@@ -57,4 +57,4 @@ const RenderWarnings = createClass({
}
});
module.exports = RenderWarnings;
export default RenderWarnings;
@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.renderWarnings {
position : relative;
float : right;
+3 -4
View File
@@ -1,6 +1,5 @@
require('./splitPane.less');
const React = require('react');
const { useState, useEffect } = React;
import './splitPane.less';
import React, { useEffect, useState } from 'react';
const PANE_WIDTH_KEY = 'HB_editor_splitWidth';
const LIVE_SCROLL_KEY = 'HB_editor_liveScroll';
@@ -108,4 +107,4 @@ const Pane = ({ width, children, isDragging, moveBrew, moveSource, liveScroll, s
);
};
module.exports = SplitPane;
export default SplitPane;
@@ -1,3 +1,4 @@
@import '@sharedStyles/core.less';
.splitPane {
position : relative;
+2 -3
View File
@@ -1,7 +1,6 @@
const React = require('react');
const createClass = require('create-react-class');
import React from 'react';
module.exports = function(props){
export default function(props){
return <svg version='1.1' x='0px' y='0px' viewBox='0 0 90 112.5' enableBackground='new 0 0 90 90' >
<path d='M25.363,25.54c0,1.906,8.793,3.454,19.636,3.454c10.848,0,19.638-1.547,19.638-3.454c0-1.12-3.056-2.117-7.774-2.75 c-1.418,1.891-3.659,3.133-6.208,3.133c-2.85,0-5.315-1.547-6.67-3.833C33.617,22.185,25.363,23.692,25.363,25.54z'/><path d='M84.075,54.142c0-8.68-2.868-17.005-8.144-23.829c1.106-1.399,1.41-2.771,1.41-3.854c0-6.574-10.245-9.358-19.264-10.533 c0.209,0.706,0.359,1.439,0.359,2.215c0,0.09-0.022,0.17-0.028,0.26l0,0c-0.028,0.853-0.195,1.667-0.479,2.429 c9.106,1.282,14.508,3.754,14.508,5.63c0,2.644-10.688,6.486-27.439,6.486c-16.748,0-27.438-3.842-27.438-6.486 c0-2.542,9.904-6.183,25.559-6.459c-0.098-0.396-0.159-0.807-0.2-1.223c0.006,0,0.013,0,0.017,0 c-0.017-0.213-0.063-0.417-0.063-0.636c0-1.084,0.226-2.119,0.628-3.058c-6.788,0.129-30.846,1.299-30.846,11.376 c0,1.083,0.305,2.455,1.411,3.854c-5.276,6.823-8.145,15.149-8.145,23.829c0,11.548,5.187,20.107,14.693,25.115 c-0.902,3.146-1.391,7.056,1.111,8.181c2.626,1.178,5.364-2.139,7.111-5.005c4.73,1.261,10.13,1.923,16.161,1.923 c6.034,0,11.428-0.661,16.158-1.922c1.75,2.865,4.493,6.18,7.112,5.004c2.504-1.123,2.014-5.035,1.113-8.179 C78.889,74.249,84.075,65.689,84.075,54.142z M70.39,31.392c5.43,6.046,8.78,14,8.78,22.75c0,20.919-18.582,25.309-34.171,25.309 c-15.587,0-34.17-4.39-34.17-25.309c0-8.75,3.35-16.7,8.781-22.753c5.561,2.643,15.502,4.009,25.389,4.009 C54.886,35.397,64.829,34.031,70.39,31.392z'/><path d='M50.654,23.374c2.892,0,5.234-2.341,5.234-5.233c0-2.887-2.343-5.23-5.234-5.23c-2.887,0-5.231,2.343-5.231,5.23 C45.423,21.032,47.768,23.374,50.654,23.374z'/>
<circle cx='62.905' cy='10.089' r='3.595'/>
@@ -1,6 +1,5 @@
const React = require('react');
const createClass = require('create-react-class');
import React from 'react';
module.exports = function(props){
export default function(props){
return <svg version='1.1' x='0px' y='0px' viewBox='0 0 100 100' enableBackground='new 0 0 100 100'><path d='M80.644,87.982l16.592-41.483c0.054-0.128,0.088-0.26,0.108-0.394c0.006-0.039,0.007-0.077,0.011-0.116 c0.007-0.087,0.008-0.174,0.002-0.26c-0.003-0.046-0.007-0.091-0.014-0.137c-0.014-0.089-0.036-0.176-0.063-0.262 c-0.012-0.034-0.019-0.069-0.031-0.103c-0.047-0.118-0.106-0.229-0.178-0.335c-0.004-0.006-0.006-0.012-0.01-0.018L67.999,3.358 c-0.01-0.013-0.003-0.026-0.013-0.04L68,3.315V4c0,0-0.033,0-0.037,0c-0.403-1-1.094-1.124-1.752-0.976 c0,0.004-0.004-0.012-0.007-0.012C66.201,3.016,66.194,3,66.194,3H66.19h-0.003h-0.003h-0.004h-0.003c0,0-0.004,0-0.007,0 s-0.003-0.151-0.007-0.151L20.495,15.227c-0.025,0.007-0.046-0.019-0.071-0.011c-0.087,0.028-0.172,0.041-0.253,0.083 c-0.054,0.027-0.102,0.053-0.152,0.085c-0.051,0.033-0.101,0.061-0.147,0.099c-0.044,0.036-0.084,0.073-0.124,0.113 c-0.048,0.048-0.093,0.098-0.136,0.152c-0.03,0.039-0.059,0.076-0.085,0.117c-0.046,0.07-0.084,0.145-0.12,0.223 c-0.011,0.023-0.027,0.042-0.036,0.066L2.911,57.664C2.891,57.715,3,57.768,3,57.82v0.002c0,0.186,0,0.375,0,0.562 c0,0.004,0,0.004,0,0.008c0,0,0,0,0,0.002c0,0,0,0,0,0.004v0.004v0.002c0,0.074-0.002,0.15,0.012,0.223 C3.015,58.631,3,58.631,3,58.633c0,0.004,0,0.004,0,0.008c0,0,0,0,0,0.002c0,0,0,0,0,0.004v0.004c0,0,0,0,0,0.002v0.004 c0,0.191-0.046,0.377,0.06,0.545c0-0.002-0.03,0.004-0.03,0.004c0,0.004-0.03,0.004-0.03,0.004c0,0.002,0,0.002,0,0.002 l-0.045,0.004c0.03,0.047,0.036,0.09,0.068,0.133l29.049,37.359c0.002,0.004,0,0.006,0.002,0.01c0.002,0.002,0,0.004,0.002,0.008 c0.006,0.008,0.014,0.014,0.021,0.021c0.024,0.029,0.052,0.051,0.078,0.078c0.027,0.029,0.053,0.057,0.082,0.082 c0.03,0.027,0.055,0.062,0.086,0.088c0.026,0.02,0.057,0.033,0.084,0.053c0.04,0.027,0.081,0.053,0.123,0.076 c0.005,0.004,0.01,0.008,0.016,0.01c0.087,0.051,0.176,0.09,0.269,0.123c0.042,0.014,0.082,0.031,0.125,0.043 c0.021,0.006,0.041,0.018,0.062,0.021c0.123,0.027,0.249,0.043,0.375,0.043c0.099,0,0.202-0.012,0.304-0.027l45.669-8.303 c0.057-0.01,0.108-0.021,0.163-0.037C79.547,88.992,79.562,89,79.575,89c0.004,0,0.004,0,0.004,0c0.021,0,0.039-0.027,0.06-0.035 c0.041-0.014,0.08-0.034,0.12-0.052c0.021-0.01,0.044-0.019,0.064-0.03c0.017-0.01,0.026-0.015,0.033-0.017 c0.014-0.008,0.023-0.021,0.037-0.028c0.14-0.078,0.269-0.174,0.38-0.285c0.014-0.016,0.024-0.034,0.038-0.048 c0.109-0.119,0.201-0.252,0.271-0.398c0.006-0.01,0.016-0.018,0.021-0.029c0.004-0.008,0.008-0.017,0.011-0.026 c0.002-0.004,0.003-0.006,0.005-0.01C80.627,88.021,80.635,88.002,80.644,87.982z M77.611,84.461L48.805,66.453l32.407-25.202 L77.611,84.461z M46.817,63.709L35.863,23.542l43.818,14.608L46.817,63.709z M84.668,40.542l8.926,5.952l-11.902,29.75 L84.668,40.542z M89.128,39.446L84.53,36.38l-6.129-12.257L89.128,39.446z M79.876,34.645L37.807,20.622L65.854,6.599L79.876,34.645 z M33.268,19.107l-6.485-2.162l23.781-6.487L33.268,19.107z M21.92,18.895l8.67,2.891L10.357,47.798L21.92,18.895z M32.652,24.649 l10.845,39.757L7.351,57.178L32.652,24.649z M43.472,67.857L32.969,92.363L8.462,60.855L43.472,67.857z M46.631,69.09l27.826,17.393 l-38.263,6.959L46.631,69.09z'></path></svg>;
};
+18 -15
View File
@@ -1,23 +1,24 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./brewRenderer.less');
const React = require('react');
const { useState, useRef, useMemo, useEffect } = React;
const _ = require('lodash');
import brewRendererStylesUrl from './brewRenderer.less?url';
import headerNavStylesUrl from './headerNav/headerNav.less?url';
import './brewRenderer.less';
import React, { useState, useRef, useMemo, useEffect } from 'react';
import _ from 'lodash';
const MarkdownLegacy = require('markdownLegacy.js');
import Markdown from 'markdown.js';
const ErrorBar = require('./errorBar/errorBar.jsx');
const ToolBar = require('./toolBar/toolBar.jsx');
import MarkdownLegacy from '@shared/markdownLegacy.js';
import Markdown from '@shared/markdown.js';
import ErrorBar from './errorBar/errorBar.jsx';
import ToolBar from './toolBar/toolBar.jsx';
//TODO: move to the brew renderer
const RenderWarnings = require('client/components/renderWarnings/renderWarnings.jsx');
const NotificationPopup = require('./notificationPopup/notificationPopup.jsx');
const Frame = require('react-frame-component').default;
const dedent = require('dedent-tabs').default;
const { printCurrentBrew } = require('../../../shared/helpers.js');
import RenderWarnings from '../../components/renderWarnings/renderWarnings.jsx';
import NotificationPopup from './notificationPopup/notificationPopup.jsx';
import Frame from 'react-frame-component';
import dedent from 'dedent';
import { printCurrentBrew } from '@shared/helpers.js';
import HeaderNav from './headerNav/headerNav.jsx';
import { safeHTML } from './safeHTML.js';
import safeHTML from './safeHTML.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
@@ -30,6 +31,8 @@ const INITIAL_CONTENT = dedent`
<!DOCTYPE html><html><head>
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
<link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
<link href="${brewRendererStylesUrl}" rel="stylesheet" />
<link href="${headerNavStylesUrl}" rel="stylesheet" />
<base target=_blank>
</head><body style='overflow: hidden'><div></div></body></html>`;
@@ -345,4 +348,4 @@ const BrewRenderer = (props)=>{
);
};
module.exports = BrewRenderer;
export default BrewRenderer;
@@ -1,4 +1,4 @@
@import (multiple, less) 'shared/naturalcrit/styles/reset.less';
@import '@sharedStyles/core.less';
.brewRenderer {
height : 100vh;
@@ -59,6 +59,12 @@
}
&-corner { visibility : hidden; }
}
@supports (break-after:always) {
.columnSplit {
margin-bottom: 100vh;
}
}
}
.pane { position : relative; }
@@ -81,4 +87,5 @@
}
}
.headerNav { visibility : hidden; }
}
@@ -1,5 +1,5 @@
require('./errorBar.less');
const React = require('react');
import './errorBar.less';
import React from 'react';
import Dialog from '../../../components/dialog.jsx';
@@ -50,4 +50,4 @@ const ErrorBar = (props)=>{
);
};
module.exports = ErrorBar;
export default ErrorBar;
@@ -1,3 +1,4 @@
@import '@sharedStyles/colors.less';
.errorBar {
position : absolute;
@@ -1,7 +1,7 @@
require('./headerNav.less');
import './headerNav.less';
import * as React from 'react';
import * as _ from 'lodash';
import React from 'react';
import _ from 'lodash';
const MAX_TEXT_LENGTH = 40;
@@ -1,7 +1,7 @@
require('./notificationPopup.less');
import './notificationPopup.less';
import React, { useEffect, useState } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from 'markdown.js';
import Markdown from '@shared/markdown.js';
import Dialog from '../../../components/dialog.jsx';
@@ -62,4 +62,4 @@ const NotificationPopup = ()=>{
</Dialog>;
};
module.exports = NotificationPopup;
export default NotificationPopup;
@@ -1,3 +1,5 @@
@import './client/homebrew/navbar/navbar.less';
.popups {
position : fixed;
top : calc(@navbarHeight + @viewerToolsHeight);
+1 -1
View File
@@ -43,4 +43,4 @@ function safeHTML(htmlString) {
return div.innerHTML;
};
module.exports.safeHTML = safeHTML;
export default safeHTML;
@@ -1,8 +1,7 @@
/* eslint-disable max-lines */
require('./toolBar.less');
const React = require('react');
const { useState, useEffect } = React;
const _ = require('lodash');
import './toolBar.less';
import React, { useState, useEffect } from 'react';
import _ from 'lodash';
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
@@ -100,18 +99,18 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
return (
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
<div className='toggleButton'>
<button title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{
<button data-tooltip-right={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{
setToolsVisible(!toolsVisible);
localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible);
}}><i className='fas fa-glasses' /></button>
<button title={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button>
<button data-tooltip-right={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button>
</div>
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
<button
id='fill-width'
className='tool'
title='Set zoom to fill preview with one page'
data-tooltip-bottom='Set zoom to fill preview with one page'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
>
<i className='fac fit-width' />
@@ -119,7 +118,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
<button
id='zoom-to-fit'
className='tool'
title='Set zoom to fit entire page in preview'
data-tooltip-bottom='Set zoom to fit entire page in preview'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
>
<i className='fac zoom-to-fit' />
@@ -129,7 +128,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
className='tool'
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
disabled={displayOptions.zoomLevel <= MIN_ZOOM}
title='Zoom Out'
data-tooltip-bottom='Zoom Out'
>
<i className='fas fa-magnifying-glass-minus' />
</button>
@@ -138,7 +137,6 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
className='range-input tool hover-tooltip'
type='range'
name='zoom'
title='Set Zoom'
list='zoomLevels'
min={MIN_ZOOM}
max={MAX_ZOOM}
@@ -155,7 +153,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
className='tool'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
disabled={displayOptions.zoomLevel >= MAX_ZOOM}
title='Zoom In'
data-tooltip-bottom='Zoom In'
>
<i className='fas fa-magnifying-glass-plus' />
</button>
@@ -167,44 +165,44 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
<button role='radio'
id='single-spread'
className='tool'
title='Single Page'
data-tooltip-bottom='Single Page'
onClick={()=>{handleOptionChange('spread', 'single');}}
aria-checked={displayOptions.spread === 'single'}
><i className='fac single-spread' /></button>
<button role='radio'
id='facing-spread'
className='tool'
title='Facing Pages'
data-tooltip-bottom='Facing Pages'
onClick={()=>{handleOptionChange('spread', 'facing');}}
aria-checked={displayOptions.spread === 'facing'}
><i className='fac facing-spread' /></button>
<button role='radio'
id='flow-spread'
className='tool'
title='Flow Pages'
data-tooltip-bottom='Flow Pages'
onClick={()=>{handleOptionChange('spread', 'flow');}}
aria-checked={displayOptions.spread === 'flow'}
><i className='fac flow-spread' /></button>
</div>
<Anchored>
<AnchoredTrigger id='spread-settings' className='tool' title='Spread options'><i className='fas fa-gear' /></AnchoredTrigger>
<AnchoredBox title='Options'>
<AnchoredTrigger id='spread-settings' className='tool' data-tooltip-bottom='Spread options'><i className='fas fa-gear' /></AnchoredTrigger>
<AnchoredBox>
<h1>Options</h1>
<label title='Modify the horizontal space between pages.'>
<label data-tooltip-left='Modify the horizontal space between pages.'>
Column gap
<input type='range' min={0} max={200} defaultValue={displayOptions.columnGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('columnGap', evt.target.value)} />
</label>
<label title='Modify the vertical space between rows of pages.'>
<label data-tooltip-left='Modify the vertical space between rows of pages.'>
Row gap
<input type='range' min={0} max={200} defaultValue={displayOptions.rowGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('rowGap', evt.target.value)} />
</label>
<label title='Start 1st page on the right side, such as if you have cover page.'>
<label data-tooltip-left='Start 1st page on the right side, such as if you have cover page.'>
Start on right
<input type='checkbox' checked={displayOptions.startOnRight} onChange={()=>{handleOptionChange('startOnRight', !displayOptions.startOnRight);}}
title={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
data-tooltip-right={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
</label>
<label title='Toggle the page shadow on every page.'>
<label data-tooltip-left='Toggle the page shadow on every page.'>
Page shadows
<input type='checkbox' checked={displayOptions.pageShadows} onChange={()=>{handleOptionChange('pageShadows', !displayOptions.pageShadows);}} />
</label>
@@ -218,7 +216,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
id='previous-page'
className='previousPage tool'
type='button'
title='Previous Page(s)'
data-tooltip-bottom='Previous Page(s)'
onClick={()=>scrollToPage(_.min(visiblePages) - visiblePages.length)}
disabled={visiblePages.includes(1)}
>
@@ -231,7 +229,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
className='text-input'
type='text'
name='page'
title='Current page(s) in view'
data-tooltip-bottom='Current page(s) in view'
inputMode='numeric'
pattern='[0-9]'
value={pageNum}
@@ -241,14 +239,14 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
style={{ width: `${pageNum.length}ch` }}
/>
<span id='page-count' title='Total Page Count'>/ {totalPages}</span>
<span id='page-count' data-tooltip-bottom='Total Page Count'>/ {totalPages}</span>
</div>
<button
id='next-page'
className='tool'
type='button'
title='Next Page(s)'
data-tooltip-bottom='Next Page(s)'
onClick={()=>scrollToPage(_.max(visiblePages) + 1)}
disabled={visiblePages.includes(totalPages)}
>
@@ -259,4 +257,4 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
);
};
module.exports = ToolBar;
export default ToolBar;
@@ -166,7 +166,7 @@
&.hidden {
flex-wrap : nowrap;
width : 92px;
width : 50%;
overflow : hidden;
background-color : unset;
opacity : 0.7;
+53 -48
View File
@@ -1,14 +1,14 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
require('./editor.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const dedent = require('dedent-tabs').default;
import Markdown from '../../../shared/markdown.js';
import './editor.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import dedent from 'dedent';
import Markdown from '@shared/markdown.js';
const CodeEditor = require('client/components/codeEditor/codeEditor.jsx');
const SnippetBar = require('./snippetbar/snippetbar.jsx');
const MetadataEditor = require('./metadataEditor/metadataEditor.jsx');
import CodeEditor from '../../components/codeEditor/codeEditor.jsx';
import SnippetBar from './snippetbar/snippetbar.jsx';
import MetadataEditor from './metadataEditor/metadataEditor.jsx';
const EDITOR_THEME_KEY = 'HB_editor_theme';
@@ -31,7 +31,7 @@ const DEFAULT_SNIPPET_TEXT = dedent`
`;
let isJumping = false;
const Editor = createClass({
const Editor = createReactClass({
displayName : 'Editor',
getDefaultProps : function() {
return {
@@ -76,8 +76,8 @@ const Editor = createClass({
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys);
document.addEventListener('keydown', this.handleControlKeys);
this.codeEditor.current.codeMirror.on('cursorActivity', (cm)=>{this.updateCurrentCursorPage(cm.getCursor());});
this.codeEditor.current.codeMirror.on('scroll', _.throttle(()=>{this.updateCurrentViewPage(this.codeEditor.current.getTopVisibleLine());}, 200));
this.codeEditor.current.codeMirror?.on('cursorActivity', (cm)=>{this.updateCurrentCursorPage(cm.getCursor());});
this.codeEditor.current.codeMirror?.on('scroll', _.throttle(()=>{this.updateCurrentViewPage(this.codeEditor.current.getTopVisibleLine());}, 200));
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
if(editorTheme) {
@@ -86,9 +86,9 @@ const Editor = createClass({
});
}
const snippetBar = document.querySelector('.editor > .snippetBar');
if (!snippetBar) return;
if(!snippetBar) return;
this.resizeObserver = new ResizeObserver(entries => {
this.resizeObserver = new ResizeObserver((entries)=>{
const height = document.querySelector('.editor > .snippetBar').offsetHeight;
this.setState({ snippetBarHeight: height });
});
@@ -117,7 +117,7 @@ const Editor = createClass({
},
componentWillUnmount() {
if (this.resizeObserver) this.resizeObserver.disconnect();
if(this.resizeObserver) this.resizeObserver.disconnect();
},
handleControlKeys : function(e){
@@ -156,21 +156,21 @@ const Editor = createClass({
this.setState({
view : newView
}, ()=>{
this.codeEditor.current?.codeMirror.focus();
this.codeEditor.current?.codeMirror?.focus();
});
},
highlightCustomMarkdown : function(){
if(!this.codeEditor.current) return;
if(!this.codeEditor.current?.codeMirror) return;
if((this.state.view === 'text') ||(this.state.view === 'snippet')) {
const codeMirror = this.codeEditor.current.codeMirror;
codeMirror.operation(()=>{ // Batch CodeMirror styling
codeMirror?.operation(()=>{ // Batch CodeMirror styling
const foldLines = [];
//reset custom text styles
const customHighlights = codeMirror.getAllMarks().filter((mark)=>{
const customHighlights = codeMirror?.getAllMarks().filter((mark)=>{
// Record details of folded sections
if(mark.__isFold) {
const fold = mark.find();
@@ -191,10 +191,10 @@ const Editor = createClass({
const textOrSnip = this.state.view === 'text';
//reset custom line styles
codeMirror.removeLineClass(lineNumber, 'background', 'pageLine');
codeMirror.removeLineClass(lineNumber, 'background', 'snippetLine');
codeMirror.removeLineClass(lineNumber, 'text');
codeMirror.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash');
codeMirror?.removeLineClass(lineNumber, 'background', 'pageLine');
codeMirror?.removeLineClass(lineNumber, 'background', 'snippetLine');
codeMirror?.removeLineClass(lineNumber, 'text');
codeMirror?.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash');
// Don't process lines inside folded text
// If the current lineNumber is inside any folded marks, skip line styling
@@ -210,19 +210,19 @@ const Editor = createClass({
else if(this.state.view !== 'text') userSnippetCount += 1;
// add back the original class 'background' but also add the new class '.pageline'
codeMirror.addLineClass(lineNumber, 'background', tabHighlight);
codeMirror?.addLineClass(lineNumber, 'background', tabHighlight);
const pageCountElement = Object.assign(document.createElement('span'), {
className : 'editor-page-count',
textContent : textOrSnip ? editorPageCount : userSnippetCount
});
codeMirror.setBookmark({ line: lineNumber, ch: line.length }, pageCountElement);
codeMirror?.setBookmark({ line: lineNumber, ch: line.length }, pageCountElement);
};
// New Codemirror styling for V3 renderer
// New CodeMirror styling for V3 renderer
if(this.props.renderer === 'V3') {
if(line.match(/^\\column(?:break)?$/)){
codeMirror.addLineClass(lineNumber, 'text', 'columnSplit');
codeMirror?.addLineClass(lineNumber, 'text', 'columnSplit');
}
// definition lists
@@ -231,14 +231,14 @@ const Editor = createClass({
const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error.
let match;
while ((match = regex.exec(line)) != null){
codeMirror.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' });
codeMirror.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
codeMirror.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
codeMirror?.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' });
codeMirror?.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
codeMirror?.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
const ddIndex = match.indices[2][0];
const colons = /::/g;
const colonMatches = colons.exec(match[2]);
if(colonMatches !== null){
codeMirror.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' });
codeMirror?.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' });
}
}
}
@@ -255,7 +255,7 @@ const Editor = createClass({
const match = subRegex.exec(line) || superRegex.exec(line);
if(match) {
isSuper = !subRegex.lastIndex;
codeMirror.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
}
startIndex = line.indexOf('^', Math.max(startIndex + 1, subRegex.lastIndex, superRegex.lastIndex));
}
@@ -266,7 +266,7 @@ const Editor = createClass({
const regex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gm;
let match;
while ((match = regex.exec(line)) != null) {
codeMirror.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'injection' });
codeMirror?.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'injection' });
}
}
// Highlight inline spans {{content}}
@@ -284,7 +284,7 @@ const Editor = createClass({
blockCount = 0;
continue;
}
codeMirror.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: 'inline-block' });
codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: 'inline-block' });
}
} else if(line.trimLeft().startsWith('{{') || line.trimLeft().startsWith('}}')){
// Highlight block divs {{\n Content \n}}
@@ -293,7 +293,7 @@ const Editor = createClass({
const match = line.match(/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/);
if(match)
endCh = match.index+match[0].length;
codeMirror.markText({ line: lineNumber, ch: 0 }, { line: lineNumber, ch: endCh }, { className: 'block' });
codeMirror?.markText({ line: lineNumber, ch: 0 }, { line: lineNumber, ch: endCh }, { className: 'block' });
}
// Emojis
@@ -314,11 +314,11 @@ const Editor = createClass({
const endPos = { line: lineNumber, ch: match.index + match[0].length };
// Iterate over conflicting marks and clear them
const marks = codeMirror.findMarks(startPos, endPos);
const marks = codeMirror?.findMarks(startPos, endPos);
marks.forEach(function(marker) {
if(!marker.__isFold) marker.clear();
});
codeMirror.markText(startPos, endPos, { className: 'emoji' });
codeMirror?.markText(startPos, endPos, { className: 'emoji' });
}
startIndex = line.indexOf(':', Math.max(startIndex + 1, emojiRegex.lastIndex));
}
@@ -378,44 +378,46 @@ const Editor = createClass({
const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit);
const targetLine = textString.match('\n') ? textString.split('\n').length - 1 : -1;
let currentY = this.codeEditor.current.codeMirror.getScrollInfo().top;
let targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
let currentY = this.codeEditor.current.codeMirror?.getScrollInfo().top;
let targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true);
let scrollingTimeout;
const checkIfScrollComplete = ()=>{ // Prevent interrupting a scroll in progress if user clicks multiple times
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
scrollingTimeout = setTimeout(()=>{
isJumping = false;
this.codeEditor.current.codeMirror.off('scroll', checkIfScrollComplete);
this.codeEditor.current.codeMirror?.off('scroll', checkIfScrollComplete);
}, 150); // If 150 ms pass without a scroll event, assume scrolling is done
};
isJumping = true;
checkIfScrollComplete();
this.codeEditor.current.codeMirror.on('scroll', checkIfScrollComplete);
if(this.codeEditor.current?.codeMirror) {
this.codeEditor.current.codeMirror?.on('scroll', checkIfScrollComplete);
}
if(smooth) {
//Scroll 1/10 of the way every 10ms until 1px off.
const incrementalScroll = setInterval(()=>{
currentY += (targetY - currentY) / 10;
this.codeEditor.current.codeMirror.scrollTo(null, currentY);
this.codeEditor.current.codeMirror?.scrollTo(null, currentY);
// Update target: target height is not accurate until within +-10 lines of the visible window
if(Math.abs(targetY - currentY > 100))
targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true);
// End when close enough
if(Math.abs(targetY - currentY) < 1) {
this.codeEditor.current.codeMirror.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
this.codeEditor.current.codeMirror.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
clearInterval(incrementalScroll);
}
}, 10);
} else {
this.codeEditor.current.codeMirror.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
this.codeEditor.current.codeMirror.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
}
},
@@ -440,6 +442,7 @@ const Editor = createClass({
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='gfm'
tab='brewText'
view={this.state.view}
value={this.props.brew.text}
onChange={this.props.onBrewChange('text')}
@@ -453,6 +456,7 @@ const Editor = createClass({
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='css'
tab='brewStyles'
view={this.state.view}
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
onChange={this.props.onBrewChange('style')}
@@ -482,6 +486,7 @@ const Editor = createClass({
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='gfm'
tab='brewSnippets'
view={this.state.view}
value={this.props.brew.snippets}
onChange={this.props.onBrewChange('snippets')}
@@ -542,4 +547,4 @@ const Editor = createClass({
}
});
module.exports = Editor;
export default Editor;
+4 -1
View File
@@ -1,9 +1,12 @@
@import 'themes/codeMirror/customEditorStyles.less';
@import '@sharedStyles/core.less';
@import '@themes/codeMirror/customEditorStyles.less';
.editor {
position : relative;
width : 100%;
height : 100%;
container : editor / inline-size;
background:white;
.codeEditor {
height : calc(100% - 25px);
.CodeMirror { height : 100%; }
@@ -1,19 +1,17 @@
/* eslint-disable max-lines */
require('./metadataEditor.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
import './metadataEditor.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import request from '../../utils/request-middleware.js';
const Combobox = require('client/components/combobox.jsx');
const TagInput = require('../tagInput/tagInput.jsx');
import Combobox from '../../../components/combobox.jsx';
import TagInput from '../tagInput/tagInput.jsx';
const Themes = require('themes/themes.json');
const validations = require('./validations.js');
import Themes from '@themes/themes.json';
import validations from './validations.js';
const SYSTEMS = ['5e', '4e', '3.5e', 'Pathfinder'];
const homebreweryThumbnail = require('../../thumbnail.png');
import homebreweryThumbnail from '../../thumbnail.png';
const callIfExists = (val, fn, ...args)=>{
if(val[fn]) {
@@ -21,7 +19,7 @@ const callIfExists = (val, fn, ...args)=>{
}
};
const MetadataEditor = createClass({
const MetadataEditor = createReactClass({
displayName : 'MetadataEditor',
getDefaultProps : function() {
return {
@@ -34,7 +32,6 @@ const MetadataEditor = createClass({
tags : [],
published : false,
authors : [],
systems : [],
renderer : 'legacy',
theme : '5ePHB',
lang : 'en'
@@ -92,15 +89,6 @@ const MetadataEditor = createClass({
}
},
handleSystem : function(system, e){
if(e.target.checked){
this.props.metadata.systems.push(system);
} else {
this.props.metadata.systems = _.without(this.props.metadata.systems, system);
}
this.props.onChange(this.props.metadata);
},
handleRenderer : function(renderer, e){
if(e.target.checked){
this.props.metadata.renderer = renderer;
@@ -156,18 +144,6 @@ const MetadataEditor = createClass({
});
},
renderSystems : function(){
return _.map(SYSTEMS, (val)=>{
return <label key={val}>
<input
type='checkbox'
checked={_.includes(this.props.metadata.systems, val)}
onChange={(e)=>this.handleSystem(val, e)} />
{val}
</label>;
});
},
renderPublish : function(){
if(this.props.metadata.published){
return <button className='unpublish' onClick={()=>this.handlePublish(false)}>
@@ -238,7 +214,7 @@ const MetadataEditor = createClass({
</div>;
} else {
dropdown =
<div className='value'>
<div className='value' data-tooltip-top='Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.'>
<Combobox trigger='click'
className='themes-dropdown'
default={currentThemeDisplay}
@@ -256,7 +232,6 @@ const MetadataEditor = createClass({
filterOn : ['value', 'title']
}}
/>
<small>Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.</small>
</div>;
}
@@ -281,7 +256,7 @@ const MetadataEditor = createClass({
return <div className='field language'>
<label>language</label>
<div className='value'>
<div className='value' data-tooltip-right='Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.'>
<Combobox trigger='click'
className='language-dropdown'
default={this.props.metadata.lang || ''}
@@ -298,14 +273,13 @@ const MetadataEditor = createClass({
filterOn : ['value', 'detail', 'title']
}}
/>
<small>Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.</small>
</div>
</div>;
},
renderRenderOptions : function(){
return <div className='field systems'>
return <div className='field renderers'>
<label>Renderer</label>
<div className='value'>
<label key='legacy'>
@@ -364,18 +338,20 @@ const MetadataEditor = createClass({
{this.renderThumbnail()}
</div>
<TagInput label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
<div className='field tags'>
<label>Tags</label>
<div className='value' >
<TagInput
label='tags'
valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/}
placeholder='add tag' unique={true}
values={this.props.metadata.tags}
onChange={(e)=>this.handleFieldChange('tags', e)}
tooltip='You may start tags with "type", "system", "group" or "meta" followed by a colon ":", these will be colored in your userpage.'
/>
</div>
</div>
<div className='field systems'>
<label>systems</label>
<div className='value'>
{this.renderSystems()}
</div>
</div>
{this.renderLanguageDropdown()}
@@ -387,13 +363,22 @@ const MetadataEditor = createClass({
{this.renderAuthors()}
<TagInput label='invited authors' valuePatterns={[/.+/]}
<div className='field invitedAuthors'>
<label>Invited authors</label>
<div className='value'>
<TagInput
label='invited authors'
valuePatterns={/.+/}
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
placeholder='invite author' unique={true}
tooltip={`Invited author usernames are case sensitive.
After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.`}
values={this.props.metadata.invitedAuthors}
notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']}
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}
/>
</div>
</div>
<h2>Privacy</h2>
@@ -411,4 +396,4 @@ const MetadataEditor = createClass({
}
});
module.exports = MetadataEditor;
export default MetadataEditor;
@@ -1,4 +1,4 @@
@import 'naturalcrit/styles/colors.less';
@import '@sharedStyles/core.less';
.userThemeName {
padding-right : 10px;
@@ -44,8 +44,6 @@
gap : 10px;
}
.field {
position : relative;
display : flex;
@@ -62,6 +60,7 @@
& > .value {
flex : 1 1 auto;
width : 50px;
&[data-tooltip-right] { max-width : 380px; }
&:invalid { background : #FFB9B9; }
small {
display : block;
@@ -74,6 +73,16 @@
border : 1px solid gray;
&:focus { outline : 1px solid #444444; }
}
&.description {
flex : 1;
textarea.value {
height : auto;
font-family : 'Open Sans', sans-serif;
resize : none;
}
}
&.thumbnail, &.themes {
label { line-height : 2.0em; }
.value {
@@ -90,6 +99,15 @@
}
}
&.tags .tagInput-dropdown {
z-index : 400;
max-width : 200px;
}
&.language .value {
z-index : 300;
max-width : 150px;
}
&.themes {
.value {
overflow : visible;
@@ -101,22 +119,13 @@
}
}
&.description {
flex : 1;
textarea.value {
height : auto;
font-family : 'Open Sans', sans-serif;
resize : none;
}
}
&.invitedAuthors .value {
z-index : 100;
&.language .language-dropdown {
z-index : 200;
max-width : 150px;
.tagInput-dropdown { max-width : 200px; }
}
}
.thumbnail-preview {
position : relative;
flex : 1 1;
@@ -129,7 +138,7 @@
background-color : #AAAAAA;
}
.systems.field .value {
.renderers.field .value {
label {
display : inline-flex;
align-items : center;
@@ -169,7 +178,7 @@
.themes.field {
& .dropdown-container {
position : relative;
z-index : 100;
z-index : 200;
background-color : white;
}
& .dropdown-options { overflow-y : visible; }
@@ -1,4 +1,4 @@
module.exports = {
export default {
title : [
(value)=>{
return value?.length > 100 ? 'Max title length of 100 characters' : null;
@@ -1,29 +1,36 @@
/*eslint max-lines: ["warn", {"max": 350, "skipBlankLines": true, "skipComments": true}]*/
require('./snippetbar.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
import './snippetbar.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import cx from 'classnames';
import { loadHistory } from '../../utils/versionHistory.js';
import { brewSnippetsToJSON } from '../../../../shared/helpers.js';
import { brewSnippetsToJSON } from '@shared/helpers.js';
//Import all themes
const ThemeSnippets = {};
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
ThemeSnippets['V3_5ePHB'] = require('themes/V3/5ePHB/snippets.js');
ThemeSnippets['V3_5eDMG'] = require('themes/V3/5eDMG/snippets.js');
ThemeSnippets['V3_Journal'] = require('themes/V3/Journal/snippets.js');
ThemeSnippets['V3_Blank'] = require('themes/V3/Blank/snippets.js');
import Legacy5ePHB from '@themes/Legacy/5ePHB/snippets.js';
import V3_5ePHB from '@themes/V3/5ePHB/snippets.js';
import V3_5eDMG from '@themes/V3/5eDMG/snippets.js';
import V3_Journal from '@themes/V3/Journal/snippets.js';
import V3_Blank from '@themes/V3/Blank/snippets.js';
const EditorThemes = require('build/homebrew/codeMirror/editorThemes.json');
const ThemeSnippets = {
Legacy_5ePHB : Legacy5ePHB,
V3_5ePHB : V3_5ePHB,
V3_5eDMG : V3_5eDMG,
V3_Journal : V3_Journal,
V3_Blank : V3_Blank,
};
import EditorThemes from '../../../../build/homebrew/codeMirror/editorThemes.json';
const execute = function(val, props){
if(_.isFunction(val)) return val(props);
return val;
};
const Snippetbar = createClass({
const Snippetbar = createReactClass({
displayName : 'SnippetBar',
getDefaultProps : function() {
return {
@@ -281,9 +288,9 @@ const Snippetbar = createClass({
}
});
module.exports = Snippetbar;
export default Snippetbar;
const SnippetGroup = createClass({
const SnippetGroup = createReactClass({
displayName : 'SnippetGroup',
getDefaultProps : function() {
return {
@@ -1,5 +1,6 @@
@import '@sharedStyles/core.less';
@import (less) './client/icons/customIcons.less';
@import (less) '././././themes/fonts/5e/fonts.less';
@import (less) '@themes/fonts/5e/fonts.less';
.snippetBar {
@menuHeight : 25px;
@@ -0,0 +1,219 @@
export const tagSuggestionList = [
// ############################## Systems
// D&D
'system:D&D Original',
'system:D&D Basic',
'system:AD&D 1e',
'system:AD&D 2e',
'system:D&D 3e',
'system:D&D 3.5e',
'system:D&D 4e',
'system:D&D 5e',
'system:D&D 5e 2024',
'system:BD&D (B/X)',
'system:D&D Essentials',
// Other Famous RPGs
'system:Pathfinder 1e',
'system:Pathfinder 2e',
'system:Vampire: The Masquerade',
'system:Werewolf: The Apocalypse',
'system:Mage: The Ascension',
'system:Call of Cthulhu',
'system:Shadowrun',
'system:Star Wars RPG (D6/D20/Edge of the Empire)',
'system:Warhammer Fantasy Roleplay',
'system:Cyberpunk 2020',
'system:Blades in the Dark',
'system:Daggerheart',
'system:Draw Steel',
'system:Mutants and Masterminds',
// Meta
'meta:V3',
'meta:Legacy',
'meta:Template',
'meta:Theme',
'meta:free',
'meta:Character Sheet',
'meta:Documentation',
'meta:NPC',
'meta:Guide',
'meta:Resource',
'meta:Notes',
'meta:Example',
// Book type
'type:Campaign',
'type:Campaign Setting',
'type:Adventure',
'type:One-Shot',
'type:Setting',
'type:World',
'type:Lore',
'type:History',
'type:Dungeon Master',
'type:Encounter Pack',
'type:Encounter',
'type:Session Notes',
'type:reference',
'type:Handbook',
'type:Manual',
'type:Manuals',
'type:Compendium',
'type:Bestiary',
// ###################################### RPG Keywords
// Classes / Subclasses / Archetypes
'Class',
'Subclass',
'Archetype',
'Martial',
'Half-Caster',
'Full Caster',
'Artificer',
'Barbarian',
'Bard',
'Cleric',
'Druid',
'Fighter',
'Monk',
'Paladin',
'Rogue',
'Sorcerer',
'Warlock',
'Wizard',
// Races / Species / Lineages
'Race',
'Ancestry',
'Lineage',
'Aasimar',
'Beastfolk',
'Dragonborn',
'Dwarf',
'Elf',
'Goblin',
'Half-Elf',
'Half-Orc',
'Human',
'Kobold',
'Lizardfolk',
'Lycan',
'Orc',
'Tiefling',
'Vampire',
'Yuan-Ti',
// Magic / Spells / Items
'Magic',
'Magic Item',
'Magic Items',
'Wondrous Item',
'Magic Weapon',
'Artifact',
'Spell',
'Spells',
'Cantrip',
'Cantrips',
'Eldritch',
'Eldritch Invocation',
'Invocation',
'Invocations',
'Pact boon',
'Pact Boon',
'Spellcaster',
'Spellblade',
'Magical Tattoos',
'Enchantment',
'Enchanted',
'Attunement',
'Requires Attunement',
'Rune',
'Runes',
'Wand',
'Rod',
'Scroll',
'Potion',
'Potions',
'Item',
'Items',
'Bag of Holding',
// Monsters / Creatures / Enemies
'Monster',
'Creatures',
'Creature',
'Beast',
'Beasts',
'Humanoid',
'Undead',
'Fiend',
'Aberration',
'Ooze',
'Giant',
'Dragon',
'Monstrosity',
'Demon',
'Devil',
'Elemental',
'Construct',
'Constructs',
'Boss',
'BBEG',
// ############################# Media / Pop Culture
'One Piece',
'Dragon Ball',
'Dragon Ball Z',
'Naruto',
'Jujutsu Kaisen',
'Fairy Tail',
'Final Fantasy',
'Kingdom Hearts',
'Elder Scrolls',
'Skyrim',
'WoW',
'World of Warcraft',
'Marvel Comics',
'DC Comics',
'Pokemon',
'League of Legends',
'Runeterra',
'Arcane',
'Yu-Gi-Oh',
'Minecraft',
'Don\'t Starve',
'Witcher',
'Witcher 3',
'Cyberpunk',
'Cyberpunk 2077',
'Fallout',
'Divinity Original Sin 2',
'Fullmetal Alchemist',
'Fullmetal Alchemist Brotherhood',
'Lobotomy Corporation',
'Bloodborne',
'Dragonlance',
'Shackled City Adventure Path',
'Baldurs Gate 3',
'Library of Ruina',
'Radiant Citadel',
'Ravenloft',
'Forgotten Realms',
'Exandria',
'Critical Role',
'Star Wars',
'SW5e',
'Star Wars 5e',
];
// substrings to be normalized to the first value on the array
export const canonizationList = [
['5e 2024', '5.5e', '5e\'24', '5.24', '5e24', '5.5'],
['5e', '5th Edition'],
['Dungeons & Dragons', 'Dungeons and Dragons', 'Dungeons n dragons'],
['D&D', 'DnD', 'dnd', 'Dnd', 'dnD', 'd&d', 'd&D', 'D&d'],
['P2e', 'p2e', 'P2E', 'Pathfinder 2e'],
];
+174 -81
View File
@@ -1,105 +1,198 @@
require('./tagInput.less');
const React = require('react');
const { useState, useEffect } = React;
const _ = require('lodash');
import './tagInput.less';
import React, { useState, useEffect } from 'react';
import Combobox from '../../../components/combobox.jsx';
const TagInput = ({ unique = true, values = [], ...props })=>{
const [tempInputText, setTempInputText] = useState('');
const [tagList, setTagList] = useState(values.map((value)=>({ value, editing: false })));
import { tagSuggestionList, canonizationList } from './curatedTagSuggestionList.js';
const TagInput = ({ tooltip, label, valuePatterns, values = [], unique = true, placeholder = '', smallText = '', onChange })=>{
const [tagList, setTagList] = useState(
values.map((value)=>({
value,
editing : false,
draft : '',
})),
);
useEffect(()=>{
handleChange(tagList.map((context)=>context.value));
const incoming = values || [];
const current = tagList.map((t)=>t.value);
const changed = incoming.length !== current.length || incoming.some((v, i)=>v !== current[i]);
if(changed) {
setTagList(
incoming.map((value)=>({
value,
editing : false,
})),
);
}
}, [values]);
useEffect(()=>{
onChange?.({
target : { value: tagList.map((t)=>t.value) },
});
}, [tagList]);
const handleChange = (value)=>{
props.onChange({
target : { value }
const normalizeValue = (input)=>{
const lowerInput = input.toLowerCase();
let normalizedTag = input;
for (const group of canonizationList) {
for (const tag of group) {
if(!tag) continue;
const index = lowerInput.indexOf(tag.toLowerCase());
if(index !== -1) {
normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length);
break;
}
}
}
if(normalizedTag.includes(':')) {
const [rawType, rawValue = ''] = normalizedTag.split(':');
const tagType = rawType.trim().toLowerCase();
const tagValue = rawValue.trim();
if(tagValue.length > 0) {
normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`;
}
//trims spaces around colon and capitalizes the first word after the colon
//this is preferred to users not understanding they can't put spaces in
}
return normalizedTag;
};
const submitTag = (newValue, index = null)=>{
const trimmed = newValue?.trim();
if(!trimmed) return;
if(!valuePatterns.test(trimmed)) return;
const normalizedTag = normalizeValue(trimmed);
setTagList((prev)=>{
const existsIndex = prev.findIndex((t)=>t.value.toLowerCase() === normalizedTag.toLowerCase());
if(unique && existsIndex !== -1) return prev;
if(index !== null) {
return prev.map((t, i)=>(i === index ? { ...t, value: normalizedTag, editing: false } : t));
}
return [...prev, { value: normalizedTag, editing: false }];
});
};
const handleInputKeyDown = ({ evt, value, index, options = {} })=>{
if(_.includes(['Enter', ','], evt.key)) {
evt.preventDefault();
submitTag(evt.target.value, value, index);
if(options.clear) {
setTempInputText('');
}
}
};
const submitTag = (newValue, originalValue, index)=>{
setTagList((prevContext)=>{
// remove existing tag
if(newValue === null){
return [...prevContext].filter((context, i)=>i !== index);
}
// add new tag
if(originalValue === null){
return [...prevContext, { value: newValue, editing: false }];
}
// update existing tag
return prevContext.map((context, i)=>{
if(i === index) {
return { ...context, value: newValue, editing: false };
}
return context;
});
});
const removeTag = (index)=>{
setTagList((prev)=>prev.filter((_, i)=>i !== index));
};
const editTag = (index)=>{
setTagList((prevContext)=>{
return prevContext.map((context, i)=>{
if(i === index) {
return { ...context, editing: true };
setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: true, draft: t.value } : t)));
};
const stopEditing = (index)=>{
setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: false, draft: '' } : t)));
};
const suggestionOptions = tagSuggestionList.map((tag)=>{
const tagType = tag.split(':');
let classes = 'item';
switch (tagType[0]) {
case 'type':
classes = 'item type';
break;
case 'group':
classes = 'item group';
break;
case 'meta':
classes = 'item meta';
break;
case 'system':
classes = 'item system';
break;
default:
classes = 'item';
break;
}
return { ...context, editing: false };
});
});
};
const renderReadTag = (context, index)=>{
return (
<li key={index}
data-value={context.value}
className='tag'
onClick={()=>editTag(index)}>
{context.value}
<button onClick={(evt)=>{evt.stopPropagation(); submitTag(null, context.value, index);}}><i className='fa fa-times fa-fw'/></button>
</li>
<div className={classes} key={`tag-${tag}`} value={tag} data={tag}>
{tag}
</div>
);
};
});
const renderWriteTag = (context, index)=>{
return (
<input type='text'
key={index}
defaultValue={context.value}
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: context.value, index: index })}
<div className='tagInputWrap'>
<Combobox
trigger='click'
className='tagInput-dropdown'
default=''
placeholder={placeholder}
options={label === 'tags' ? suggestionOptions : []}
tooltip={tooltip}
autoSuggest={
label === 'tags'
? {
suggestMethod : 'startsWith',
clearAutoSuggestOnClick : true,
filterOn : ['value', 'title'],
}
: { suggestMethod: 'includes', clearAutoSuggestOnClick: true, filterOn: [] }
}
valuePatterns={valuePatterns.source}
onSelect={(value)=>submitTag(value)}
onEntry={(e)=>{
if(e.key === 'Enter') {
e.preventDefault();
submitTag(e.target.value);
}
}}
/>
<ul className='list'>
{tagList.map((t, i)=>t.editing ? (
<input
key={i}
type='text'
value={t.draft} // always use draft
pattern={valuePatterns.source}
onChange={(e)=>setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: e.target.value } : tag)),
)
}
onKeyDown={(e)=>{
if(e.key === 'Enter') {
e.preventDefault();
submitTag(t.draft, i); // submit draft
setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: '' } : tag)),
);
}
if(e.key === 'Escape') {
stopEditing(i);
e.target.blur();
}
}}
autoFocus
/>
);
};
return (
<div className='field'>
<label>{props.label}</label>
<div className='value'>
<ul className='list'>
{tagList.map((context, index)=>{ return context.editing ? renderWriteTag(context, index) : renderReadTag(context, index); })}
) : (
<li key={i} className='tag' onClick={()=>editTag(i)}>
{t.value}
<button
type='button'
onClick={(e)=>{
e.stopPropagation();
removeTag(i);
}}>
<i className='fa fa-times fa-fw' />
</button>
</li>
),
)}
</ul>
<input
type='text'
className='value'
placeholder={props.placeholder}
value={tempInputText}
onChange={(e)=>setTempInputText(e.target.value)}
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: null, options: { clear: true } })}
/>
</div>
</div>
);
};
module.exports = TagInput;
export default TagInput;
@@ -0,0 +1,31 @@
.tags {
.tagInputWrap {
display:grid;
grid-template-columns: 200px 3fr;
gap:10px;
}
.list input {
border-radius: 5px;
}
.tagInput-dropdown {
.dropdown-options {
.item {
&.type {
background-color: #00800035;
}
&.group {
background-color: #50505035;
}
&.meta {
background-color: #00008035;
}
&.system {
background-color: #80000035;
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+21 -12
View File
@@ -1,8 +1,7 @@
/* eslint-disable camelcase */
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 React from 'react';
import { StaticRouter as Router, Route, Routes, useParams, useSearchParams } from 'react-router';
import { BrowserRouter as Router, Routes, Route, useParams, useSearchParams } from 'react-router';
import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js';
@@ -41,24 +40,34 @@ const Homebrew = (props)=>{
brews
} = props;
global.account = account;
global.version = version;
global.config = config;
const backgroundObject = ()=>{
if(global.config.deployment || (config.local && config.development)){
const bgText = global.config.deployment || 'Local';
if(config?.deployment || (config?.local && config?.development)) {
const bgText = config?.deployment || 'Local';
return {
backgroundImage : `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='100px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${bgText}</text></svg>")`
};
}
return null;
};
updateLocalStorage();
if(brew.pureError) {
return (
<Router location={url}>
<div className={`homebrew${(config.deployment || config.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<Router>
<div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<Routes>
<Route path={brew.originalUrl} element={<WithRoute el={ErrorPage} brew={brew} />} />
</Routes>
</div>
</Router>
);
}
return (
<Router>
<div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<Routes>
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} />} />
@@ -80,4 +89,4 @@ const Homebrew = (props)=>{
);
};
module.exports = Homebrew;
export default Homebrew;
+1 -1
View File
@@ -1,4 +1,4 @@
@import 'naturalcrit/styles/core.less';
@import '@sharedStyles/core.less';
.homebrew {
height : 100%;
background-color:@steel;
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from 'react-dom/client';
import Homebrew from './homebrew.jsx';
const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById('reactRoot')).render(<Homebrew {...props} />);
+7 -7
View File
@@ -1,9 +1,9 @@
const React = require('react');
const createClass = require('create-react-class');
const Nav = require('client/homebrew/navbar/nav.jsx');
const request = require('superagent');
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
import Nav from './nav.jsx';
const Account = createClass({
const Account = createReactClass({
displayName : 'AccountNavItem',
getInitialState : function() {
return {
@@ -97,7 +97,7 @@ const Account = createClass({
// Logged out
// LOCAL ONLY
if(global.config.local) {
if(global.config?.local) {
return <Nav.item color='teal' icon='fas fa-sign-in-alt' onClick={this.localLogin}>
login
</Nav.item>;
@@ -111,4 +111,4 @@ const Account = createClass({
}
});
module.exports = Account;
export default Account;
+4 -4
View File
@@ -1,6 +1,6 @@
require('./error-navitem.less');
const React = require('react');
const Nav = require('client/homebrew/navbar/nav.jsx');
import './error-navitem.less';
import React from 'react';
import Nav from './nav.jsx';
const ErrorNavItem = ({ error = '', clearError })=>{
const response = error.response;
@@ -144,4 +144,4 @@ const ErrorNavItem = ({ error = '', clearError })=>{
</Nav.item>;
};
module.exports = ErrorNavItem;
export default ErrorNavItem;
@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.navItem.error {
position : relative;
background-color : @red;
+4 -4
View File
@@ -1,9 +1,9 @@
const React = require('react');
const dedent = require('dedent-tabs').default;
import React from 'react';
import dedent from 'dedent';
const Nav = require('client/homebrew/navbar/nav.jsx');
import Nav from './nav.jsx';
module.exports = function(props){
export default function(props){
return <Nav.dropdown>
<Nav.item color='grey' icon='fas fa-question-circle'>
need help?
+6 -15
View File
@@ -1,11 +1,11 @@
const React = require('react');
const createClass = require('create-react-class');
const Moment = require('moment');
import React from 'react';
import createReactClass from 'create-react-class';
import Moment from 'moment';
const Nav = require('client/homebrew/navbar/nav.jsx');
import Nav from './nav.jsx';
const MetadataNav = createClass({
const MetadataNav = createReactClass({
displayName : 'MetadataNav',
getDefaultProps : function() {
return {
@@ -46,11 +46,6 @@ const MetadataNav = createClass({
</>;
},
getSystems : function(){
if(!this.props.brew.systems || this.props.brew.systems.length == 0) return 'No systems';
return this.props.brew.systems.join(', ');
},
renderMetaWindow : function(){
return <div className={`window ${this.state.showMetaWindow ? 'active' : 'inactive'}`}>
<div className='row'>
@@ -65,10 +60,6 @@ const MetadataNav = createClass({
<h4>Tags</h4>
<p>{this.getTags()}</p>
</div>
<div className='row'>
<h4>Systems</h4>
<p>{this.getSystems()}</p>
</div>
<div className='row'>
<h4>Updated</h4>
<p>{Moment(this.props.brew.updatedAt).fromNow()}</p>
@@ -86,4 +77,4 @@ const MetadataNav = createClass({
});
module.exports = MetadataNav;
export default MetadataNav;
+10 -11
View File
@@ -1,14 +1,13 @@
require('client/homebrew/navbar/navbar.less');
const React = require('react');
const { useState, useRef, useEffect } = React;
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
import './navbar.less';
import React, { useState, useRef, useEffect } from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import cx from 'classnames';
const NaturalCritIcon = require('client/components/svg/naturalcrit-d20.svg.jsx');
import NaturalCritIcon from '../../components/svg/naturalcrit-d20.svg.jsx';
const Nav = {
base : createClass({
base : createReactClass({
displayName : 'Nav.base',
render : function(){
return <nav>
@@ -25,7 +24,7 @@ const Nav = {
</a>;
},
section : createClass({
section : createReactClass({
displayName : 'Nav.section',
render : function(){
return <div className={`navSection ${this.props.className ?? ''}`}>
@@ -34,7 +33,7 @@ const Nav = {
}
}),
item : createClass({
item : createReactClass({
displayName : 'Nav.item',
getDefaultProps : function() {
return {
@@ -117,4 +116,4 @@ const Nav = {
};
module.exports = Nav;
export default Nav;
+9 -15
View File
@@ -1,22 +1,16 @@
require('./navbar.less');
const React = require('react');
const createClass = require('create-react-class');
import './navbar.less';
import React from 'react';
import createReactClass from 'create-react-class';
const Nav = require('client/homebrew/navbar/nav.jsx');
const PatreonNavItem = require('./patreon.navitem.jsx');
import Nav from './nav.jsx';
import PatreonNavItem from './patreon.navitem.jsx';
const Navbar = createClass({
const Navbar = createReactClass({
displayName : 'Navbar',
getInitialState : function() {
return {
//showNonChromeWarning : false,
ver : '0.0.0'
};
},
getInitialState : function() {
return {
ver : global.version
// showNonChromeWarning: false, // uncomment if needed
ver : global.version || '0.0.0'
};
},
@@ -49,4 +43,4 @@ const Navbar = createClass({
}
});
module.exports = Navbar;
export default Navbar;
+1 -1
View File
@@ -1,4 +1,4 @@
@import 'naturalcrit/styles/colors.less';
@import '@sharedStyles/core.less';
@navbarHeight : 28px;
@viewerToolsHeight : 32px;
+6 -6
View File
@@ -1,7 +1,7 @@
const React = require('react');
const _ = require('lodash');
const Nav = require('client/homebrew/navbar/nav.jsx');
const { splitTextStyleAndMetadata } = require('../../../shared/helpers.js'); // Importing the function from helpers.js
import React from 'react';
import _ from 'lodash';
import Nav from './nav.jsx';
import { splitTextStyleAndMetadata } from '@shared/helpers.js';
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
@@ -24,7 +24,7 @@ const NewBrew = ()=>{
localStorage.setItem(BREWKEY, newBrew.text);
localStorage.setItem(STYLEKEY, newBrew.style);
localStorage.setItem(METAKEY, JSON.stringify(
_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])
_.pick(newBrew, ['title', 'description', 'tags', 'renderer', 'theme', 'lang'])
));
window.location.href = '/new';
return;
@@ -100,4 +100,4 @@ const NewBrew = ()=>{
);
};
module.exports = NewBrew;
export default NewBrew;
+3 -3
View File
@@ -1,7 +1,7 @@
const React = require('react');
const Nav = require('client/homebrew/navbar/nav.jsx');
import React from 'react';
import Nav from './nav.jsx';
module.exports = function(props){
export default function(props){
return <Nav.item
className='patreon'
newTab={true}
+4 -4
View File
@@ -1,8 +1,8 @@
const React = require('react');
const Nav = require('client/homebrew/navbar/nav.jsx');
const { printCurrentBrew } = require('../../../shared/helpers.js');
import React from 'react';
import Nav from './nav.jsx';
import { printCurrentBrew } from '@shared/helpers.js';
module.exports = function(){
export default function(){
return <Nav.item onClick={printCurrentBrew} color='purple' icon='far fa-file-pdf'>
get PDF
</Nav.item>;
+7 -7
View File
@@ -1,15 +1,15 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const Moment = require('moment');
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import Moment from 'moment';
const Nav = require('client/homebrew/navbar/nav.jsx');
import Nav from './nav.jsx';
const EDIT_KEY = 'HB_nav_recentlyEdited';
const VIEW_KEY = 'HB_nav_recentlyViewed';
const RecentItems = createClass({
const RecentItems = createReactClass({
DisplayName : 'RecentItems',
getDefaultProps : function() {
return {
@@ -175,7 +175,7 @@ const RecentItems = createClass({
});
module.exports = {
export default {
edited : (props)=>{
return <RecentItems
+2 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import dedent from 'dedent-tabs';
import Nav from 'client/homebrew/navbar/nav.jsx';
import dedent from 'dedent';
import Nav from './nav.jsx';
const getShareId = (brew)=>(
brew.googleId && !brew.stubbed
+3 -3
View File
@@ -1,8 +1,8 @@
const React = require('react');
import React from 'react';
const Nav = require('client/homebrew/navbar/nav.jsx');
import Nav from './nav.jsx';
module.exports = function (props) {
export default function (props) {
return (
<Nav.item
color='purple'
@@ -1,7 +1,7 @@
const React = require('react');
const moment = require('moment');
const UIPage = require('../basePages/uiPage/uiPage.jsx');
const NaturalCritIcon = require('client/components/svg/naturalcrit-d20.svg.jsx');
import React from 'react';
import moment from 'moment';
import UIPage from '../basePages/uiPage/uiPage.jsx';
import NaturalCritIcon from '../../../components/svg/naturalcrit-d20.svg.jsx';
let SAVEKEY = '';
@@ -79,4 +79,4 @@ const AccountPage = (props)=>{
</UIPage>);
};
module.exports = AccountPage;
export default AccountPage;
@@ -1,12 +1,11 @@
require('./brewItem.less');
const React = require('react');
const { useCallback } = React;
const moment = require('moment');
import './brewItem.less';
import React, { useCallback } from 'react';
import moment from 'moment';
import request from '../../../../utils/request-middleware.js';
const googleDriveIcon = require('../../../../googleDrive.svg');
const homebreweryIcon = require('../../../../thumbnail.svg');
const dedent = require('dedent-tabs').default;
import googleDriveIcon from '../../../../googleDrive.svg';
import homebreweryIcon from '../../../../thumbnail.svg';
import dedent from 'dedent';
const BrewItem = ({
brew = {
@@ -176,4 +175,4 @@ const BrewItem = ({
);
};
module.exports = BrewItem;
export default BrewItem;
@@ -1,3 +1,4 @@
@import '@sharedStyles/core.less';
.brewItem {
position : relative;
@@ -1,11 +1,11 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./listPage.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const moment = require('moment');
import './listPage.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import moment from 'moment';
const BrewItem = require('./brewItem/brewItem.jsx');
import BrewItem from './brewItem/brewItem.jsx';
const USERPAGE_SORT_DIR = 'HB_listPage_sortDir';
const USERPAGE_SORT_TYPE = 'HB_listPage_sortType';
@@ -14,7 +14,7 @@ const USERPAGE_GROUP_VISIBILITY_PREFIX = 'HB_listPage_visibility_group';
const DEFAULT_SORT_TYPE = 'alpha';
const DEFAULT_SORT_DIR = 'asc';
const ListPage = createClass({
const ListPage = createReactClass({
displayName : 'ListPage',
getDefaultProps : function() {
return {
@@ -279,4 +279,4 @@ const ListPage = createClass({
}
});
module.exports = ListPage;
export default ListPage;
@@ -1,16 +1,17 @@
require('./uiPage.less');
const React = require('react');
const createClass = require('create-react-class');
import './uiPage.less';
import React from 'react';
import createReactClass from 'create-react-class';
const Nav = require('client/homebrew/navbar/nav.jsx');
const Navbar = require('client/homebrew/navbar/navbar.jsx');
const NewBrewItem = require('client/homebrew/navbar/newbrew.navitem.jsx');
const HelpNavItem = require('client/homebrew/navbar/help.navitem.jsx');
const RecentNavItem = require('client/homebrew/navbar/recent.navitem.jsx').both;
const Account = require('client/homebrew/navbar/account.navitem.jsx');
import Nav from '../../../navbar/nav.jsx';
import Navbar from '../../../navbar/navbar.jsx';
import NewBrewItem from '../../../navbar/newbrew.navitem.jsx';
import HelpNavItem from '../../../navbar/help.navitem.jsx';
import RecentNavItems from '../../../navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from '../../../navbar/account.navitem.jsx';
const UIPage = createClass({
const UIPage = createReactClass({
displayName : 'UIPage',
render : function(){
@@ -35,4 +36,4 @@ const UIPage = createClass({
}
});
module.exports = UIPage;
export default UIPage;
+33 -31
View File
@@ -4,33 +4,35 @@ import './editPage.less';
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from 'markdown.js';
import Markdown from '@shared/markdown.js';
import _ from 'lodash';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import SplitPane from 'client/components/splitPane/splitPane.jsx';
import SplitPane from '../../../components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from 'client/homebrew/navbar/nav.jsx';
import Navbar from 'client/homebrew/navbar/navbar.jsx';
import NewBrewItem from 'client/homebrew/navbar/newbrew.navitem.jsx';
import AccountNavItem from 'client/homebrew/navbar/account.navitem.jsx';
import ErrorNavItem from 'client/homebrew/navbar/error-navitem.jsx';
import HelpNavItem from 'client/homebrew/navbar/help.navitem.jsx';
import VaultNavItem from 'client/homebrew/navbar/vault.navitem.jsx';
import PrintNavItem from 'client/homebrew/navbar/print.navitem.jsx';
import { both as RecentNavItem } from 'client/homebrew/navbar/recent.navitem.jsx';
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
// Page specific imports
import { Meta } from 'vitreum/headtags';
import Headtags from '../../../../vitreum/headtags.js';
const Meta = Headtags.Meta;
import { md5 } from 'hash-wasm';
import { gzipSync, strToU8 } from 'fflate';
import { makePatches, stringifyPatches } from '@sanity/diff-match-patch';
import ShareNavItem from 'client/homebrew/navbar/share.navitem.jsx';
import ShareNavItem from '@navbar/share.navitem.jsx';
import LockNotification from './lockNotification/lockNotification.jsx';
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
import googleDriveIcon from '../../googleDrive.svg';
@@ -55,28 +57,28 @@ const EditPage = (props)=>{
...props
};
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 [error , setError ] = useState(null);
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1);
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 [error, setError] = useState(null);
const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle ] = useState({});
const [unsavedChanges , setUnsavedChanges ] = useState(false);
const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed);
const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false);
const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false);
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true);
const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true);
const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [alertTrashedGoogleBrew, setAlertTrashedGoogleBrew] = useState(props.brew.trashed);
const [alertLoginToTransfer, setAlertLoginToTransfer] = useState(false);
const [confirmGoogleTransfer, setConfirmGoogleTransfer] = useState(false);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const saveTimeout = useRef(null);
const warnUnsavedTimeout = useRef(null);
const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
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(()=>{
@@ -216,7 +218,7 @@ const EditPage = (props)=>{
text : brew.text.normalize('NFC'),
pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1,
patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))),
hash : await md5(lastSavedBrew.current.text),
hash : await md5(lastSavedBrew.current.text.normalize('NFC')),
textBin : undefined,
version : lastSavedBrew.current.version
};
@@ -415,4 +417,4 @@ const EditPage = (props)=>{
);
};
module.exports = EditPage;
export default EditPage;
@@ -40,4 +40,4 @@ function LockNotification(props) {
</Dialog>;
};
module.exports = LockNotification;
export default LockNotification;
@@ -1,8 +1,8 @@
require('./errorPage.less');
const React = require('react');
const UIPage = require('../basePages/uiPage/uiPage.jsx');
import Markdown from '../../../../shared/markdown.js';
const ErrorIndex = require('./errors/errorIndex.js');
import './errorPage.less';
import React from 'react';
import UIPage from '../basePages/uiPage/uiPage.jsx';
import Markdown from '@shared/markdown.js';
import ErrorIndex from './errors/errorIndex.js';
const ErrorPage = ({ brew })=>{
// Retrieving the error text based on the brew's error code from ErrorIndex
@@ -22,4 +22,4 @@ const ErrorPage = ({ brew })=>{
);
};
module.exports = ErrorPage;
export default ErrorPage;
@@ -1,7 +1,6 @@
.homebrew {
.uiPage.sitePage {
.uiPage.sitePage:has(.errorTitle) {
.errorTitle {
//background-color: @orange;
color : #D02727;
text-align : center;
}
@@ -1,4 +1,4 @@
const dedent = require('dedent-tabs').default;
import dedent from 'dedent';
const loginUrl = 'https://www.naturalcrit.com/login';
@@ -268,4 +268,4 @@ const errorIndex = (props)=>{
};
};
module.exports = errorIndex;
export default errorIndex;
+26 -23
View File
@@ -1,31 +1,34 @@
/* eslint-disable max-lines */
import './homePage.less';
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from 'markdown.js';
import Markdown from '@shared/markdown.js';
import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import SplitPane from 'client/components/splitPane/splitPane.jsx';
import SplitPane from '../../../components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from 'client/homebrew/navbar/nav.jsx';
import Navbar from 'client/homebrew/navbar/navbar.jsx';
import NewBrewItem from 'client/homebrew/navbar/newbrew.navitem.jsx';
import AccountNavItem from 'client/homebrew/navbar/account.navitem.jsx';
import ErrorNavItem from 'client/homebrew/navbar/error-navitem.jsx';
import HelpNavItem from 'client/homebrew/navbar/help.navitem.jsx';
import VaultNavItem from 'client/homebrew/navbar/vault.navitem.jsx';
import PrintNavItem from 'client/homebrew/navbar/print.navitem.jsx';
import { both as RecentNavItem } from 'client/homebrew/navbar/recent.navitem.jsx';
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
// Page specific imports
import { Meta } from 'vitreum/headtags';
import Headtags from '@vitreum/headtags.js';
const Meta = Headtags.Meta;
const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style';
@@ -42,16 +45,16 @@ const HomePage =(props)=>{
...props
};
const [currentBrew , setCurrentBrew] = useState(props.brew);
const [error , setError] = useState(undefined);
const [HTMLErrors , setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum] = useState(1);
const [currentBrew, setCurrentBrew] = useState(props.brew);
const [error, setError] = useState(undefined);
const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle] = useState({});
const [unsavedChanges , setUnsavedChanges] = useState(false);
const [isSaving , setIsSaving] = useState(false);
const [autoSaveEnabled , setAutoSaveEnable] = useState(false);
const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [autoSaveEnabled, setAutoSaveEnable] = useState(false);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
@@ -230,4 +233,4 @@ const HomePage =(props)=>{
);
};
module.exports = HomePage;
export default HomePage;
@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.homePage {
position : relative;
a.floatingNewButton {
@@ -36,7 +36,7 @@ After clicking the "Print" item in the navbar a new page will open and a print d
If you want to save ink or have a monochrome printer, add the **PRINT → {{fas,fa-tint}} Ink Friendly** snippet to your brew!
}}
![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px}
![homebrew mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {position:absolute,bottom:20px,left:130px,width:220px}
{{artist,bottom:160px,left:100px
##### Homebrew Mug
@@ -77,16 +77,16 @@ If you wish to sell or in some way gain profit for what's created on this site,
If you'd like to credit us in your brew, we'd be flattered! Just reference that you made it with The Homebrewery.
### More Homebrew Resources
[![Discord](/assets/discordOfManyThings.svg){width:50px,float:right,padding-left:10px}](https://discord.gg/by3deKx)
[![Discord](https://homebrewery.naturalcrit.com/assets/discordOfManyThings.svg){width:50px,float:right,padding-left:10px}](https://discord.gg/by3deKx)
If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](https://www.reddit.com/r/UnearthedArcana/) and their list of useful resources [here](https://www.reddit.com/r/UnearthedArcana/wiki/resources). The [Discord Of Many Things](https://discord.gg/by3deKx) is another great resource to connect with fellow homebrewers for help and feedback.
{{position:absolute;top:20px;right:20px;width:auto
[![Discord](/assets/discord.png){height:30px}](https://discord.gg/by3deKx)
[![Github](/assets/github.png){height:30px}](https://github.com/naturalcrit/homebrewery)
[![Patreon](/assets/patreon.png){height:30px}](https://patreon.com/NaturalCrit)
[![Reddit](/assets/reddit.png){height:30px}](https://www.reddit.com/r/homebrewery/)
[![Discord](https://homebrewery.naturalcrit.com/assets/discord.png){height:30px}](https://discord.gg/by3deKx)
[![Github](https://homebrewery.naturalcrit.com/assets/github.png){height:30px}](https://github.com/naturalcrit/homebrewery)
[![Patreon](https://homebrewery.naturalcrit.com/assets/patreon.png){height:30px}](https://patreon.com/NaturalCrit)
[![Reddit](https://homebrewery.naturalcrit.com/assets/reddit.png){height:30px}](https://www.reddit.com/r/homebrewery/)
}}
\page
@@ -162,7 +162,7 @@ Images must be hosted online somewhere, like [Imgur](https://www.imgur.com). You
Using *Curly Injection* you can assign an id, classes, or inline CSS properties to the Markdown image syntax.
![alt-text](https://s-media-cache-ak0.pinimg.com/736x/4a/81/79/4a8179462cfdf39054a418efd4cb743e.jpg) {width:100px,border:"2px solid",border-radius:10px}
![alt-text](https://homebrewery.naturalcrit.com/assets/catwarrior.jpg) {width:100px,border:"2px solid",border-radius:10px}
\* *When using Imgur-hosted images, use the "direct link", which can be found when you click into your image in the Imgur interface.*
+24 -24
View File
@@ -4,28 +4,28 @@ import './newPage.less';
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from 'markdown.js';
import Markdown from '@shared/markdown.js';
import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js';
import SplitPane from 'client/components/splitPane/splitPane.jsx';
import SplitPane from '../../../components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from 'client/homebrew/navbar/nav.jsx';
import Navbar from 'client/homebrew/navbar/navbar.jsx';
import NewBrewItem from 'client/homebrew/navbar/newbrew.navitem.jsx';
import AccountNavItem from 'client/homebrew/navbar/account.navitem.jsx';
import ErrorNavItem from 'client/homebrew/navbar/error-navitem.jsx';
import HelpNavItem from 'client/homebrew/navbar/help.navitem.jsx';
import VaultNavItem from 'client/homebrew/navbar/vault.navitem.jsx';
import PrintNavItem from 'client/homebrew/navbar/print.navitem.jsx';
import { both as RecentNavItem } from 'client/homebrew/navbar/recent.navitem.jsx';
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
// Page specific imports
import { Meta } from 'vitreum/headtags';
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
@@ -42,23 +42,23 @@ const NewPage = (props)=>{
...props
};
const [currentBrew , setCurrentBrew ] = useState(props.brew);
const [isSaving , setIsSaving ] = useState(false);
const [saveGoogle , setSaveGoogle ] = useState(global.account?.googleId ? true : false);
const [error , setError ] = useState(null);
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1);
const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving, setIsSaving] = useState(false);
const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false);
const [error, setError] = useState(null);
const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle ] = useState({});
const [unsavedChanges , setUnsavedChanges ] = useState(false);
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(false);
const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges, setUnsavedChanges] = useState(false);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(false);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
// const saveTimeout = useRef(null);
// const warnUnsavedTimeout = useRef(null);
const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
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(()=>{
@@ -276,4 +276,4 @@ const NewPage = (props)=>{
);
};
module.exports = NewPage;
export default NewPage;
@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.newPage {
.navItem.save {
background-color : @orange;
+15 -14
View File
@@ -1,18 +1,19 @@
require('./sharePage.less');
const React = require('react');
const { useState, useEffect, useCallback } = React;
const { Meta } = require('vitreum/headtags');
import './sharePage.less';
import React, { useState, useEffect, useCallback } from 'react';
import Headtags from '../../../../vitreum/headtags.js';
const Meta = Headtags.Meta;
const Nav = require('client/homebrew/navbar/nav.jsx');
const Navbar = require('client/homebrew/navbar/navbar.jsx');
const MetadataNav = require('client/homebrew/navbar/metadata.navitem.jsx');
const PrintNavItem = require('client/homebrew/navbar/print.navitem.jsx');
const RecentNavItem = require('client/homebrew/navbar/recent.navitem.jsx').both;
const Account = require('client/homebrew/navbar/account.navitem.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import MetadataNav from '@navbar/metadata.navitem.jsx';
import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from '@navbar/account.navitem.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
const SharePage = (props)=>{
const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props;
@@ -116,4 +117,4 @@ const SharePage = (props)=>{
);
};
module.exports = SharePage;
export default SharePage;
+13 -13
View File
@@ -1,17 +1,17 @@
const React = require('react');
const { useState } = React;
const _ = require('lodash');
import React, { useState } from 'react';
import _ from 'lodash';
const ListPage = require('../basePages/listPage/listPage.jsx');
import ListPage from '../basePages/listPage/listPage.jsx';
const Nav = require('client/homebrew/navbar/nav.jsx');
const Navbar = require('client/homebrew/navbar/navbar.jsx');
const RecentNavItem = require('client/homebrew/navbar/recent.navitem.jsx').both;
const Account = require('client/homebrew/navbar/account.navitem.jsx');
const NewBrew = require('client/homebrew/navbar/newbrew.navitem.jsx');
const HelpNavItem = require('client/homebrew/navbar/help.navitem.jsx');
const ErrorNavItem = require('client/homebrew/navbar/error-navitem.jsx');
const VaultNavitem = require('client/homebrew/navbar/vault.navitem.jsx');
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from '@navbar/account.navitem.jsx';
import NewBrew from '@navbar/newbrew.navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx';
import ErrorNavItem from '@navbar/error-navitem.jsx';
import VaultNavitem from '@navbar/vault.navitem.jsx';
const UserPage = (props)=>{
props = {
@@ -61,4 +61,4 @@ const UserPage = (props)=>{
);
};
module.exports = UserPage;
export default UserPage;
+13 -14
View File
@@ -1,19 +1,18 @@
/*eslint max-lines: ["warn", {"max": 400, "skipBlankLines": true, "skipComments": true}]*/
/*eslint max-params:["warn", { max: 10 }], */
require('./vaultPage.less');
import './vaultPage.less';
import React, { useState, useEffect, useRef } from 'react';
const React = require('react');
const { useState, useEffect, useRef } = React;
const Nav = require('client/homebrew/navbar/nav.jsx');
const Navbar = require('client/homebrew/navbar/navbar.jsx');
const RecentNavItem = require('client/homebrew/navbar/recent.navitem.jsx').both;
const Account = require('client/homebrew/navbar/account.navitem.jsx');
const NewBrew = require('client/homebrew/navbar/newbrew.navitem.jsx');
const HelpNavItem = require('client/homebrew/navbar/help.navitem.jsx');
const BrewItem = require('../basePages/listPage/brewItem/brewItem.jsx');
const SplitPane = require('client/components/splitPane/splitPane.jsx');
const ErrorIndex = require('../errorPage/errors/errorIndex.js');
import Nav from '@navbar/nav.jsx';
import Navbar from '@navbar/navbar.jsx';
import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from '@navbar/account.navitem.jsx';
import NewBrew from '@navbar/newbrew.navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx';
import BrewItem from '../basePages/listPage/brewItem/brewItem.jsx';
import SplitPane from '../../../components/splitPane/splitPane.jsx';
import ErrorIndex from '../errorPage/errors/errorIndex.js';
import request from '../../utils/request-middleware.js';
@@ -430,4 +429,4 @@ const VaultPage = (props)=>{
);
};
module.exports = VaultPage;
export default VaultPage;
@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.vaultPage {
height : 100%;
overflow-y : hidden;
-33
View File
@@ -1,33 +0,0 @@
const template = async function(name, title='', props = {}){
const ogTags = [];
const ogMeta = props.ogMeta ?? {};
Object.entries(ogMeta).forEach(([key, value])=>{
if(!value) return;
const tag = `<meta property="og:${key}" content="${value}">`;
ogTags.push(tag);
});
const ogMetaTags = ogTags.join('\n');
const ssrModule = await import(`../build/${name}/ssr.cjs`);
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, height=device-height, interactive-widget=resizes-visual" />
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
<link href=${`/${name}/bundle.css`} type="text/css" rel='stylesheet' />
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" />
${ogMetaTags}
<meta name="twitter:card" content="summary">
<title>${title.length ? `${title} - The Homebrewery`: 'The Homebrewery - NaturalCrit'}</title>
</head>
<body>
<main id="reactRoot">${ssrModule.default(props)}</main>
<script src=${`/${name}/bundle.js`}></script>
<script>start_app(${JSON.stringify(props)})</script>
</body>
</html>
`;
};
export default template;
+1 -1
View File
@@ -32,7 +32,7 @@ export default [{
"max-depth" : ["warn", { max: 4 }],
"max-params" : ["warn", { max: 5 }],
"no-restricted-syntax" : ["warn", "ClassDeclaration", "SwitchStatement"],
"no-unused-vars" : ["warn", { vars: "all", args: "none", varsIgnorePattern: "config|_|cx|createClass" }],
"no-unused-vars" : ["warn", { vars: "all", args: "none", varsIgnorePattern: "config|_|cx|createReactClass" }],
"react/jsx-uses-vars" : "warn",
/** Fixable **/
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, height=device-height, interactive-widget=resizes-visual" />
<link
href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700"
rel="stylesheet"
type="text/css" />
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" />
<meta name="twitter:card" content="summary" />
<title>The Homebrewery - NaturalCrit</title>
</head>
<body>
<main id="reactRoot"></main>
<script type="module">
if (window.location.pathname.startsWith('/admin')) {
import('/client/admin/main.jsx');
} else {
import('/client/homebrew/main.jsx');
}
</script>
</body>
</html>
+36 -34
View File
@@ -1,21 +1,19 @@
{
"name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.20.0",
"version": "3.20.1",
"type": "module",
"engines": {
"npm": "^10.8.x",
"node": "^20.18.x"
"npm": ">=10.8 <12",
"node": ">=20.18 <25"
},
"repository": {
"type": "git",
"url": "git://github.com/naturalcrit/homebrewery.git"
},
"scripts": {
"dev": "node --experimental-require-module scripts/dev.js",
"quick": "node --experimental-require-module scripts/quick.js",
"build": "node --experimental-require-module scripts/buildHomebrew.js && node --experimental-require-module scripts/buildAdmin.js",
"builddev": "node --experimental-require-module scripts/buildHomebrew.js --dev",
"start": "node server.js",
"build": "vite build",
"lint": "eslint --fix",
"lint:dry": "eslint",
"stylelint": "stylelint --fix **/*.{less}",
@@ -44,7 +42,6 @@
"phb": "node --experimental-require-module scripts/phb.js",
"prod": "set NODE_ENV=production && npm run build",
"postinstall": "npm run build",
"start": "node --experimental-require-module server.js",
"docker:build": "docker build -t ${DOCKERID}/homebrewery:$npm_package_version .",
"docker:publish": "docker login && docker push ${DOCKERID}/homebrewery:$npm_package_version"
},
@@ -61,8 +58,12 @@
"server"
],
"transformIgnorePatterns": [
"node_modules/(?!nanoid/).*"
"node_modules/(?!(nanoid|@exodus/bytes|parse5|@asamuzakjp|@csstools)/)"
],
"transform": {
"^.+\\.[jt]s$": "babel-jest",
"^.+\\.mjs$": "babel-jest"
},
"coveragePathIgnorePatterns": [
"build/*"
],
@@ -85,14 +86,15 @@
]
},
"dependencies": {
"@babel/core": "^7.28.4",
"@babel/plugin-transform-runtime": "^7.28.3",
"@babel/preset-env": "^7.28.3",
"@babel/core": "^7.29.0",
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/preset-react": "^7.28.5",
"@babel/runtime": "^7.28.4",
"@babel/runtime": "^7.28.6",
"@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^19.2.0",
"@googleapis/drive": "^20.1.0",
"@sanity/diff-match-patch": "^3.2.0",
"@vitejs/plugin-react": "^5.1.2",
"body-parser": "^2.2.0",
"classnames": "^2.5.1",
"codemirror": "^5.65.6",
@@ -100,18 +102,17 @@
"core-js": "^3.47.0",
"cors": "^2.8.5",
"create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3",
"expr-eval": "^2.0.2",
"dedent": "^1.7.1",
"express": "^5.1.0",
"express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.0",
"fflate": "^0.8.2",
"fs-extra": "11.3.2",
"fs-extra": "^11.3.3",
"hash-wasm": "^4.12.0",
"idb-keyval": "^6.2.2",
"js-yaml": "^4.1.1",
"jwt-simple": "^0.5.6",
"less": "^3.13.1",
"less": "^4.5.1",
"lodash": "^4.17.21",
"marked": "15.0.12",
"marked-alignment-paragraphs": "^1.0.0",
@@ -123,36 +124,37 @@
"marked-nonbreaking-spaces": "^1.0.1",
"marked-smartypants-lite": "^1.0.3",
"marked-subsuper-text": "^1.0.4",
"marked-variables": "^1.0.4",
"marked-variables": "^1.0.5",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
"mongoose": "^8.20.0",
"mongoose": "^9.2.1",
"nanoid": "5.1.6",
"nconf": "^0.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-frame-component": "^4.1.3",
"react-router": "^7.9.6",
"romans": "^3.1.0",
"node": "^25.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-frame-component": "^5.2.7",
"react-router": "^7.13.1",
"sanitize-filename": "1.6.3",
"superagent": "^10.2.1",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git",
"written-number": "^0.11.1"
"superagent": "^10.2.1"
},
"devDependencies": {
"@stylistic/stylelint-plugin": "^4.0.0",
"@stylistic/stylelint-plugin": "^5.0.1",
"babel-jest": "^30.2.0",
"babel-plugin-transform-import-meta": "^2.3.3",
"eslint": "^9.39.1",
"eslint": "9.7",
"eslint-plugin-jest": "^29.1.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.4.0",
"jest": "^30.2.0",
"jest-expect-message": "^1.1.3",
"jsdom": "^28.1.0",
"jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0",
"stylelint": "^16.25.0",
"stylelint-config-recess-order": "^7.3.0",
"stylelint-config-recommended": "^17.0.0",
"supertest": "^7.1.4"
"stylelint": "^17.4.0",
"stylelint-config-recess-order": "^7.6.1",
"stylelint-config-recommended": "^18.0.0",
"supertest": "^7.1.4",
"vite": "^7.3.1"
}
}
-32
View File
@@ -1,32 +0,0 @@
import fs from 'fs-extra';
import Proj from './project.json' with { type: 'json' };
import vitreum from 'vitreum';
const { pack } = vitreum;
import lessTransform from 'vitreum/transforms/less.js';
import assetTransform from 'vitreum/transforms/asset.js';
const isDev = !!process.argv.find((arg)=>arg=='--dev');
const transforms = {
'.less' : lessTransform,
'*' : assetTransform('./build')
};
const build = async ({ bundle, render, ssr })=>{
const css = await lessTransform.generate({ paths: './shared' });
await fs.outputFile('./build/admin/bundle.css', css);
await fs.outputFile('./build/admin/bundle.js', bundle);
await fs.outputFile('./build/admin/ssr.cjs', ssr);
};
fs.emptyDirSync('./build/admin');
pack('./client/admin/admin.jsx', {
paths : ['./shared'],
libs : Proj.libs,
dev : isDev && build,
transforms
})
.then(build)
.catch(console.error);
-169
View File
@@ -1,169 +0,0 @@
import fs from 'fs-extra';
import zlib from 'zlib';
import Proj from './project.json' with { type: 'json' };
import vitreum from 'vitreum';
const { pack, watchFile, livereload } = vitreum;
import lessTransform from 'vitreum/transforms/less.js';
import assetTransform from 'vitreum/transforms/asset.js';
import babel from '@babel/core';
import babelConfig from '../babel.config.json' with { type : 'json' };
import less from 'less';
const isDev = !!process.argv.find((arg)=>arg === '--dev');
const babelify = async (code)=>(await babel.transformAsync(code, babelConfig)).code;
const transforms = {
'.js' : (code, filename, opts)=>babelify(code),
'.jsx' : (code, filename, opts)=>babelify(code),
'.less' : lessTransform,
'*' : assetTransform('./build')
};
const build = async ({ bundle, render, ssr })=>{
const css = await lessTransform.generate({ paths: './shared' });
//css = `@layer bundle {\n${css}\n}`;
await fs.outputFile('./build/homebrew/bundle.css', css);
await fs.outputFile('./build/homebrew/bundle.js', bundle);
await fs.outputFile('./build/homebrew/ssr.cjs', ssr);
await fs.copy('./client/homebrew/favicon.ico', './build/assets/favicon.ico');
//compress files in production
if(!isDev){
await fs.outputFile('./build/homebrew/bundle.css.br', zlib.brotliCompressSync(css));
await fs.outputFile('./build/homebrew/bundle.js.br', zlib.brotliCompressSync(bundle));
await fs.outputFile('./build/homebrew/ssr.js.br', zlib.brotliCompressSync(ssr));
} else {
await fs.remove('./build/homebrew/bundle.css.br');
await fs.remove('./build/homebrew/bundle.js.br');
await fs.remove('./build/homebrew/ssr.js.br');
}
};
fs.emptyDirSync('./build');
(async ()=>{
//v==----------------------------- COMPILE THEMES --------------------------------==v//
// Update list of all Theme files
const themes = { Legacy: {}, V3: {} };
let themeFiles = fs.readdirSync('./themes/Legacy');
for (const dir of themeFiles) {
const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`).toString());
themeData.path = dir;
themes.Legacy[dir] = (themeData);
//fs.copy(`./themes/Legacy/${dir}/dropdownTexture.png`, `./build/themes/Legacy/${dir}/dropdownTexture.png`);
const src = `./themes/Legacy/${dir}/style.less`;
((outputDirectory)=>{
less.render(fs.readFileSync(src).toString(), {
compress : !isDev
}, function(e, output) {
fs.outputFile(outputDirectory, output.css);
});
})(`./build/themes/Legacy/${dir}/style.css`);
}
themeFiles = fs.readdirSync('./themes/V3');
for (const dir of themeFiles) {
const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`).toString());
themeData.path = dir;
themes.V3[dir] = (themeData);
fs.copy(`./themes/V3/${dir}/dropdownTexture.png`, `./build/themes/V3/${dir}/dropdownTexture.png`);
fs.copy(`./themes/V3/${dir}/dropdownPreview.png`, `./build/themes/V3/${dir}/dropdownPreview.png`);
const src = `./themes/V3/${dir}/style.less`;
((outputDirectory)=>{
less.render(fs.readFileSync(src).toString(), {
compress : !isDev
}, function(e, output) {
fs.outputFile(outputDirectory, output.css);
});
})(`./build/themes/V3/${dir}/style.css`);
}
await fs.outputFile('./themes/themes.json', JSON.stringify(themes, null, 2));
// await less.render(lessCode, {
// compress : !dev,
// sourceMap : (dev ? {
// sourceMapFileInline: true,
// outputSourceFiles: true
// } : false),
// })
// Move assets
await fs.copy('./themes/fonts', './build/fonts');
await fs.copy('./themes/assets', './build/assets');
await fs.copy('./client/icons', './build/icons');
//v==---------------------------MOVE CM EDITOR THEMES -----------------------------==v//
const editorThemesBuildDir = './build/homebrew/cm-themes';
await fs.copy('./node_modules/codemirror/theme', editorThemesBuildDir);
await fs.copy('./themes/codeMirror/customThemes', editorThemesBuildDir);
const editorThemeFiles = fs.readdirSync(editorThemesBuildDir);
const editorThemeFile = './themes/codeMirror/editorThemes.json';
if(fs.existsSync(editorThemeFile)) fs.rmSync(editorThemeFile);
const stream = fs.createWriteStream(editorThemeFile, { flags: 'a' });
stream.write('[\n"default"');
for (const themeFile of editorThemeFiles) {
stream.write(`,\n"${themeFile.slice(0, -4)}"`);
}
stream.write('\n]\n');
stream.end();
await fs.copy('./themes/codeMirror', './build/homebrew/codeMirror');
//v==----------------------------- BUNDLE PACKAGES --------------------------------==v//
const bundles = await pack('./client/homebrew/homebrew.jsx', {
paths : ['./shared', './'],
libs : Proj.libs,
dev : isDev && build,
transforms
});
build(bundles);
// Possible method for generating separate bundles for theme snippets: factor-bundle first sending all common files to bundle.js, then again using default settings, keeping only snippet bundles
// await fs.outputFile('./build/junk.js', '');
// await fs.outputFile('./build/themes/Legacy/5ePHB/snippets.js', '');
//
// const files = ['./client/homebrew/homebrew.jsx','./themes/Legacy/5ePHB/snippets.js'];
//
// bundles = await pack(files, {
// dedupe: false,
// plugin : [['factor-bundle', { outputs: [ './build/junk.js','./build/themes/Legacy/5ePHB/snippets.js'], threshold : function(row, groups) {
// console.log(groups);
// if (groups.some(group => /.*homebrew.jsx$/.test(group))) {
// console.log("found homebrewery")
// return true;
// }
// return this._defaultThreshold(row, groups);
// }}]],
// paths : ['./shared','./','./build'],
// libs : Proj.libs,
// dev : isDev && build,
// transforms
// });
// build(bundles);
//
//In development, set up LiveReload (refreshes browser), and Nodemon (restarts server)
if(isDev){
livereload('./build'); // Install the Chrome extension LiveReload to automatically refresh the browser
watchFile('./server.js', { // Restart server when change detected to this file or any nested directory from here
ignore : ['./build', './client', './themes'], // Ignore folders that are not running server code / avoids unneeded restarts
ext : 'js json' // Extensions to watch (only .js/.json by default)
//watch : ['./server', './themes'], // Watch additional folders if needed
});
}
})().catch(console.error);
-22
View File
@@ -1,22 +0,0 @@
const label = 'dev';
console.time(label);
const jsx = require('vitreum/steps/jsx.watch.js');
const less = require('vitreum/steps/less.watch.js');
const assets = require('vitreum/steps/assets.watch.js');
const server = require('vitreum/steps/server.watch.js');
const livereload = require('vitreum/steps/livereload.js');
const Proj = require('./project.json');
Promise.resolve()
.then(()=>jsx('homebrew', './client/homebrew/homebrew.jsx', { libs: Proj.libs, shared: ['./shared'] }))
.then((deps)=>less('homebrew', { shared: ['./shared'] }, deps))
.then(()=>jsx('admin', './client/admin/admin.jsx', { libs: Proj.libs, shared: ['./shared'] }))
.then((deps)=>less('admin', { shared: ['./shared'] }, deps))
.then(()=>assets(Proj.assets, ['./shared', './client']))
.then(()=>livereload())
.then(()=>server('./server.js', ['server']))
.then(console.timeEnd.bind(console, label))
.catch(console.error);
-17
View File
@@ -1,17 +0,0 @@
const label = 'quick';
console.time(label);
const jsx = require('vitreum/steps/jsx.js').partial;
const less = require('vitreum/steps/less.js').partial;
const server = require('vitreum/steps/server.watch.js').partial;
const Proj = require('./project.json');
Promise.resolve()
.then(jsx('homebrew', './client/homebrew/homebrew.jsx', Proj.libs, ['./shared']))
.then(less('homebrew', ['./shared']))
.then(jsx('admin', './client/admin/admin.jsx', Proj.libs, ['./shared']))
.then(less('admin', ['./shared']))
.then(server('./server.js', ['server']))
.then(console.timeEnd.bind(console, label))
.catch(console.error);
+29 -9
View File
@@ -1,12 +1,29 @@
import DB from './server/db.js';
import server from './server/app.js';
import createApp from './server/app.js';
import config from './server/config.js';
import { createServer as createViteServer } from 'vite';
DB.connect(config).then(()=>{
// Ensure that we have successfully connected to the database
// before launching server
const PORT = process.env.PORT || config.get('web_port') || 8000;
server.listen(PORT, ()=>{
const isDev = process.env.NODE_ENV === 'local';
async function start() {
let vite;
if(isDev) {
vite = await createViteServer({
server : { middlewareMode: true },
appType : 'custom',
});
}
await DB.connect(config).catch((err)=>{
console.error('Database connection failed:', err);
process.exit(1);
});
const app = await createApp(vite);
const PORT = process.env.PORT || config.get('web_port') || 3000;
app.listen(PORT, ()=>{
const reset = '\x1b[0m'; // Reset to default style
const bright = '\x1b[1m'; // Bright (bold) style
const cyan = '\x1b[36m'; // Cyan color
@@ -14,7 +31,10 @@ DB.connect(config).then(()=>{
console.log(`\n\tserver started at: ${new Date().toLocaleString()}`);
console.log(`\tserver on port: ${PORT}`);
console.log(`\t${bright + cyan}Open in browser: ${reset}${underline + bright + cyan}http://localhost:${PORT}${reset}\n\n`);
console.log(
`\t${bright + cyan}Open in browser: ${reset}${underline + bright + cyan}http://localhost:${PORT}${reset}\n\n`,
);
});
});
}
start();
+85 -67
View File
@@ -4,19 +4,24 @@ import { model as NotificationModel } from './notifications.model.js';
import express from 'express';
import Moment from 'moment';
import zlib from 'zlib';
import templateFn from '../client/template.js';
import config from './config.js';
import path from 'path';
import fs from 'fs-extra';
const nodeEnv = config.get('node_env');
const isProd = nodeEnv === 'production';
import HomebrewAPI from './homebrew.api.js';
import asyncHandler from 'express-async-handler';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
const router = express.Router();
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
const mw = {
export default function createAdminApi(vite) {
const router = express.Router();
const mw = {
adminOnly : (req, res, next)=>{
if(!req.get('authorization')){
return res
@@ -32,9 +37,9 @@ const mw = {
}
throw { HBErrorCode: '52', code: 401, message: 'Access denied' };
}
};
};
const junkBrewPipeline = [
const junkBrewPipeline = [
{ $match : {
updatedAt : { $lt: Moment().subtract(30, 'days').toDate() },
lastViewed : { $lt: Moment().subtract(30, 'days').toDate() }
@@ -42,25 +47,25 @@ const junkBrewPipeline = [
{ $project: { textBinSize: { $binarySize: '$textBin' } } },
{ $match: { textBinSize: { $lt: 140 } } },
{ $limit: 100 }
];
];
/* Search for brews that aren't compressed (missing the compressed text field) */
const uncompressedBrewQuery = HomebrewModel.find({
/* Search for brews that aren't compressed (missing the compressed text field) */
const uncompressedBrewQuery = HomebrewModel.find({
'text' : { '$exists': true }
}).lean().limit(10000).select('_id');
}).lean().limit(10000).select('_id');
// Search for up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.get('/admin/cleanup', mw.adminOnly, (req, res)=>{
// Search for up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.get('/admin/cleanup', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 })
.then((objs)=>res.json({ count: objs.length }))
.catch((error)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
});
// Delete up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
// Delete up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 })
.then((docs)=>{
const ids = docs.map((doc)=>doc._id);
@@ -71,15 +76,15 @@ router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
console.error(error);
res.status(500).json({ error: 'Internal Server Error' });
});
});
});
/* Searches for matching edit or share id, also attempts to partial match */
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{
/* Searches for matching edit or share id, also attempts to partial match */
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{
return res.json(req.brew);
});
});
/* Find 50 brews that aren't compressed yet */
router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
/* Find 50 brews that aren't compressed yet */
router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
const query = uncompressedBrewQuery.clone();
query.exec()
@@ -91,10 +96,10 @@ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
console.error(err);
res.status(500).send(err.message || 'Internal Server Error');
});
});
});
/* Cleans `<script` and `</script>` from the "text" field of a brew */
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
/* Cleans `<script` and `</script>` from the "text" field of a brew */
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Cleaning script tags from ShareID ${req.params.id}`);
function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');};
@@ -114,10 +119,10 @@ router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin',
req.account = undefined;
return await HomebrewAPI.updateBrew(req, res);
});
});
/* Get list of a user's documents */
router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
/* Get list of a user's documents */
router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
const username = req.params.user;
const fields = { _id: 0, text: 0, textBin: 0 }; // Remove unnecessary fields from document lists
@@ -126,10 +131,10 @@ router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
const brews = await HomebrewModel.getByUser(username, true, fields);
return res.json(brews);
});
});
/* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{
/* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{
HomebrewModel.findOne({ _id: req.params.id })
.then((brew)=>{
if(!brew)
@@ -147,9 +152,9 @@ router.put('/admin/compress/:id', (req, res)=>{
console.error(err);
res.status(500).send('Error while saving');
});
});
});
router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
try {
const totalBrewsCount = await HomebrewModel.countDocuments({});
const publishedBrewsCount = await HomebrewModel.countDocuments({ published: true });
@@ -162,11 +167,11 @@ router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
console.error(error);
return res.status(500).json({ error: 'Internal Server Error' });
}
});
});
// ####################### LOCKS
// ####################### LOCKS
router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{
router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksQuery = {
lock : { $exists: true }
@@ -178,9 +183,9 @@ router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{
return res.json({ count });
}));
}));
router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{
router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksPipeline = [
{
$match :
@@ -205,9 +210,9 @@ router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{
lockedDocuments
});
}));
}));
router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const lock = req.body;
@@ -237,9 +242,9 @@ router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
return res.json({ name: 'LOCKED', message: `Lock applied to brew ID ${brew.shareId} - ${brew.title}`, ...lock });
}));
}));
router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const filter = {
shareId : req.params.id
@@ -260,9 +265,9 @@ router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
});
return res.json({ name: 'Unlocked', message: `Lock removed from brew ID ${req.params.id}` });
}));
}));
router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{
router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{
const countReviewsPipeline = [
{
$match :
@@ -287,9 +292,9 @@ router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{
reviewDocuments
});
}));
}));
router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{
router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{
// === This route is NOT Admin only ===
// Any user can request a review of their document
const filter = {
@@ -314,9 +319,9 @@ router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{
return res.json({ name: 'Review Requested', message: `Review requested on brew ID ${brew.shareId} - ${brew.title}` });
}));
}));
router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const filter = {
shareId : req.params.id,
@@ -336,11 +341,11 @@ router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req,
return res.json({ name: 'Review Request Cleared', message: `Review request removed for brew ID ${brew.shareId} - ${brew.title}` });
}));
}));
// ####################### NOTIFICATIONS
// ####################### NOTIFICATIONS
router.get('/admin/notification/all', async (req, res, next)=>{
router.get('/admin/notification/all', async (req, res, next)=>{
try {
const notifications = await NotificationModel.getAll();
return res.json(notifications);
@@ -349,9 +354,9 @@ router.get('/admin/notification/all', async (req, res, next)=>{
console.log('Error getting all notifications: ', error.message);
return res.status(500).json({ message: error.message });
}
});
});
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.addNotification(req.body);
return res.status(201).json(notification);
@@ -359,9 +364,9 @@ router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
console.log('Error adding notification: ', error.message);
return res.status(500).json({ message: error.message });
}
});
});
router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{
router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.deleteNotification(req.params.id);
return res.json(notification);
@@ -369,17 +374,30 @@ router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, n
console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }');
return res.status(500).json({ message: error.message });
}
});
router.get('/admin', mw.adminOnly, (req, res)=>{
templateFn('admin', {
url : req.originalUrl
})
.then((page)=>res.send(page))
.catch((err)=>{
console.log(err);
res.sendStatus(500);
});
});
export default router;
router.get('/admin', mw.adminOnly, asyncHandler(async (req, res)=>{
const props = {
url : req.originalUrl
};
const htmlPath = isProd
? path.resolve('build', 'index.html')
: path.resolve('index.html');
let html = fs.readFileSync(htmlPath, 'utf-8');
if(!isProd && vite?.transformIndexHtml) {
html = await vite.transformIndexHtml(req.originalUrl, html);
}
res.send(html.replace(
'<head>',
`<head>\n<script id="props">window.__INITIAL_PROPS__ = ${JSON.stringify(props)}</script>`
));
}));
return router;
}
+139 -195
View File
@@ -1,28 +1,43 @@
/*eslint max-lines: ["warn", {"max": 1000, "skipBlankLines": true, "skipComments": true}]*/
import mongoose from 'mongoose';
import supertest from 'supertest';
import HBApp from './app.js';
import createApp from './app.js';
import { model as NotificationModel } from './notifications.model.js';
import { model as HomebrewModel } from './homebrew.model.js';
let app;
let request;
let dbState;
// Mimic https responses to avoid being redirected all the time
const app = supertest.agent(HBApp).set('X-Forwarded-Proto', 'https');
beforeAll(async ()=>{
app = await createApp();
request = supertest.agent(app).set('X-Forwarded-Proto', 'https');
});
describe('Tests for admin api', ()=>{
beforeEach(()=>{
dbState = mongoose.connection.readyState;
mongoose.connection.readyState = 1;
});
afterEach(()=>{
mongoose.connection.readyState = dbState;
jest.resetAllMocks();
});
afterAll(async ()=>{
await mongoose.connection.close();
});
describe('Notifications', ()=>{
it('should return list of all notifications', async ()=>{
const testNotifications = ['a', 'b'];
jest.spyOn(NotificationModel, 'find')
.mockImplementationOnce(()=>{
jest.spyOn(NotificationModel, 'find').mockImplementationOnce(()=>{
return { exec: jest.fn().mockResolvedValue(testNotifications) };
});
const response = await app
const response = await request
.get('/admin/notification/all')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
@@ -44,15 +59,14 @@ describe('Tests for admin api', ()=>{
_id : expect.any(String),
createdAt : expect.any(String),
startAt : inputNotification.startAt,
stopAt : inputNotification.stopAt,
stopAt : inputNotification.stopAt
};
jest.spyOn(NotificationModel.prototype, 'save')
.mockImplementationOnce(function() {
jest.spyOn(NotificationModel.prototype, 'save').mockImplementationOnce(function () {
return Promise.resolve(this);
});
const response = await app
const response = await request
.post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(inputNotification);
@@ -69,13 +83,11 @@ describe('Tests for admin api', ()=>{
stopAt : new Date().toISOString()
};
//Change 'save' function to just return itself instead of actually interacting with the database
jest.spyOn(NotificationModel.prototype, 'save')
.mockImplementationOnce(function() {
jest.spyOn(NotificationModel.prototype, 'save').mockImplementationOnce(function () {
return Promise.resolve(this);
});
const response = await app
const response = await request
.post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(inputNotification);
@@ -87,15 +99,15 @@ describe('Tests for admin api', ()=>{
it('should delete a notification based on its dismiss key', async ()=>{
const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete')
.mockImplementationOnce((key)=>{
jest.spyOn(NotificationModel, 'findOneAndDelete').mockImplementationOnce((key)=>{
return { exec: jest.fn().mockResolvedValue(key) };
});
const response = await app
const response = await request
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ 'dismissKey': 'testKey' });
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ dismissKey: 'testKey' });
expect(response.status).toBe(200);
expect(response.body).toEqual({ dismissKey: 'testKey' });
});
@@ -103,15 +115,15 @@ describe('Tests for admin api', ()=>{
it('should handle error deleting a notification that doesnt exist', async ()=>{
const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete')
.mockImplementationOnce(()=>{
jest.spyOn(NotificationModel, 'findOneAndDelete').mockImplementationOnce(()=>{
return { exec: jest.fn().mockResolvedValue() };
});
const response = await app
const response = await request
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ 'dismissKey': 'testKey' });
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ dismissKey: 'testKey' });
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Notification not found' });
});
@@ -120,30 +132,24 @@ describe('Tests for admin api', ()=>{
describe('Locks', ()=>{
describe('Count', ()=>{
it('Count of all locked documents', async ()=>{
const testNumber = 16777216; // 8^8, because why not
const testNumber = 16777216;
jest.spyOn(HomebrewModel, 'countDocuments')
.mockImplementationOnce(()=>{
return Promise.resolve(testNumber);
});
jest.spyOn(HomebrewModel, 'countDocuments').mockImplementationOnce(()=>Promise.resolve(testNumber));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/count');
const response = await request
.get('/api/lock/count')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ count: testNumber });
});
it('Handle error while fetching count of locked documents', async ()=>{
jest.spyOn(HomebrewModel, 'countDocuments')
.mockImplementationOnce(()=>{
return Promise.reject();
});
jest.spyOn(HomebrewModel, 'countDocuments').mockImplementationOnce(()=>Promise.reject());
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/count');
const response = await request
.get('/api/lock/count')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -151,7 +157,7 @@ describe('Tests for admin api', ()=>{
message : 'Unable to get lock count',
name : 'Lock Count Error',
originalUrl : '/api/lock/count',
status : 500,
status : 500
});
});
});
@@ -160,28 +166,22 @@ describe('Tests for admin api', ()=>{
it('Get list of all locked documents', async ()=>{
const testLocks = ['a', 'b'];
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.resolve(testLocks);
});
jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.resolve(testLocks));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/locks');
const response = await request
.get('/api/locks')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ lockedDocuments: testLocks });
});
it('Handle error while fetching list of all locked documents', async ()=>{
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.reject();
});
jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.reject());
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/locks');
const response = await request
.get('/api/locks')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -196,28 +196,22 @@ describe('Tests for admin api', ()=>{
it('Get list of all locked documents with pending review requests', async ()=>{
const testLocks = ['a', 'b'];
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.resolve(testLocks);
});
jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.resolve(testLocks));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/reviews');
const response = await request
.get('/api/lock/reviews')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ reviewDocuments: testLocks });
});
it('Handle error while fetching list of all locked documents with pending review requests', async ()=>{
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.reject();
});
jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.reject());
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/reviews');
const response = await request
.get('/api/lock/reviews')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -235,8 +229,8 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); }
markModified : ()=>true,
save : ()=>Promise.resolve()
};
const testLock = {
@@ -245,14 +239,11 @@ describe('Tests for admin api', ()=>{
shareMessage : 'share'
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
const response = await request
.post(`/api/lock/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock);
expect(response.status).toBe(200);
@@ -277,23 +268,20 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve(),
lock : {
code : 1,
editMessage : 'oldEdit',
shareMessage : 'oldShare',
shareMessage : 'oldShare'
}
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
const response = await request
.post(`/api/lock/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock);
expect(response.status).toBe(200);
@@ -317,23 +305,20 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve(),
lock : {
code : 1,
editMessage : 'oldEdit',
shareMessage : 'oldShare',
shareMessage : 'oldShare'
}
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
const response = await request
.post(`/api/lock/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock);
expect(response.status).toBe(500);
@@ -352,8 +337,8 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); }
markModified : ()=>true,
save : ()=>Promise.reject()
};
const testLock = {
@@ -362,14 +347,11 @@ describe('Tests for admin api', ()=>{
shareMessage : 'share'
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
const response = await request
.post(`/api/lock/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock);
expect(response.status).toBe(500);
@@ -396,19 +378,17 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/unlock/${testBrew.shareId}`);
const response = await request.put(`/api/unlock/${testBrew.shareId}`).set(
'Authorization',
`Basic ${Buffer.from('admin:password3').toString('base64')}`
);
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -421,18 +401,16 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve()
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/unlock/${testBrew.shareId}`);
const response = await request.put(`/api/unlock/${testBrew.shareId}`).set(
'Authorization',
`Basic ${Buffer.from('admin:password3').toString('base64')}`
);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -441,7 +419,7 @@ describe('Tests for admin api', ()=>{
name : 'Not Locked',
originalUrl : `/api/unlock/${testBrew.shareId}`,
shareId : testBrew.shareId,
status : 500,
status : 500
});
});
@@ -456,19 +434,17 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); },
markModified : ()=>true,
save : ()=>Promise.reject(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/unlock/${testBrew.shareId}`);
const response = await request.put(`/api/unlock/${testBrew.shareId}`).set(
'Authorization',
`Basic ${Buffer.from('admin:password3').toString('base64')}`
);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -494,40 +470,28 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`);
const response = await request.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({
message : `Review requested on brew ID ${testBrew.shareId} - ${testBrew.title}`,
name : 'Review Requested',
name : 'Review Requested'
});
});
it('Error when cannot find a locked brew', async ()=>{
const testBrew = {
shareId : 'shareId'
};
const testBrew = { shareId: 'shareId' };
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(false));
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`)
.catch((err)=>{return err;});
const response = await request.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -557,25 +521,20 @@ describe('Tests for admin api', ()=>{
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
.mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`)
.catch((err)=>{return err;});
const response = await request
.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '70',
HBErrorCode : '71',
code : 500,
message : `Cannot find a locked brew with ID ${testBrew.shareId}`,
name : 'Brew Not Found',
message : `Review already requested for brew ${testBrew.shareId} - ${testBrew.title}`,
name : 'Review Already Requested',
originalUrl : `/api/lock/review/request/${testBrew.shareId}`
});
});
it('Handle error while adding review request to a locked brew', async ()=>{
const testLock = {
applied : 'YES',
@@ -587,18 +546,14 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); },
markModified : ()=>true,
save : ()=>Promise.reject(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`);
const response = await request.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -622,19 +577,16 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
markModified : ()=>true,
save : ()=>Promise.resolve(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`);
const response = await request
.put(`/api/lock/review/remove/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({
@@ -644,18 +596,13 @@ describe('Tests for admin api', ()=>{
});
it('Error when clearing review request from a brew with no review request', async ()=>{
const testBrew = {
shareId : 'shareId',
};
const testBrew = { shareId: 'shareId' };
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(false));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`);
const response = await request
.put(`/api/lock/review/remove/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
@@ -678,19 +625,16 @@ describe('Tests for admin api', ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); },
markModified : ()=>true,
save : ()=>Promise.reject(),
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`);
const response = await request
.put(`/api/lock/review/remove/${testBrew.shareId}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
+139 -114
View File
@@ -12,10 +12,9 @@ import _ from 'lodash';
import jwt from 'jwt-simple';
import express from 'express';
import config from './config.js';
import path from 'path';
import fs from 'fs-extra';
const app = express();
import api from './homebrew.api.js';
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = api;
import adminApi from './admin.api.js';
@@ -24,7 +23,6 @@ import GoogleActions from './googleActions.js';
import serveCompressedStaticAssets from './static-assets.mv.js';
import sanitizeFilename from 'sanitize-filename';
import asyncHandler from 'express-async-handler';
import templateFn from '../client/template.js';
import { model as HomebrewModel } from './homebrew.model.js';
import { DEFAULT_BREW } from './brewDefaults.js';
@@ -37,30 +35,38 @@ import cookieParser from 'cookie-parser';
import forceSSL from './forcessl.mw.js';
import dbCheck from './middleware/dbCheck.js';
import cors from 'cors';
const sanitizeBrew = (brew, accessType)=>{
export default async function createApp(vite) {
const app = express();
const nodeEnv = config.get('node_env');
const isProd = nodeEnv === 'production';
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
const sanitizeBrew = (brew, accessType)=>{
brew._id = undefined;
brew.__v = undefined;
if(accessType !== 'edit' && accessType !== 'shareAuthor') {
brew.editId = undefined;
}
return brew;
};
};
app.set('trust proxy', 1 /* number of proxies between user and server */);
app.set('trust proxy', 1 /* number of proxies between user and server */);
app.use('/', serveCompressedStaticAssets(`build`));
app.use(contentNegotiation);
app.use(bodyParser.json({ limit: '25mb' }));
app.use(cookieParser());
app.use(forceSSL);
if(vite) {
app.use(vite.middlewares);
}
import cors from 'cors';
app.use('/', serveCompressedStaticAssets('build'));
app.use(contentNegotiation);
app.use(bodyParser.json({ limit: '25mb' }));
app.use(cookieParser());
app.use(forceSSL);
const nodeEnv = config.get('node_env');
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
const corsOptions = {
const corsOptions = {
origin : (origin, callback)=>{
const allowedOrigins = [
@@ -83,12 +89,12 @@ const corsOptions = {
},
methods : ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials : true,
};
};
app.use(cors(corsOptions));
app.use(cors(corsOptions));
//Account Middleware
app.use((req, res, next)=>{
//Account Middleware
app.use((req, res, next)=>{
if(req.cookies && req.cookies.nc_session){
try {
req.account = jwt.decode(req.cookies.nc_session, config.get('secret'));
@@ -104,35 +110,35 @@ app.use((req, res, next)=>{
google_client_secret : config.get('google_client_secret')
};
return next();
});
});
app.use(homebrewApi);
app.use(adminApi);
app.use(vaultApi);
app.use(homebrewApi);
app.use(adminApi(vite));
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');
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 = {
site_name : 'The Homebrewery - Make your Homebrew content look legit!',
title : 'The Homebrewery',
description : 'A NaturalCrit Tool for creating authentic Homebrews using Markdown.',
image : `${config.get('publicUrl')}/thumbnail.png`,
type : 'website'
};
};
//Robots.txt
app.get('/robots.txt', (req, res)=>{
//Robots.txt
app.get('/robots.txt', (req, res)=>{
return res.sendFile(`robots.txt`, { root: process.cwd() });
});
});
//Home page
app.get('/', (req, res, next)=>{
//Home page
app.get('/', (req, res, next)=>{
req.brew = {
text : welcomeText,
renderer : 'V3',
@@ -146,10 +152,10 @@ app.get('/', (req, res, next)=>{
splitTextStyleAndMetadata(req.brew);
return next();
});
});
//Home page Legacy
app.get('/legacy', (req, res, next)=>{
//Home page Legacy
app.get('/legacy', (req, res, next)=>{
req.brew = {
text : welcomeTextLegacy,
renderer : 'legacy',
@@ -163,10 +169,10 @@ app.get('/legacy', (req, res, next)=>{
splitTextStyleAndMetadata(req.brew);
return next();
});
});
//Legacy/Other Document -> v3 Migration Guide
app.get('/migrate', (req, res, next)=>{
//Legacy/Other Document -> v3 Migration Guide
app.get('/migrate', (req, res, next)=>{
req.brew = {
text : migrateText,
renderer : 'V3',
@@ -180,10 +186,10 @@ app.get('/migrate', (req, res, next)=>{
splitTextStyleAndMetadata(req.brew);
return next();
});
});
//Changelog page
app.get('/changelog', async (req, res, next)=>{
//Changelog page
app.get('/changelog', async (req, res, next)=>{
req.brew = {
title : 'Changelog',
text : changelogText,
@@ -198,10 +204,10 @@ app.get('/changelog', async (req, res, next)=>{
splitTextStyleAndMetadata(req.brew);
return next();
});
});
//FAQ page
app.get('/faq', async (req, res, next)=>{
//FAQ page
app.get('/faq', async (req, res, next)=>{
req.brew = {
title : 'FAQ',
text : faqText,
@@ -216,10 +222,10 @@ app.get('/faq', async (req, res, next)=>{
splitTextStyleAndMetadata(req.brew);
return next();
});
});
//Source page
app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{
//Source page
app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{
const { brew } = req;
const replaceStrings = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
@@ -229,10 +235,10 @@ app.get('/source/:id', asyncHandler(getBrew('share')), (req, res)=>{
}
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)=>{
//Download brew source page
app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{
const { brew } = req;
sanitizeBrew(brew, 'share');
const prefix = 'HB - ';
@@ -252,10 +258,10 @@ app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{
'Content-Disposition' : `attachment; filename*=UTF-8''${encodeRFC3986ValueChars(fileName)}.txt`
});
res.status(200).send(brew.text);
});
});
//Serve brew metadata
app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{
//Serve brew metadata
app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{
const { brew } = req;
sanitizeBrew(brew, 'share');
@@ -269,13 +275,13 @@ app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{
return acc;
}, {});
res.status(200).json(metadata);
});
});
//Serve brew styling
app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);});
//Serve brew styling
app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);});
//User Page
app.get('/user/:username', dbCheck, async (req, res, next)=>{
//User Page
app.get('/user/:username', dbCheck, async (req, res, next)=>{
const ownAccount = req.account && (req.account.username == req.params.username);
req.ogMeta = { ...defaultMetaTags,
@@ -344,10 +350,10 @@ app.get('/user/:username', dbCheck, async (req, res, next)=>{
});
return next();
});
});
//Change author name on brews
app.put('/api/user/rename', dbCheck, async (req, res)=>{
//Change author name on brews
app.put('/api/user/rename', dbCheck, async (req, res)=>{
const { username, newUsername } = req.body;
const ownAccount = req.account && (req.account.username == newUsername);
@@ -372,10 +378,10 @@ app.put('/api/user/rename', dbCheck, async (req, res)=>{
console.error('Error renaming brews:', error);
return res.status(500).json({ error: 'Failed to rename brews.' });
}
});
});
//Edit Page
app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, 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));
@@ -392,10 +398,10 @@ app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res,
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)=>{
//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 = {
@@ -418,10 +424,10 @@ app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res,
};
return next();
}));
}));
//New Page
app.get('/new', asyncHandler(async(req, res, next)=>{
//New Page
app.get('/new', asyncHandler(async(req, res, next)=>{
req.userThemes = await(getUsersBrewThemes(req.account?.username));
req.ogMeta = { ...defaultMetaTags,
@@ -430,10 +436,10 @@ app.get('/new', asyncHandler(async(req, res, next)=>{
};
return next();
}));
}));
//Share Page
app.get('/share/:id', dbCheck, asyncHandler(getBrew('share')), asyncHandler(async (req, res, 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.'}`,
@@ -457,10 +463,10 @@ app.get('/share/:id', dbCheck, asyncHandler(getBrew('share')), asyncHandler(asyn
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)=>{
//Account Page
app.get('/account', dbCheck, asyncHandler(async (req, res, next)=>{
const data = {};
data.title = 'Account Information Page';
@@ -510,10 +516,10 @@ app.get('/account', dbCheck, asyncHandler(async (req, res, next)=>{
};
return next();
}));
}));
// Local only
if(isLocalEnvironment){
// Local only
if(isLocalEnvironment){
// Login
app.post('/local/login', (req, res)=>{
const username = req.body.username;
@@ -522,32 +528,33 @@ if(isLocalEnvironment){
const payload = jwt.encode({ username: username, issued: new Date }, config.get('secret'));
return res.json(payload);
});
}
}
// Add Static Local Paths
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'));
// Add Static Local Paths
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'));
//Vault Page
app.get('/vault', asyncHandler(async(req, res, next)=>{
//Vault Page
app.get('/vault', asyncHandler(async(req, res, next)=>{
req.ogMeta = { ...defaultMetaTags,
title : 'The Vault',
description : 'Search for Brews'
};
return next();
}));
}));
//Send rendered page
app.use(asyncHandler(async (req, res, next)=>{
//Send rendered page
app.use(asyncHandler(async (req, res, next)=>{
if(!req.route) return res.redirect('/'); // Catch-all for invalid routes
const page = await renderPage(req, res);
if(!page) return;
res.send(page);
}));
}));
//Render the page
const renderPage = async (req, res)=>{
//Render the page
const renderPage = async (req, res)=>{
// Create configuration object
const configuration = {
local : isLocalEnvironment,
@@ -567,17 +574,34 @@ const renderPage = async (req, res)=>{
ogMeta : req.ogMeta,
userThemes : req.userThemes
};
const title = req.brew ? req.brew.title : '';
const page = await templateFn('homebrew', title, props)
.catch((err)=>{
console.log(err);
});
return page;
};
//v=====----- Error-Handling Middleware -----=====v//
//Format Errors as plain objects so all fields will appear in the string sent
const formatErrors = (key, value)=>{
const ogTags = [];
const ogMeta = req.ogMeta ?? {};
Object.entries(ogMeta).forEach(([key, value])=>{
if(!value) return;
const tag = `<meta property="og:${key}" content="${value}">`;
ogTags.push(tag);
});
const ogMetaTags = ogTags.join('\n');
const htmlPath = isProd ? path.resolve('build', 'index.html') : path.resolve('index.html');
let html = fs.readFileSync(htmlPath, 'utf-8');
if(!isProd && vite?.transformIndexHtml) {
html = await vite.transformIndexHtml(req.originalUrl, html);
}
html = html.replace(
'<head>',
`<head>\n<script id="props" >window.__INITIAL_PROPS__ = ${JSON.stringify(props)}</script>\n${ogMetaTags}`
);
return html;
};
//v=====----- Error-Handling Middleware -----=====v//
//Format Errors as plain objects so all fields will appear in the string sent
const formatErrors = (key, value)=>{
if(value instanceof Error) {
const error = {};
Object.getOwnPropertyNames(value).forEach(function (key) {
@@ -586,13 +610,13 @@ const formatErrors = (key, value)=>{
return error;
}
return value;
};
};
const getPureError = (error)=>{
const getPureError = (error)=>{
return JSON.parse(JSON.stringify(error, formatErrors));
};
};
app.use(async (err, req, res, next)=>{
app.use(async (err, req, res, next)=>{
err.originalUrl = req.originalUrl;
console.error(err);
@@ -622,14 +646,15 @@ app.use(async (err, req, res, next)=>{
const page = await renderPage(req, res);
if(!page) return;
res.send(page);
});
});
app.use((req, res)=>{
app.use((req, res)=>{
if(!res.headersSent) {
console.error('Headers have not been sent, responding with a server error.', req.url);
res.status(500).send('An error occurred and the server did not send a response. The error has been logged, please note the time this occurred and report this issue.');
}
});
//^=====--------------------------------------=====^//
});
//^=====--------------------------------------=====^//
export default app;
return app;
}

Some files were not shown because too many files have changed in this diff Show More