0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-03-22 06:48:11 +00:00

Merge branch 'master' into commonRenderSaveButton

This commit is contained in:
Trevor Buckner
2026-03-09 22:12:19 -04:00
100 changed files with 5802 additions and 5401 deletions

View File

@@ -49,4 +49,4 @@ const Admin = ()=>{
);
};
module.exports = Admin;
export default Admin;

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%; }

View File

@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.brewUtil {
.result {
margin-top : 20px;

6
client/admin/main.jsx Normal file
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} />);

View File

@@ -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,

View File

@@ -12,6 +12,7 @@ const CodeEditor = createReactClass({
getDefaultProps : function() {
return {
language : '',
tab : 'brewText',
value : '',
wrap : true,
onChange : ()=>{},
@@ -186,6 +187,22 @@ const CodeEditor = createReactClass({
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()) {
@@ -200,6 +217,7 @@ const CodeEditor = createReactClass({
},
makeHeader : function (number) {
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection();
const header = Array(number).fill('#').join('');
this.codeMirror?.replaceSelection(`${header} ${selection}`, 'around');
@@ -208,6 +226,7 @@ const CodeEditor = createReactClass({
},
makeBold : 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');
if(selection.length === 0){
@@ -217,7 +236,8 @@ const CodeEditor = createReactClass({
},
makeItalic : function() {
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 1) === '*' && selection.slice(-1) === '*';
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');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
@@ -226,7 +246,8 @@ const CodeEditor = createReactClass({
},
makeSuper : function() {
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 1) === '^' && selection.slice(-1) === '^';
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');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
@@ -235,7 +256,8 @@ const CodeEditor = createReactClass({
},
makeSub : function() {
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 2) === '^^' && selection.slice(-2) === '^^';
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();
@@ -245,10 +267,12 @@ const CodeEditor = createReactClass({
makeNbsp : function() {
if(!this.isGFM()) return;
this.codeMirror?.replaceSelection('&nbsp;', 'end');
},
makeSpace : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection();
const t = selection.slice(0, 8) === '{{width:' && selection.slice(0 -4) === '% }}';
if(t){
@@ -260,6 +284,7 @@ const CodeEditor = createReactClass({
},
removeSpace : function() {
if(!this.isGFM()) return;
const selection = this.codeMirror?.getSelection();
const t = selection.slice(0, 8) === '{{width:' && selection.slice(0 -4) === '% }}';
if(t){
@@ -269,10 +294,12 @@ const CodeEditor = createReactClass({
},
newColumn : function() {
if(!this.isGFM()) return;
this.codeMirror?.replaceSelection('\n\\column\n\n', 'end');
},
newPage : function() {
if(!this.isGFM()) return;
this.codeMirror?.replaceSelection('\n\\page\n\n', 'end');
},
@@ -286,7 +313,8 @@ const CodeEditor = createReactClass({
},
makeUnderline : function() {
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 3) === '<u>' && selection.slice(-4) === '</u>';
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');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
@@ -295,7 +323,8 @@ const CodeEditor = createReactClass({
},
makeSpan : function() {
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 2) === '{{' && selection.slice(-2) === '}}';
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();
@@ -304,7 +333,8 @@ const CodeEditor = createReactClass({
},
makeDiv : function() {
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 2) === '{{' && selection.slice(-2) === '}}';
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');
if(selection.length === 0){
const cursor = this.codeMirror?.getCursor();
@@ -317,7 +347,7 @@ const CodeEditor = createReactClass({
let cursorPos;
let newComment;
const selection = this.codeMirror?.getSelection();
if(this.props.language === 'gfm'){
if(this.isGFM()){
regex = /^\s*(<!--\s?)(.*?)(\s?-->)\s*$/gs;
cursorPos = 4;
newComment = `<!-- ${selection} -->`;
@@ -334,6 +364,7 @@ const CodeEditor = createReactClass({
},
makeLink : function() {
if(!this.isGFM()) return;
const isLink = /^\[(.*)\]\((.*)\)$/;
const selection = this.codeMirror?.getSelection().trim();
let match;
@@ -351,7 +382,8 @@ const CodeEditor = createReactClass({
},
makeList : function(listType) {
const selectionStart = this.codeMirror?.getCursor('from'), selectionEnd = this.codeMirror?.getCursor('to');
if(!this.isGFM()) return;
const selectionStart = this.codeMirror.getCursor('from'), selectionEnd = this.codeMirror.getCursor('to');
this.codeMirror?.setSelection(
{ line: selectionStart.line, ch: 0 },
{ line: selectionEnd.line, ch: this.codeMirror?.getLine(selectionEnd.line).length }

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;}

View File

@@ -11,14 +11,17 @@ const Combobox = createReactClass({
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 = createReactClass({
},
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 = createReactClass({
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 = createReactClass({
});
}
}}
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 = createReactClass({
});
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)}

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;

View File

@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.renderWarnings {
position : relative;
float : right;

View File

@@ -1,3 +1,4 @@
@import '@sharedStyles/core.less';
.splitPane {
position : relative;

View File

@@ -1,10 +1,12 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
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';
import MarkdownLegacy from '../../../shared/markdownLegacy.js';
import Markdown from '../../../shared/markdown.js';
import MarkdownLegacy from '@shared/markdownLegacy.js';
import Markdown from '@shared/markdown.js';
import ErrorBar from './errorBar/errorBar.jsx';
import ToolBar from './toolBar/toolBar.jsx';
@@ -13,10 +15,10 @@ 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 { 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;
@@ -29,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>`;

View File

@@ -1,4 +1,4 @@
@import (multiple, less) 'shared/naturalcrit/styles/reset.less';
@import '@sharedStyles/core.less';
.brewRenderer {
height : 100vh;

View File

@@ -1,3 +1,4 @@
@import '@sharedStyles/colors.less';
.errorBar {
position : absolute;

View File

@@ -1,7 +1,7 @@
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';

View File

@@ -1,3 +1,5 @@
@import './client/homebrew/navbar/navbar.less';
.popups {
position : fixed;
top : calc(@navbarHeight + @viewerToolsHeight);

View File

@@ -43,4 +43,4 @@ function safeHTML(htmlString) {
return div.innerHTML;
};
module.exports.safeHTML = safeHTML;
export default safeHTML;

View File

@@ -99,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' />
@@ -118,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' />
@@ -128,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>
@@ -137,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}
@@ -154,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>
@@ -166,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>
@@ -217,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)}
>
@@ -230,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}
@@ -240,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)}
>

View File

@@ -166,7 +166,7 @@
&.hidden {
flex-wrap : nowrap;
width : 92px;
width : 50%;
overflow : hidden;
background-color : unset;
opacity : 0.7;

View File

@@ -4,7 +4,7 @@ import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import dedent from 'dedent';
import Markdown from '../../../shared/markdown.js';
import Markdown from '@shared/markdown.js';
import CodeEditor from '../../components/codeEditor/codeEditor.jsx';
import SnippetBar from './snippetbar/snippetbar.jsx';
@@ -88,7 +88,7 @@ const Editor = createReactClass({
const snippetBar = document.querySelector('.editor > .snippetBar');
if(!snippetBar) return;
this.resizeObserver = new ResizeObserver(entries => {
this.resizeObserver = new ResizeObserver((entries)=>{
const height = document.querySelector('.editor > .snippetBar').offsetHeight;
this.setState({ snippetBarHeight: height });
});
@@ -442,6 +442,7 @@ const Editor = createReactClass({
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='gfm'
tab='brewText'
view={this.state.view}
value={this.props.brew.text}
onChange={this.props.onBrewChange('text')}
@@ -455,6 +456,7 @@ const Editor = createReactClass({
<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')}
@@ -484,6 +486,7 @@ const Editor = createReactClass({
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='gfm'
tab='brewSnippets'
view={this.state.view}
value={this.props.brew.snippets}
onChange={this.props.onBrewChange('snippets')}

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%; }

View File

@@ -7,10 +7,9 @@ import request from '../../utils/request-middleware.js';
import Combobox from '../../../components/combobox.jsx';
import TagInput from '../tagInput/tagInput.jsx';
import Themes from 'themes/themes.json';
import validations from './validations.js';
const SYSTEMS = ['5e', '4e', '3.5e', 'Pathfinder'];
import Themes from '@themes/themes.json';
import validations from './validations.js';
import homebreweryThumbnail from '../../thumbnail.png';
@@ -33,7 +32,6 @@ const MetadataEditor = createReactClass({
tags : [],
published : false,
authors : [],
systems : [],
renderer : 'legacy',
theme : '5ePHB',
lang : 'en'
@@ -91,15 +89,6 @@ const MetadataEditor = createReactClass({
}
},
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;
@@ -155,18 +144,6 @@ const MetadataEditor = createReactClass({
});
},
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)}>
@@ -237,7 +214,7 @@ const MetadataEditor = createReactClass({
</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}
@@ -255,7 +232,6 @@ const MetadataEditor = createReactClass({
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>;
}
@@ -280,7 +256,7 @@ const MetadataEditor = createReactClass({
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 || ''}
@@ -297,14 +273,13 @@ const MetadataEditor = createReactClass({
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'>
@@ -363,18 +338,20 @@ const MetadataEditor = createReactClass({
{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()}
@@ -386,13 +363,22 @@ const MetadataEditor = createReactClass({
{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>

View File

@@ -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; }

View File

@@ -7,13 +7,13 @@ 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 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';
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 ThemeSnippets = {
Legacy_5ePHB : Legacy5ePHB,
@@ -23,7 +23,7 @@ const ThemeSnippets = {
V3_Blank : V3_Blank,
};
import EditorThemes from 'build/homebrew/codeMirror/editorThemes.json';
import EditorThemes from '../../../../build/homebrew/codeMirror/editorThemes.json';
const execute = function(val, props){
if(_.isFunction(val)) return val(props);

View File

@@ -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;

View File

@@ -0,0 +1,210 @@
export default [
// ############################## 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',
];

View File

@@ -1,102 +1,205 @@
import './tagInput.less';
import React, { useState, useEffect } from 'react';
import _ from 'lodash';
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 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 }
// substrings to be normalized to the first value on the array
const duplicateGroups = [
['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'],
];
const normalizeValue = (input)=>{
const lowerInput = input.toLowerCase();
let normalizedTag = input;
for (const group of duplicateGroups) {
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>
);
};

View File

@@ -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

View File

@@ -1,8 +1,7 @@
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,21 @@ 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();
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='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} />} />
@@ -80,4 +76,4 @@ const Homebrew = (props)=>{
);
};
module.exports = Homebrew;
export default Homebrew;

View File

@@ -1,4 +1,4 @@
@import 'naturalcrit/styles/core.less';
@import '@sharedStyles/core.less';
.homebrew {
height : 100%;
background-color:@steel;

6
client/homebrew/main.jsx Normal file
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} />);

View File

@@ -97,7 +97,7 @@ const Account = createReactClass({
// 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>;

View File

@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.navItem.error {
position : relative;
background-color : @red;

View File

@@ -9,14 +9,8 @@ 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'
};
},

View File

@@ -1,4 +1,4 @@
@import 'naturalcrit/styles/colors.less';
@import '@sharedStyles/core.less';
@navbarHeight : 28px;
@viewerToolsHeight : 32px;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import _ from 'lodash';
import Nav from './nav.jsx';
import { splitTextStyleAndMetadata } from '../../../shared/helpers.js';
import { splitTextStyleAndMetadata } from '@shared/helpers.js';
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import Nav from './nav.jsx';
import { printCurrentBrew } from '../../../shared/helpers.js';
import { printCurrentBrew } from '@shared/helpers.js';
export default function(){
return <Nav.item onClick={printCurrentBrew} color='purple' icon='far fa-file-pdf'>

View File

@@ -1,3 +1,4 @@
@import '@sharedStyles/core.less';
.brewItem {
position : relative;

View File

@@ -4,34 +4,35 @@ import './editPage.less';
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from '../../../../shared/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 '../../../components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.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';
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 '../../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';
@@ -77,7 +78,7 @@ const EditPage = (props)=>{
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(()=>{

View File

@@ -1,7 +1,7 @@
import './errorPage.less';
import React from 'react';
import UIPage from '../basePages/uiPage/uiPage.jsx';
import Markdown from '../../../../shared/markdown.js';
import Markdown from '@shared/markdown.js';
import ErrorIndex from './errors/errorIndex.js';
const ErrorPage = ({ brew })=>{

View File

@@ -1,33 +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 '../../../../shared/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 '../../../components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.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';
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';

View File

@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.homePage {
position : relative;
a.floatingNewButton {

View File

@@ -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.*

View File

@@ -4,29 +4,28 @@ import './newPage.less';
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from '../../../../shared/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 '../../../components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.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';
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 UNSAVED_WARNING_TIMEOUT = 9000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
@@ -64,7 +63,7 @@ const NewPage = (props)=>{
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(()=>{

View File

@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.newPage {
.navItem.save {
background-color : @orange;

View File

@@ -1,18 +1,19 @@
import './sharePage.less';
import React, { useState, useEffect, useCallback } from 'react';
import { Meta } from 'vitreum/headtags';
import Headtags from '../../../../vitreum/headtags.js';
const Meta = Headtags.Meta;
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';
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 Account from '@navbar/account.navitem.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '../../../../shared/helpers.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
const SharePage = (props)=>{
const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props;

View File

@@ -3,15 +3,15 @@ import _ from 'lodash';
import ListPage from '../basePages/listPage/listPage.jsx';
import Nav from '../../navbar/nav.jsx';
import Navbar from '../../navbar/navbar.jsx';
import RecentNavItems from '../../navbar/recent.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';
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 = {

View File

@@ -3,13 +3,13 @@
import './vaultPage.less';
import React, { useState, useEffect, useRef } from 'react';
import Nav from '../../navbar/nav.jsx';
import Navbar from '../../navbar/navbar.jsx';
import RecentNavItems from '../../navbar/recent.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 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';

View File

@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.vaultPage {
height : 100%;
overflow-y : hidden;

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;

29
index.html Normal file
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>

5410
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,10 +12,8 @@
"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,10 +58,11 @@
"server"
],
"transformIgnorePatterns": [
"node_modules/(?!(nanoid|@exodus/bytes|parse5)/)"
"node_modules/(?!(nanoid|@exodus/bytes|parse5|@asamuzakjp|@csstools)/)"
],
"transform": {
"^.+\\.js$": "babel-jest"
"^.+\\.[jt]s$": "babel-jest",
"^.+\\.mjs$": "babel-jest"
},
"coveragePathIgnorePatterns": [
"build/*"
@@ -92,10 +90,11 @@
"@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",
@@ -104,7 +103,6 @@
"cors": "^2.8.5",
"create-react-class": "^15.7.0",
"dedent": "^1.7.1",
"expr-eval": "^2.0.2",
"express": "^5.1.0",
"express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.0",
@@ -114,7 +112,7 @@
"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",
@@ -128,21 +126,19 @@
"marked-variables": "^1.0.5",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
"mongoose": "^9.1.5",
"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",
@@ -151,12 +147,13 @@
"globals": "^16.4.0",
"jest": "^30.2.0",
"jest-expect-message": "^1.1.3",
"jsdom": "^28.0.0",
"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"
}
}

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);

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);

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);

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);

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();

View File

@@ -4,18 +4,23 @@ 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';
export default function createAdminApi(vite) {
const router = express.Router();
const mw = {
adminOnly : (req, res, next)=>{
if(!req.get('authorization')){
@@ -371,15 +376,28 @@ router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, n
}
});
router.get('/admin', mw.adminOnly, (req, res)=>{
templateFn('admin', {
router.get('/admin', mw.adminOnly, asyncHandler(async (req, res)=>{
const props = {
url : req.originalUrl
})
.then((page)=>res.send(page))
.catch((err)=>{
console.log(err);
res.sendStatus(500);
});
});
};
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;
}
export default router;

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({

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,6 +35,14 @@ import cookieParser from 'cookie-parser';
import forceSSL from './forcessl.mw.js';
import dbCheck from './middleware/dbCheck.js';
import cors from 'cors';
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;
@@ -49,16 +55,16 @@ const sanitizeBrew = (brew, accessType)=>{
app.set('trust proxy', 1 /* number of proxies between user and server */);
app.use('/', serveCompressedStaticAssets(`build`));
if(vite) {
app.use(vite.middlewares);
}
app.use('/', serveCompressedStaticAssets('build'));
app.use(contentNegotiation);
app.use(bodyParser.json({ limit: '25mb' }));
app.use(cookieParser());
app.use(forceSSL);
import cors from 'cors';
const nodeEnv = config.get('node_env');
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
const corsOptions = {
origin : (origin, callback)=>{
@@ -107,12 +113,12 @@ app.use((req, res, next)=>{
});
app.use(homebrewApi);
app.use(adminApi);
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 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');
@@ -548,6 +554,7 @@ app.use(asyncHandler(async (req, res, next)=>{
//Render the page
const renderPage = async (req, res)=>{
// Create configuration object
const configuration = {
local : isLocalEnvironment,
@@ -567,12 +574,29 @@ 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);
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);
});
return page;
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//
@@ -632,4 +656,5 @@ app.use((req, res)=>{
});
//^=====--------------------------------------=====^//
export default app;
return app;
}

View File

@@ -19,7 +19,7 @@ const brewSnippetsToJSON = (menuTitle, userBrewSnippets, themeBundleSnippets=nul
userSnippets.push({
name : snippetName,
icon : '',
gen : snipSplit[snips + 1],
gen : snipSplit[snips + 1].replace(/\n$/, ''),
});
}
}
@@ -44,7 +44,7 @@ const brewSnippetsToJSON = (menuTitle, userBrewSnippets, themeBundleSnippets=nul
if(snippetName.length != 0) {
const subSnip = {
name : snippetName,
gen : snipSplit[snips + 1],
gen : snipSplit[snips + 1].replace(/\n$/, ''),
};
// if(full) subSnip.icon = '';
userSnippets.push(subSnip);

View File

@@ -1,4 +1,4 @@
/* eslint-disable max-depth */
/* eslint-disable max-lines */
import _ from 'lodash';
import { marked as Marked } from 'marked';

View File

@@ -1,16 +1,16 @@
@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 './reset.less';
//@import './elements.less';
@import './animations.less';
@import './colors.less';
@import './tooltip.less';
@font-face {
font-family : 'CodeLight';
src : data-uri('naturalcrit/styles/CODE Light.otf') format('opentype');
src : url('./CODE Light.otf') format('opentype');
}
@font-face {
font-family : 'CodeBold';
src : data-uri('naturalcrit/styles/CODE Bold.otf') format('opentype');
src : url('./CODE Bold.otf') format('opentype');
}
html,body, #reactRoot {
height : 100vh;

View File

@@ -3,18 +3,23 @@
@arrowSize : 6px;
@arrowPosition : 18px;
[data-tooltip] {
position:relative;
.tooltip(attr(data-tooltip));
}
[data-tooltip-top] {
position:relative;
.tooltipTop(attr(data-tooltip-top));
}
[data-tooltip-bottom] {
position:relative;
.tooltipBottom(attr(data-tooltip-bottom));
}
[data-tooltip-left] {
position:relative;
.tooltipLeft(attr(data-tooltip-left));
}
[data-tooltip-right] {
position:relative;
.tooltipRight(attr(data-tooltip-right));
}
.tooltip(@content) {
@@ -30,6 +35,7 @@
&::before, &::after {
bottom : 100%;
left : 50%;
translate: -50% 0;
}
&:hover::after, &:hover::before, &:focus::after, &:focus::before {
.transform(translateY(-(@arrowSize + 2)));
@@ -45,6 +51,7 @@
&::before, &::after {
top : 100%;
left : 50%;
translate: -50% 0;
}
&:hover::after, &:hover::before, &:focus::after, &:focus::before {
.transform(translateY(@arrowSize + 2));
@@ -57,7 +64,10 @@
margin-bottom : -@arrowSize;
border-left-color : @tooltipColor;
}
&::after { margin-bottom : -14px;}
&::after {
margin-bottom : -14px;
max-width : 50ch;
}
&::before, &::after {
right : 100%;
bottom : 50%;
@@ -73,10 +83,14 @@
margin-left : -@arrowSize * 2;
border-right-color : @tooltipColor;
}
&::after { margin-bottom : -14px;}
&::after {
margin-bottom : -14px;
max-width : 50ch;
}
&::before, &::after {
bottom : 50%;
top : 50%;
left : 100%;
translate:0 -50%;
}
&:hover::after, &:hover::before, &:focus::after, &:focus::before {
.transform(translateX(@arrowSize + 2));
@@ -106,9 +120,12 @@
font-size : 12px;
line-height : 12px;
color : white;
white-space : nowrap;
content : @content;
background : @tooltipColor;
max-width : 60ch;
width :max-content;
word-break : break-word;
overflow-wrap : break-word;
}
&:hover::before, &:hover::after {
visibility : visible;

View File

@@ -1,6 +1,6 @@
import globalJsdom from 'jsdom-global';
globalJsdom();
import { safeHTML } from '../../client/homebrew/brewRenderer/safeHTML';
import safeHTML from '../../client/homebrew/brewRenderer/safeHTML';
test('Exit if no document', function() {
const doc = document;

View File

@@ -1,6 +1,6 @@
import Markdown from 'markdown.js';
import Markdown from '../../shared/markdown.js';
test('Processes the markdown within an HTML block if its just a class wrapper', function() {
const source = '<div>*Bold text*</div>';

View File

@@ -1,6 +1,6 @@
import Markdown from 'markdown.js';
import Markdown from '../../shared/markdown.js';
describe('Inline Definition Lists', ()=>{
test('No Term 1 Definition', function() {

View File

@@ -1,4 +1,4 @@
import Markdown from 'markdown.js';
import Markdown from '../../shared/markdown.js';
import dedent from 'dedent';
// Marked.js adds line returns after closing tags on some default tokens.

View File

@@ -1,6 +1,6 @@
import Markdown from 'markdown.js';
import Markdown from '../../shared/markdown.js';
describe('Hard Breaks', ()=>{
test('Single Break', function() {

View File

@@ -1,7 +1,7 @@
/* eslint-disable max-lines */
import dedent from 'dedent';
import Markdown from 'markdown.js';
import Markdown from '../../shared/markdown.js';
// Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake.

View File

@@ -1,6 +1,6 @@
import Markdown from 'markdown.js';
import Markdown from '../../shared/markdown.js';
describe('Non-Breaking Spaces Interactions', ()=>{
test('I am actually a single-line definition list!', function() {

View File

@@ -1,6 +1,6 @@
import Markdown from 'markdown.js';
import Markdown from '../../shared/markdown.js';
describe('Justification', ()=>{
test('Left Justify', function() {

View File

@@ -1,7 +1,7 @@
/* eslint-disable max-lines */
import dedent from 'dedent';
import Markdown from 'markdown.js';
import Markdown from '../../shared/markdown.js';
// Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake.

View File

@@ -1,27 +1,32 @@
import supertest from 'supertest';
import HBApp from 'app.js';
import createApp from '../../server/app.js';
// Mimic https responses to avoid being redirected all the time
const app = supertest.agent(HBApp).set('X-Forwarded-Proto', 'https');
let app;
let request;
beforeAll(async ()=>{
app = await createApp();
request = supertest.agent(app).set('X-Forwarded-Proto', 'https');
});
describe('Tests for static pages', ()=>{
it('Home page works', ()=>{
return app.get('/').expect(200);
it('Home page works', async ()=>{
await request.get('/').expect(200);
});
it('Home page legacy works', ()=>{
return app.get('/legacy').expect(200);
it('Home page legacy works', async ()=>{
await request.get('/legacy').expect(200);
});
it('Changelog page works', ()=>{
return app.get('/changelog').expect(200);
it('Changelog page works', async ()=>{
await request.get('/changelog').expect(200);
});
it('FAQ page works', ()=>{
return app.get('/faq').expect(200);
it('FAQ page works', async ()=>{
await request.get('/faq').expect(200);
});
it('robots.txt works', ()=>{
return app.get('/robots.txt').expect(200);
it('robots.txt works', async ()=>{
await request.get('/robots.txt').expect(200);
});
});

View File

@@ -40,7 +40,7 @@ export default [
icon : 'fas fa-image',
gen : [
'<img ',
' src=\'https://s-media-cache-ak0.pinimg.com/736x/4a/81/79/4a8179462cfdf39054a418efd4cb743e.jpg\' ',
' src=\'https://homebrewery.naturalcrit.com/assets/catwarrior.jpg\' ',
' style=\'width:325px\' />',
'Credit: Kyounghwan Kim'
].join('\n')
@@ -50,7 +50,7 @@ export default [
icon : 'fas fa-tree',
gen : [
'<img ',
' src=\'http://i.imgur.com/hMna6G0.png\' ',
' src=\'https://homebrewery.naturalcrit.com/assets/homebrewerymug.png\' ',
' style=\'position:absolute; top:50px; right:30px; width:280px\' />'
].join('\n')
},

View File

@@ -1,6 +1,6 @@
import _ from 'lodash';
export default function(classname){
function classFeatureGen(classname) {
classname = _.sample(['archivist', 'fancyman', 'linguist', 'fletcher',
'notary', 'berserker-typist', 'fishmongerer', 'manicurist', 'haberdasher', 'concierge']);
@@ -49,4 +49,6 @@ export default function(classname){
`- ${_.sample(['10 lint fluffs', '1 button', 'a cherished lost sock'])}`,
'\n\n\n'
].join('\n');
};
}
export default classFeatureGen;

View File

@@ -98,7 +98,7 @@ const subtitles = [
];
export default ()=>{
function coverPageGen() {
return `<style>
.phb#p1{ text-align:center; }
.phb#p1:after{ display:none; }
@@ -114,4 +114,6 @@ export default ()=>{
</div>
\\page`;
};
}
export default coverPageGen;

View File

@@ -4,7 +4,7 @@ import ClassFeatureGen from './classfeature.gen.js';
import ClassTableGen from './classtable.gen.js';
export default function(){
function fullClassGen(){
const classname = _.sample(['Archivist', 'Fancyman', 'Linguist', 'Fletcher',
'Notary', 'Berserker-Typist', 'Fishmongerer', 'Manicurist', 'Haberdasher', 'Concierge']);
@@ -40,4 +40,6 @@ export default function(){
].join('\n')}\n\n\n`;
};
}
export default fullClassGen;

View File

@@ -47,7 +47,8 @@ const getTOC = (pages)=>{
return res;
};
export default function(props){
function tableOfContentsGen(props){
const pages = props.brew.text.split('\\page');
const TOC = getTOC(pages);
const markdown = _.reduce(TOC, (r, g1, idx1)=>{
@@ -69,4 +70,6 @@ export default function(props){
##### Table Of Contents
${markdown}
</div>\n`;
};
}
export default tableOfContentsGen;

View File

@@ -84,7 +84,7 @@ export default {
return dedent`
{{frontCover}}
{{logo ![](/assets/naturalCritLogoRed.svg)}}
{{logo ![](https://homebrewery.naturalcrit.com/assets/naturalCritLogoRed.svg)}}
# ${_.sample(titles)}
## ${_.sample(subtitles)}
@@ -96,7 +96,7 @@ export default {
${_.sample(footnote)}
}}
![background image](https://i.imgur.com/IwHRrbF.jpg){position:absolute,bottom:0,left:0,height:100%}
![background image](https://homebrewery.naturalcrit.com/assets/demontemple.jpg){position:absolute,bottom:0,left:0,height:100%}
\page`;
},
@@ -110,10 +110,10 @@ export default {
___
{{imageMaskCenter${_.random(1, 16)},--offsetX:0%,--offsetY:0%,--rotation:0
![background image](https://i.imgur.com/IsfUnFR.jpg){position:absolute,bottom:0,left:0,height:100%}
![background image](https://homebrewery.naturalcrit.com/assets/mountaincottage.jpg){position:absolute,bottom:0,left:0,height:100%}
}}
{{logo ![](/assets/naturalCritLogoRed.svg)}}
{{logo ![](https://homebrewery.naturalcrit.com/assets/naturalCritLogoRed.svg)}}
\page`;
},
@@ -126,7 +126,7 @@ export default {
## ${_.sample(subtitles)}
{{imageMaskEdge${_.random(1, 8)},--offset:10cm,--rotation:180
![Background image](https://i.imgur.com/9TU96xY.jpg){position:absolute,bottom:0,left:0,height:100%}
![Background image](https://homebrewery.naturalcrit.com/assets/nightchapel.jpg){position:absolute,bottom:0,left:0,height:100%}
}}
\page`;
@@ -143,10 +143,10 @@ export default {
For use with any fantasy roleplaying ruleset. Play the best game of your life!
![background image](https://i.imgur.com/MJ4YHu7.jpg){position:absolute,bottom:0,left:0,height:100%}
![background image](https://homebrewery.naturalcrit.com/assets/shopvials.jpg){position:absolute,bottom:0,left:0,height:100%}
{{logo
![](/assets/naturalCritLogoWhite.svg)
![](https://homebrewery.naturalcrit.com/assets/naturalCritLogoWhite.svg)
Homebrewery.Naturalcrit.com
}}`;

View File

@@ -645,25 +645,25 @@ export default [
name : 'Image',
icon : 'fas fa-image',
gen : dedent`
![cat warrior](https://s-media-cache-ak0.pinimg.com/736x/4a/81/79/4a8179462cfdf39054a418efd4cb743e.jpg) {width:325px,mix-blend-mode:multiply}`
![cat warrior](https://homebrewery.naturalcrit.com/assets/catwarrior.jpg) {width:325px,mix-blend-mode:multiply}`
},
{
name : 'Image Wrap Left',
icon : 'fac image-wrap-left',
gen : dedent`
![homebrewery_mug](http://i.imgur.com/hMna6G0.png) {width:280px,margin-right:-3cm,wrapLeft}`
![homebrewery_mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {width:280px,margin-right:-3cm,wrapLeft}`
},
{
name : 'Image Wrap Right',
icon : 'fac image-wrap-right',
gen : dedent`
![homebrewery_mug](http://i.imgur.com/hMna6G0.png) {width:280px,margin-left:-3cm,wrapRight}`
![homebrewery_mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {width:280px,margin-left:-3cm,wrapRight}`
},
{
name : 'Background Image',
icon : 'fas fa-tree',
gen : dedent`
![homebrew mug](http://i.imgur.com/hMna6G0.png) {position:absolute,top:50px,right:30px,width:280px}`
![homebrew mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {position:absolute,top:50px,right:30px,width:280px}`
},
{
name : 'Watercolor Splatter',

View File

@@ -1,4 +1,4 @@
import Markdown from '../../../../shared/markdown.js';
import Markdown from '@shared/markdown.js';
export default {
createFooterFunc : function(headerSize=1){

View File

@@ -5,7 +5,7 @@ export default {
center : ()=>{
return dedent`
{{imageMaskCenter${_.random(1, 16)},--offsetX:0%,--offsetY:0%,--rotation:0
![](https://i.imgur.com/GZfjDWV.png){height:100%}
![](https://homebrewery.naturalcrit.com/assets/dragoninflight.jpg){height:100%}
}}
<!-- Use --offsetX to shift the mask left or right (can use cm instead of %)
Use --offsetY to shift the mask up or down
@@ -21,7 +21,7 @@ export default {
}[side];
return dedent`
{{imageMaskEdge${_.random(1, 8)},--offset:0%,--rotation:${rotation}
![](https://i.imgur.com/GZfjDWV.png){height:100%}
![](https://homebrewery.naturalcrit.com/assets/dragoninflight.jpg){height:100%}
}}
<!-- Use --offset to shift the mask away from page center (can use cm instead of %)
Use --rotation to set rotation angle in degrees. -->\n\n`;
@@ -32,7 +32,7 @@ export default {
const offsetY = (y == 'top' ? '50%' : '-50%');
return dedent`
{{imageMaskCorner${_.random(1, 37)},--offsetX:${offsetX},--offsetY:${offsetY},--rotation:0
![](https://i.imgur.com/GZfjDWV.png){height:100%}
![](https://homebrewery.naturalcrit.com/assets/dragoninflight.jpg){height:100%}
}}
<!-- Use --offsetX to shift the mask left or right (can use cm instead of %)
Use --offsetY to shift the mask up or down

View File

@@ -50,10 +50,10 @@ export default {
ccbyndBadge : `![CC BY-ND](https://mirrors.creativecommons.org/presskit/buttons/88x31/svg/by-nd.svg)`,
ccbyncndBadge : `![CC BY-NC-ND](https://mirrors.creativecommons.org/presskit/buttons/88x31/svg/by-nc-nd.svg)`,
shadowDarkNotice : `\[Product Name]\ is an independent product published under the Shadowdark RPG Third-Party License and is not affiliated with The Arcane Library, LLC. Shadowdark RPG © 2023 The Arcane Library, LLC.\n`,
shadowDarkBlack : `![Shadowdark Black Logo](/assets/license_logos/The-Arcane-Library_Third-Party-License_Black.png){width:200px}`,
shadowDarkWhite : `![Shadowdark White Logo](/assets/license_logos/The-Arcane-Library_Third-Party-License_White.png){width:200px}`,
shadowDarkBlack : `![Shadowdark Black Logo](https://homebrewery.naturalcrit.com/assets/license_logos/The-Arcane-Library_Third-Party-License_Black.png){width:200px}`,
shadowDarkWhite : `![Shadowdark White Logo](https://homebrewery.naturalcrit.com/assets/license_logos/The-Arcane-Library_Third-Party-License_White.png){width:200px}`,
bladesDarkNotice : `This work is based on Blades in the Dark \(found at (http://www.bladesinthedark.com/)\), product of One Seven Design, developed and authored by John Harper, and licensed for our use under the Creative Commons Attribution 3.0 Unported license \(http://creativecommons.org/licenses/by/3.0/\).\n`,
bladesDarkLogo : `![Forged in the Dark](/assets/license_logos/Evil-Hat_Forged-In-The-Dark_Logo-V2.png)`,
bladesDarkLogo : `![Forged in the Dark](https://homebrewery.naturalcrit.com/assets/license_logos/Evil-Hat_Forged-In-The-Dark_Logo-V2.png)`,
bladesDarkLogoAttribution : `*Blades in the Dark^tm^ is a trademark of One Seven Design. The Forged in the Dark Logo is © One Seven Design, and is used with permission.*`,
iconsCompatibility : 'Compatibility with Icons requires Icons Superpowered Roleplaying from Ad Infinitum Adventures. Ad Infinitum Adventures does not guarantee compatibility, and does not endorse this product.',
iconsTrademark : 'Icons Superpowered Roleplaying is a trademark of Steve Kenson, published exclusively by Ad Infinitum Adventures. The Icons Superpowered Roleplaying Compatibility Logo is a trademark of Ad Infinitum Adventures and is used under the Icons Superpowered Roleplaying Compatibility License.',

View File

@@ -101,10 +101,10 @@ export default {
},
// Verify Logo redistribution
greenRoninAgeCreatorsAllianceCover : `Requires the \[Game Title\] Rulebook from Green Ronin Publishing for use.`,
greenRoninAgeCreatorsAllianceLogo : `![Age Creators Alliance](/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_General-Compatibility-Logo.png){width:200px}`,
greenRoninAgeCreatorsAllianceBlueRoseLogo : `![Age Creators Alliance](/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Blue-Rose-Compatibility-Logo.png){width:200px}`,
greenRoninAgeCreatorsAllianceFantasyAgeCompatible : `![Fantasy AGE Compatible](/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Fantasy-AGE-Compatibility-Logo.png){width:200px}`,
greenRoninAgeCreatorsAllianceModernAGECompatible : `![Modern AGE Compatible](/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Modern-AGE-Compatibility-Logo.png){width:200px}`,
greenRoninAgeCreatorsAllianceLogo : `![Age Creators Alliance](https://homebrewery.naturalcrit.com/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_General-Compatibility-Logo.png){width:200px}`,
greenRoninAgeCreatorsAllianceBlueRoseLogo : `![Age Creators Alliance](https://homebrewery.naturalcrit.com/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Blue-Rose-Compatibility-Logo.png){width:200px}`,
greenRoninAgeCreatorsAllianceFantasyAgeCompatible : `![Fantasy AGE Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Fantasy-AGE-Compatibility-Logo.png){width:200px}`,
greenRoninAgeCreatorsAllianceModernAGECompatible : `![Modern AGE Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/Green-Ronin_AGE-Creators-Alliance_Modern-AGE-Compatibility-Logo.png){width:200px}`,
// Green Ronin's Chronicle - Verify Art and Access
greenRoninChronicleSystemGuildColophon : function() {
return dedent`
@@ -179,10 +179,10 @@ export default {
`;
},
// Verify Logo redistribution
monteCookLogoDarkLarge : `![Cypher System Compatible](/assets/license_logos/CSCDarkLarge.png)`,
monteCookLogoDarkSmall : `![Cypher System Compatible](/assets/license_logos/CSCDarkSmall.png)`,
monteCookLogoLightLarge : `![Cypher System Compatible](/assets/license_logos/CSCLightLarge.png)`,
monteCookLogoLightSmall : `![Cypher System Compatible](/assets/license_logos/CSCLightSmall.png)`,
monteCookLogoDarkLarge : `![Cypher System Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/CSCDarkLarge.png)`,
monteCookLogoDarkSmall : `![Cypher System Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/CSCDarkSmall.png)`,
monteCookLogoLightLarge : `![Cypher System Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/CSCLightLarge.png)`,
monteCookLogoLightSmall : `![Cypher System Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/CSCLightSmall.png)`,
// Onyx Path Canis Minor - Verify logos and access
onyxPathCanisMinorColophon : function () {
return dedent`

View File

@@ -1,6 +1,3 @@
import dedent from 'dedent';
// Mongoose Publishing Licenses
export default {

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

BIN
themes/assets/shopvials.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

38
vite.config.js Normal file
View File

@@ -0,0 +1,38 @@
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { generateAssetsPlugin } from './vitePlugins/generateAssetsPlugin.js';
export default defineConfig({
plugins : [react(), generateAssetsPlugin()],
resolve : {
alias : {
'@vitreum' : path.resolve(__dirname, './vitreum'),
'@shared' : path.resolve(__dirname, './shared'),
'@sharedStyles' : path.resolve(__dirname, './shared/naturalcrit/styles'),
'@navbar' : path.resolve(__dirname, './client/homebrew/navbar'),
'@themes' : path.resolve(__dirname, './themes'),
},
},
build : {
outDir : 'build',
emptyOutDir : false,
rollupOptions : {
output : {
entryFileNames : '[name]/bundle.js',
chunkFileNames : '[name]/[name]-[hash].js',
assetFileNames : '[name]/[name].[ext]',
},
},
},
define : {
global : 'window.__INITIAL_PROPS__',
},
server : {
port : 8000,
fs : {
allow : ['.'],
},
},
});

View File

@@ -0,0 +1,79 @@
// vite-plugins/generateAssetsPlugin.js
import fs from 'fs-extra';
import path from 'path';
import less from 'less';
export function generateAssetsPlugin(isDev = false) {
return {
name : 'generate-assets',
async buildStart() {
const buildDir = path.resolve(process.cwd(), 'build');
// Copy favicon
await fs.copy('./client/homebrew/favicon.ico', `${buildDir}/assets/favicon.ico`);
// Copy shared styles/fonts
const assets = fs.readdirSync('./shared/naturalcrit/styles');
for (const file of assets) {
await fs.copy(`./shared/naturalcrit/styles/${file}`, `${buildDir}/fonts/${file}`);
}
// Compile Legacy themes
const themes = { Legacy: {}, V3: {} };
const legacyDirs = fs.readdirSync('./themes/Legacy');
for (const dir of legacyDirs) {
const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`, 'utf-8'));
themeData.path = dir;
themes.Legacy[dir] = themeData;
const src = `./themes/Legacy/${dir}/style.less`;
const outputDir = `${buildDir}/themes/Legacy/${dir}/style.css`;
const lessOutput = await less.render(fs.readFileSync(src, 'utf-8'), { compress: !isDev });
await fs.outputFile(outputDir, lessOutput.css);
}
// Compile V3 themes
const v3Dirs = fs.readdirSync('./themes/V3');
for (const dir of v3Dirs) {
const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`, 'utf-8'));
themeData.path = dir;
themes.V3[dir] = themeData;
await fs.copy(
`./themes/V3/${dir}/dropdownTexture.png`,
`${buildDir}/themes/V3/${dir}/dropdownTexture.png`,
);
await fs.copy(
`./themes/V3/${dir}/dropdownPreview.png`,
`${buildDir}/themes/V3/${dir}/dropdownPreview.png`,
);
const src = `./themes/V3/${dir}/style.less`;
const outputDir = `${buildDir}/themes/V3/${dir}/style.css`;
const lessOutput = await less.render(fs.readFileSync(src, 'utf-8'), { compress: !isDev });
await fs.outputFile(outputDir, lessOutput.css);
}
// Write themes.json
await fs.outputFile('./themes/themes.json', JSON.stringify(themes, null, 2));
// Copy fonts/assets/icons
await fs.copy('./themes/fonts', `${buildDir}/fonts`);
await fs.copy('./themes/assets', `${buildDir}/assets`);
await fs.copy('./client/icons', `${buildDir}/icons`);
// Compile CodeMirror editor themes
const editorThemesBuildDir = `${buildDir}/homebrew/cm-themes`;
await fs.copy('./node_modules/codemirror/theme', editorThemesBuildDir);
await fs.copy('./themes/codeMirror/customThemes', editorThemesBuildDir);
const editorThemeFiles = fs.readdirSync(editorThemesBuildDir);
await fs.outputFile(`${buildDir}/homebrew/codeMirror/editorThemes.json`,
JSON.stringify(['default', ...editorThemeFiles.map((f)=>f.slice(0, -4))], null, 2),
);
// Copy remaining CodeMirror assets
await fs.copy('./themes/codeMirror', `${buildDir}/homebrew/codeMirror`);
},
};
}

91
vitreum/headtags.js Normal file
View File

@@ -0,0 +1,91 @@
import React, { useEffect } from 'react';
//old vitreum file, still imported in some pages
const injectTag = (tag, props, children)=>{
const injectNode = document.createElement(tag);
Object.entries(props).forEach(([key, val])=>injectNode[key] = val);
if(children) injectNode.appendChild(document.createTextNode(children));
document.getElementsByTagName('head')[0].appendChild(injectNode);
};
const obj2props = (obj)=>Object.entries(obj)
.map(([k, v])=>`${k}="${v}"`)
.join(' ');
const toStr = (chld)=>(Array.isArray(chld) ? chld.join('') : chld);
const onServer = typeof window === 'undefined';
let NamedTags = {};
let UnnamedTags = [];
export const HeadComponents = {
Title({ children }) {
if(onServer) NamedTags.title = `<title>${toStr(children)}</title>`;
useEffect(()=>{
document.title = toStr(children);
}, [children]);
return null;
},
Favicon({ type = 'image/png', href = '', rel = 'icon', id = 'favicon' }) {
if(onServer) NamedTags.favicon = `<link rel='shortcut icon' type="${type}" id="${id}" href="${href}" />`;
useEffect(()=>{
document.getElementById(id).href = href;
}, [id, href]);
return null;
},
Description({ children }) {
if(onServer) NamedTags.description = `<meta name='description' content='${toStr(children)}' />`;
return null;
},
Noscript({ children }) {
if(onServer) UnnamedTags.push(`<noscript>${toStr(children)}</noscript>`);
return null;
},
Script({ children = [], ...props }) {
if(onServer) {
UnnamedTags.push(
children.length
? `<script ${obj2props(props)}>${toStr(children)}</script>`
: `<script ${obj2props(props)} />`,
);
}
return null;
},
Meta(props) {
const tag = `<meta ${obj2props(props)} />`;
props.property || props.name ? (NamedTags[props.property || props.name] = tag) : UnnamedTags.push(tag);
useEffect(()=>{
document
.getElementsByTagName('head')[0]
.insertAdjacentHTML('beforeend', Object.values(NamedTags).join('\n'));
}, [NamedTags]);
return null;
},
Style({ children, type = 'text/css' }) {
if(onServer) UnnamedTags.push(`<style type="${type}">${toStr(children)}</style>`);
return null;
},
};
export const Inject = ({ tag, children, ...props })=>{
useEffect(()=>{
injectTag(tag, props, children);
}, []);
return null;
};
export const generate = ()=>Object.values(NamedTags).concat(UnnamedTags).join('\n');
export const flush = ()=>{
NamedTags = {};
UnnamedTags = [];
};
export const Meta = HeadComponents.Meta;
export default {
Inject,
...HeadComponents,
generate,
flush,
};