0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-05-09 18:28:45 +00:00

Merge branch 'master' into HTMLDownload

This commit is contained in:
David Bolack
2026-03-16 10:39:14 -05:00
89 changed files with 5619 additions and 7852 deletions
+1 -1
View File
@@ -49,4 +49,4 @@ const Admin = ()=>{
); );
}; };
module.exports = Admin; export default Admin;
+6 -8
View File
@@ -1,11 +1,9 @@
@import 'naturalcrit/styles/reset.less'; @import '@sharedStyles/reset.less';
@import 'naturalcrit/styles/elements.less'; @import '@sharedStyles/elements.less';
@import 'naturalcrit/styles/animations.less'; @import '@sharedStyles/animations.less';
@import 'naturalcrit/styles/colors.less'; @import '@sharedStyles/colors.less';
@import 'naturalcrit/styles/tooltip.less'; @import '@sharedStyles/tooltip.less';
@import './themes/fonts/iconFonts/fontAwesome.less'; @import '@themes/fonts/iconFonts/fontAwesome.less';
@import 'font-awesome/css/font-awesome.css';
html,body, #reactContainer, .naturalCrit { min-height : 100%; } html,body, #reactContainer, .naturalCrit { min-height : 100%; }
+2
View File
@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.brewUtil { .brewUtil {
.result { .result {
margin-top : 20px; margin-top : 20px;
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from 'react-dom/client';
import Admin from './admin.jsx';
const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById('reactRoot')).render(<Admin {...props} />);
@@ -1,7 +1,7 @@
import diceFont from 'themes/fonts/iconFonts/diceFont.js'; import diceFont from '@themes/fonts/iconFonts/diceFont.js';
import elderberryInn from 'themes/fonts/iconFonts/elderberryInn.js'; import elderberryInn from '@themes/fonts/iconFonts/elderberryInn.js';
import fontAwesome from 'themes/fonts/iconFonts/fontAwesome.js'; import fontAwesome from '@themes/fonts/iconFonts/fontAwesome.js';
import gameIcons from 'themes/fonts/iconFonts/gameIcons.js'; import gameIcons from '@themes/fonts/iconFonts/gameIcons.js';
const emojis = { const emojis = {
...diceFont, ...diceFont,
+4 -4
View File
@@ -5,10 +5,10 @@
@import (less) 'codemirror/addon/hint/show-hint.css'; @import (less) 'codemirror/addon/hint/show-hint.css';
//Icon fonts included so they can appear in emoji autosuggest dropdown //Icon fonts included so they can appear in emoji autosuggest dropdown
@import (less) './themes/fonts/iconFonts/diceFont.less'; @import (less) '@themes/fonts/iconFonts/diceFont.less';
@import (less) './themes/fonts/iconFonts/elderberryInn.less'; @import (less) '@themes/fonts/iconFonts/elderberryInn.less';
@import (less) './themes/fonts/iconFonts/gameIcons.less'; @import (less) '@themes/fonts/iconFonts/gameIcons.less';
@import (less) './themes/fonts/iconFonts/fontAwesome.less'; @import (less) '@themes/fonts/iconFonts/fontAwesome.less';
@keyframes sourceMoveAnimation { @keyframes sourceMoveAnimation {
50% { color : white;background-color : red;} 50% { color : white;background-color : red;}
+6 -5
View File
@@ -11,16 +11,17 @@ const Combobox = createReactClass({
trigger : 'hover', trigger : 'hover',
default : '', default : '',
placeholder : '', placeholder : '',
tooltip: '', tooltip : '',
autoSuggest : { autoSuggest : {
clearAutoSuggestOnClick : true, clearAutoSuggestOnClick : true,
suggestMethod : 'includes', suggestMethod : 'includes',
filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter
}, },
valuePatterns: /.+/ valuePatterns : /.+/
}; };
}, },
getInitialState : function() { getInitialState : function() {
this.dropdownRef = React.createRef();
return { return {
showDropdown : false, showDropdown : false,
value : '', value : '',
@@ -41,7 +42,7 @@ const Combobox = createReactClass({
}, },
handleClickOutside : function(e){ handleClickOutside : function(e){
// Close dropdown when clicked outside // 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); this.handleDropdown(false);
} }
}, },
@@ -88,7 +89,7 @@ const Combobox = createReactClass({
} }
}} }}
onKeyDown={(e)=>{ onKeyDown={(e)=>{
if (e.key === "Enter") { if(e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
this.props.onEntry(e); this.props.onEntry(e);
} }
@@ -128,7 +129,7 @@ const Combobox = createReactClass({
}); });
return ( return (
<div className={`dropdown-container ${this.props.className}`} <div className={`dropdown-container ${this.props.className}`}
ref='dropdown' ref={this.dropdownRef}
onMouseLeave={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(false);} : undefined}> onMouseLeave={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(false);} : undefined}>
{this.renderTextInput()} {this.renderTextInput()}
{this.renderDropdown(dropdownChildren)} {this.renderDropdown(dropdownChildren)}
@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.renderWarnings { .renderWarnings {
position : relative; position : relative;
float : right; float : right;
@@ -1,3 +1,4 @@
@import '@sharedStyles/core.less';
.splitPane { .splitPane {
position : relative; position : relative;
+11 -4
View File
@@ -1,10 +1,12 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/ /*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 './brewRenderer.less';
import React, { useState, useRef, useMemo, useEffect } from 'react'; import React, { useState, useRef, useMemo, useEffect } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import MarkdownLegacy from '../../../shared/markdownLegacy.js'; import MarkdownLegacy from '@shared/markdownLegacy.js';
import Markdown from '../../../shared/markdown.js'; import Markdown from '@shared/markdown.js';
import ErrorBar from './errorBar/errorBar.jsx'; import ErrorBar from './errorBar/errorBar.jsx';
import ToolBar from './toolBar/toolBar.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 NotificationPopup from './notificationPopup/notificationPopup.jsx';
import Frame from 'react-frame-component'; import Frame from 'react-frame-component';
import dedent from 'dedent'; import dedent from 'dedent';
import { printCurrentBrew } from '../../../shared/helpers.js'; import { printCurrentBrew } from '@shared/helpers.js';
import HeaderNav from './headerNav/headerNav.jsx'; 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_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m; const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
@@ -29,6 +31,8 @@ const INITIAL_CONTENT = dedent`
<!DOCTYPE html><html><head> <!DOCTYPE html><html><head>
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" /> <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='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
<link href="${brewRendererStylesUrl}" rel="stylesheet" />
<link href="${headerNavStylesUrl}" rel="stylesheet" />
<base target=_blank> <base target=_blank>
</head><body style='overflow: hidden'><div></div></body></html>`; </head><body style='overflow: hidden'><div></div></body></html>`;
@@ -343,6 +347,9 @@ const BrewRenderer = (props)=>{
</div> </div>
{headerState ? <HeaderNav ref={pagesRef} /> : <></>} {headerState ? <HeaderNav ref={pagesRef} /> : <></>}
</Frame> </Frame>
{state.isMounted &&
<div id='brewRendered'></div>
}
</> </>
); );
}; };
@@ -1,4 +1,4 @@
@import (multiple, less) 'shared/naturalcrit/styles/reset.less'; @import '@sharedStyles/core.less';
.brewRenderer { .brewRenderer {
height : 100vh; height : 100vh;
@@ -1,3 +1,4 @@
@import '@sharedStyles/colors.less';
.errorBar { .errorBar {
position : absolute; position : absolute;
@@ -1,7 +1,7 @@
import './notificationPopup.less'; import './notificationPopup.less';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import Markdown from 'markdown.js'; import Markdown from '@shared/markdown.js';
import Dialog from '../../../components/dialog.jsx'; import Dialog from '../../../components/dialog.jsx';
@@ -1,3 +1,5 @@
@import './client/homebrew/navbar/navbar.less';
.popups { .popups {
position : fixed; position : fixed;
top : calc(@navbarHeight + @viewerToolsHeight); top : calc(@navbarHeight + @viewerToolsHeight);
+1 -1
View File
@@ -43,4 +43,4 @@ function safeHTML(htmlString) {
return div.innerHTML; return div.innerHTML;
}; };
module.exports.safeHTML = safeHTML; export default safeHTML;
+2 -2
View File
@@ -4,7 +4,7 @@ import React from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import _ from 'lodash'; import _ from 'lodash';
import dedent from 'dedent'; import dedent from 'dedent';
import Markdown from '../../../shared/markdown.js'; import Markdown from '@shared/markdown.js';
import CodeEditor from '../../components/codeEditor/codeEditor.jsx'; import CodeEditor from '../../components/codeEditor/codeEditor.jsx';
import SnippetBar from './snippetbar/snippetbar.jsx'; import SnippetBar from './snippetbar/snippetbar.jsx';
@@ -88,7 +88,7 @@ const Editor = createReactClass({
const snippetBar = document.querySelector('.editor > .snippetBar'); const snippetBar = document.querySelector('.editor > .snippetBar');
if(!snippetBar) return; if(!snippetBar) return;
this.resizeObserver = new ResizeObserver(entries=>{ this.resizeObserver = new ResizeObserver((entries)=>{
const height = document.querySelector('.editor > .snippetBar').offsetHeight; const height = document.querySelector('.editor > .snippetBar').offsetHeight;
this.setState({ snippetBarHeight: height }); this.setState({ snippetBarHeight: height });
}); });
+3 -1
View File
@@ -1,4 +1,6 @@
@import 'themes/codeMirror/customEditorStyles.less'; @import '@sharedStyles/core.less';
@import '@themes/codeMirror/customEditorStyles.less';
.editor { .editor {
position : relative; position : relative;
width : 100%; width : 100%;
@@ -7,7 +7,8 @@ import request from '../../utils/request-middleware.js';
import Combobox from '../../../components/combobox.jsx'; import Combobox from '../../../components/combobox.jsx';
import TagInput from '../tagInput/tagInput.jsx'; import TagInput from '../tagInput/tagInput.jsx';
import Themes from 'themes/themes.json';
import Themes from '@themes/themes.json';
import validations from './validations.js'; import validations from './validations.js';
import homebreweryThumbnail from '../../thumbnail.png'; import homebreweryThumbnail from '../../thumbnail.png';
@@ -337,9 +338,9 @@ const MetadataEditor = createReactClass({
{this.renderThumbnail()} {this.renderThumbnail()}
</div> </div>
<div className="field tags"> <div className='field tags'>
<label>Tags</label> <label>Tags</label>
<div className="value" > <div className='value' >
<TagInput <TagInput
label='tags' label='tags'
valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/} valuePatterns={/^\s*(?:(?:group|meta|system|type)\s*:\s*)?[A-Za-z0-9][A-Za-z0-9 \/\\.&_\-]{0,40}\s*$/}
@@ -362,9 +363,9 @@ const MetadataEditor = createReactClass({
{this.renderAuthors()} {this.renderAuthors()}
<div className="field invitedAuthors"> <div className='field invitedAuthors'>
<label>Invited authors</label> <label>Invited authors</label>
<div className="value"> <div className='value'>
<TagInput <TagInput
label='invited authors' label='invited authors'
valuePatterns={/.+/} valuePatterns={/.+/}
@@ -1,4 +1,4 @@
@import 'naturalcrit/styles/colors.less'; @import '@sharedStyles/core.less';
.userThemeName { .userThemeName {
padding-right : 10px; padding-right : 10px;
@@ -7,13 +7,13 @@ import _ from 'lodash';
import cx from 'classnames'; import cx from 'classnames';
import { loadHistory } from '../../utils/versionHistory.js'; 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 Legacy5ePHB from '@themes/Legacy/5ePHB/snippets.js';
import V3_5ePHB from 'themes/V3/5ePHB/snippets.js'; import V3_5ePHB from '@themes/V3/5ePHB/snippets.js';
import V3_5eDMG from 'themes/V3/5eDMG/snippets.js'; import V3_5eDMG from '@themes/V3/5eDMG/snippets.js';
import V3_Journal from 'themes/V3/Journal/snippets.js'; import V3_Journal from '@themes/V3/Journal/snippets.js';
import V3_Blank from 'themes/V3/Blank/snippets.js'; import V3_Blank from '@themes/V3/Blank/snippets.js';
const ThemeSnippets = { const ThemeSnippets = {
Legacy_5ePHB : Legacy5ePHB, Legacy_5ePHB : Legacy5ePHB,
@@ -23,7 +23,7 @@ const ThemeSnippets = {
V3_Blank : V3_Blank, 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){ const execute = function(val, props){
if(_.isFunction(val)) return val(props); if(_.isFunction(val)) return val(props);
@@ -1,5 +1,6 @@
@import '@sharedStyles/core.less';
@import (less) './client/icons/customIcons.less'; @import (less) './client/icons/customIcons.less';
@import (less) '././././themes/fonts/5e/fonts.less'; @import (less) '@themes/fonts/5e/fonts.less';
.snippetBar { .snippetBar {
@menuHeight : 25px; @menuHeight : 25px;
@@ -1,210 +1,219 @@
export default [ export const tagSuggestionList = [
// ############################## Systems // ############################## Systems
// D&D // D&D
"system:D&D Original", 'system:D&D Original',
"system:D&D Basic", 'system:D&D Basic',
"system:AD&D 1e", 'system:AD&D 1e',
"system:AD&D 2e", 'system:AD&D 2e',
"system:D&D 3e", 'system:D&D 3e',
"system:D&D 3.5e", 'system:D&D 3.5e',
"system:D&D 4e", 'system:D&D 4e',
"system:D&D 5e", 'system:D&D 5e',
"system:D&D 5e 2024", 'system:D&D 5e 2024',
"system:BD&D (B/X)", 'system:BD&D (B/X)',
"system:D&D Essentials", 'system:D&D Essentials',
// Other Famous RPGs // Other Famous RPGs
"system:Pathfinder 1e", 'system:Pathfinder 1e',
"system:Pathfinder 2e", 'system:Pathfinder 2e',
"system:Vampire: The Masquerade", 'system:Vampire: The Masquerade',
"system:Werewolf: The Apocalypse", 'system:Werewolf: The Apocalypse',
"system:Mage: The Ascension", 'system:Mage: The Ascension',
"system:Call of Cthulhu", 'system:Call of Cthulhu',
"system:Shadowrun", 'system:Shadowrun',
"system:Star Wars RPG (D6/D20/Edge of the Empire)", 'system:Star Wars RPG (D6/D20/Edge of the Empire)',
"system:Warhammer Fantasy Roleplay", 'system:Warhammer Fantasy Roleplay',
"system:Cyberpunk 2020", 'system:Cyberpunk 2020',
"system:Blades in the Dark", 'system:Blades in the Dark',
"system:Daggerheart", 'system:Daggerheart',
"system:Draw Steel", 'system:Draw Steel',
"system:Mutants and Masterminds", 'system:Mutants and Masterminds',
// Meta // Meta
"meta:V3", 'meta:V3',
"meta:Legacy", 'meta:Legacy',
"meta:Template", 'meta:Template',
"meta:Theme", 'meta:Theme',
"meta:free", 'meta:free',
"meta:Character Sheet", 'meta:Character Sheet',
"meta:Documentation", 'meta:Documentation',
"meta:NPC", 'meta:NPC',
"meta:Guide", 'meta:Guide',
"meta:Resource", 'meta:Resource',
"meta:Notes", 'meta:Notes',
"meta:Example", 'meta:Example',
// Book type // Book type
"type:Campaign", 'type:Campaign',
"type:Campaign Setting", 'type:Campaign Setting',
"type:Adventure", 'type:Adventure',
"type:One-Shot", 'type:One-Shot',
"type:Setting", 'type:Setting',
"type:World", 'type:World',
"type:Lore", 'type:Lore',
"type:History", 'type:History',
"type:Dungeon Master", 'type:Dungeon Master',
"type:Encounter Pack", 'type:Encounter Pack',
"type:Encounter", 'type:Encounter',
"type:Session Notes", 'type:Session Notes',
"type:reference", 'type:reference',
"type:Handbook", 'type:Handbook',
"type:Manual", 'type:Manual',
"type:Manuals", 'type:Manuals',
"type:Compendium", 'type:Compendium',
"type:Bestiary", 'type:Bestiary',
// ###################################### RPG Keywords // ###################################### RPG Keywords
// Classes / Subclasses / Archetypes // Classes / Subclasses / Archetypes
"Class", 'Class',
"Subclass", 'Subclass',
"Archetype", 'Archetype',
"Martial", 'Martial',
"Half-Caster", 'Half-Caster',
"Full Caster", 'Full Caster',
"Artificer", 'Artificer',
"Barbarian", 'Barbarian',
"Bard", 'Bard',
"Cleric", 'Cleric',
"Druid", 'Druid',
"Fighter", 'Fighter',
"Monk", 'Monk',
"Paladin", 'Paladin',
"Rogue", 'Rogue',
"Sorcerer", 'Sorcerer',
"Warlock", 'Warlock',
"Wizard", 'Wizard',
// Races / Species / Lineages // Races / Species / Lineages
"Race", 'Race',
"Ancestry", 'Ancestry',
"Lineage", 'Lineage',
"Aasimar", 'Aasimar',
"Beastfolk", 'Beastfolk',
"Dragonborn", 'Dragonborn',
"Dwarf", 'Dwarf',
"Elf", 'Elf',
"Goblin", 'Goblin',
"Half-Elf", 'Half-Elf',
"Half-Orc", 'Half-Orc',
"Human", 'Human',
"Kobold", 'Kobold',
"Lizardfolk", 'Lizardfolk',
"Lycan", 'Lycan',
"Orc", 'Orc',
"Tiefling", 'Tiefling',
"Vampire", 'Vampire',
"Yuan-Ti", 'Yuan-Ti',
// Magic / Spells / Items // Magic / Spells / Items
"Magic", 'Magic',
"Magic Item", 'Magic Item',
"Magic Items", 'Magic Items',
"Wondrous Item", 'Wondrous Item',
"Magic Weapon", 'Magic Weapon',
"Artifact", 'Artifact',
"Spell", 'Spell',
"Spells", 'Spells',
"Cantrip", 'Cantrip',
"Cantrips", 'Cantrips',
"Eldritch", 'Eldritch',
"Eldritch Invocation", 'Eldritch Invocation',
"Invocation", 'Invocation',
"Invocations", 'Invocations',
"Pact boon", 'Pact boon',
"Pact Boon", 'Pact Boon',
"Spellcaster", 'Spellcaster',
"Spellblade", 'Spellblade',
"Magical Tattoos", 'Magical Tattoos',
"Enchantment", 'Enchantment',
"Enchanted", 'Enchanted',
"Attunement", 'Attunement',
"Requires Attunement", 'Requires Attunement',
"Rune", 'Rune',
"Runes", 'Runes',
"Wand", 'Wand',
"Rod", 'Rod',
"Scroll", 'Scroll',
"Potion", 'Potion',
"Potions", 'Potions',
"Item", 'Item',
"Items", 'Items',
"Bag of Holding", 'Bag of Holding',
// Monsters / Creatures / Enemies // Monsters / Creatures / Enemies
"Monster", 'Monster',
"Creatures", 'Creatures',
"Creature", 'Creature',
"Beast", 'Beast',
"Beasts", 'Beasts',
"Humanoid", 'Humanoid',
"Undead", 'Undead',
"Fiend", 'Fiend',
"Aberration", 'Aberration',
"Ooze", 'Ooze',
"Giant", 'Giant',
"Dragon", 'Dragon',
"Monstrosity", 'Monstrosity',
"Demon", 'Demon',
"Devil", 'Devil',
"Elemental", 'Elemental',
"Construct", 'Construct',
"Constructs", 'Constructs',
"Boss", 'Boss',
"BBEG", 'BBEG',
// ############################# Media / Pop Culture // ############################# Media / Pop Culture
"One Piece", 'One Piece',
"Dragon Ball", 'Dragon Ball',
"Dragon Ball Z", 'Dragon Ball Z',
"Naruto", 'Naruto',
"Jujutsu Kaisen", 'Jujutsu Kaisen',
"Fairy Tail", 'Fairy Tail',
"Final Fantasy", 'Final Fantasy',
"Kingdom Hearts", 'Kingdom Hearts',
"Elder Scrolls", 'Elder Scrolls',
"Skyrim", 'Skyrim',
"WoW", 'WoW',
"World of Warcraft", 'World of Warcraft',
"Marvel Comics", 'Marvel Comics',
"DC Comics", 'DC Comics',
"Pokemon", 'Pokemon',
"League of Legends", 'League of Legends',
"Runeterra", 'Runeterra',
"Arcane", 'Arcane',
"Yu-Gi-Oh", 'Yu-Gi-Oh',
"Minecraft", 'Minecraft',
"Don't Starve", 'Don\'t Starve',
"Witcher", 'Witcher',
"Witcher 3", 'Witcher 3',
"Cyberpunk", 'Cyberpunk',
"Cyberpunk 2077", 'Cyberpunk 2077',
"Fallout", 'Fallout',
"Divinity Original Sin 2", 'Divinity Original Sin 2',
"Fullmetal Alchemist", 'Fullmetal Alchemist',
"Fullmetal Alchemist Brotherhood", 'Fullmetal Alchemist Brotherhood',
"Lobotomy Corporation", 'Lobotomy Corporation',
"Bloodborne", 'Bloodborne',
"Dragonlance", 'Dragonlance',
"Shackled City Adventure Path", 'Shackled City Adventure Path',
"Baldurs Gate 3", 'Baldurs Gate 3',
"Library of Ruina", 'Library of Ruina',
"Radiant Citadel", 'Radiant Citadel',
"Ravenloft", 'Ravenloft',
"Forgotten Realms", 'Forgotten Realms',
"Exandria", 'Exandria',
"Critical Role", 'Critical Role',
"Star Wars", 'Star Wars',
"SW5e", 'SW5e',
"Star Wars 5e", 'Star Wars 5e',
];
// substrings to be normalized to the first value on the array
export const canonizationList = [
['5e 2024', '5.5e', '5e\'24', '5.24', '5e24', '5.5'],
['5e', '5th Edition'],
['Dungeons & Dragons', 'Dungeons and Dragons', 'Dungeons n dragons'],
['D&D', 'DnD', 'dnd', 'Dnd', 'dnD', 'd&d', 'd&D', 'D&d'],
['P2e', 'p2e', 'P2E', 'Pathfinder 2e'],
]; ];
+105 -118
View File
@@ -1,71 +1,62 @@
import "./tagInput.less"; import './tagInput.less';
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from 'react';
import Combobox from "../../../components/combobox.jsx"; import Combobox from '../../../components/combobox.jsx';
import tagSuggestionList from "./curatedTagSuggestionList.js"; import { tagSuggestionList, canonizationList } from './curatedTagSuggestionList.js';
const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, placeholder = "", smallText = "", onChange }) => { const TagInput = ({ tooltip, label, valuePatterns, values = [], unique = true, placeholder = '', smallText = '', onChange })=>{
const [tagList, setTagList] = useState( const [tagList, setTagList] = useState(
values.map((value) => ({ values.map((value)=>({
value, value,
editing: false, editing : false,
draft: "", draft : '',
})), })),
); );
useEffect(() => { useEffect(()=>{
const incoming = values || []; const incoming = values || [];
const current = tagList.map((t) => t.value); const current = tagList.map((t)=>t.value);
const changed = incoming.length !== current.length || incoming.some((v, i) => v !== current[i]); const changed = incoming.length !== current.length || incoming.some((v, i)=>v !== current[i]);
if (changed) { if(changed) {
setTagList( setTagList(
incoming.map((value) => ({ incoming.map((value)=>({
value, value,
editing: false, editing : false,
})), })),
); );
} }
}, [values]); }, [values]);
useEffect(() => { useEffect(()=>{
onChange?.({ onChange?.({
target: { value: tagList.map((t) => t.value) }, target : { value: tagList.map((t)=>t.value) },
}); });
}, [tagList]); }, [tagList]);
// substrings to be normalized to the first value on the array const normalizeValue = (input)=>{
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(); const lowerInput = input.toLowerCase();
let normalizedTag = input; let normalizedTag = input;
for (const group of duplicateGroups) { for (const group of canonizationList) {
for (const tag of group) { for (const tag of group) {
if (!tag) continue; if(!tag) continue;
const index = lowerInput.indexOf(tag.toLowerCase()); const index = lowerInput.indexOf(tag.toLowerCase());
if (index !== -1) { if(index !== -1) {
normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length); normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length);
break; break;
} }
} }
} }
if (normalizedTag.includes(":")) { if(normalizedTag.includes(':')) {
const [rawType, rawValue = ""] = normalizedTag.split(":"); const [rawType, rawValue = ''] = normalizedTag.split(':');
const tagType = rawType.trim().toLowerCase(); const tagType = rawType.trim().toLowerCase();
const tagValue = rawValue.trim(); const tagValue = rawValue.trim();
if (tagValue.length > 0) { if(tagValue.length > 0) {
normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`; normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`;
} }
//trims spaces around colon and capitalizes the first word after the colon //trims spaces around colon and capitalizes the first word after the colon
@@ -75,56 +66,56 @@ const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, pl
return normalizedTag; return normalizedTag;
}; };
const submitTag = (newValue, index = null) => { const submitTag = (newValue, index = null)=>{
const trimmed = newValue?.trim(); const trimmed = newValue?.trim();
if (!trimmed) return; if(!trimmed) return;
if (!valuePatterns.test(trimmed)) return; if(!valuePatterns.test(trimmed)) return;
const normalizedTag = normalizeValue(trimmed); const normalizedTag = normalizeValue(trimmed);
setTagList((prev) => { setTagList((prev)=>{
const existsIndex = prev.findIndex((t) => t.value.toLowerCase() === normalizedTag.toLowerCase()); const existsIndex = prev.findIndex((t)=>t.value.toLowerCase() === normalizedTag.toLowerCase());
if (unique && existsIndex !== -1) return prev; if(unique && existsIndex !== -1) return prev;
if (index !== null) { if(index !== null) {
return prev.map((t, i) => (i === index ? { ...t, value: normalizedTag, editing: false } : t)); return prev.map((t, i)=>(i === index ? { ...t, value: normalizedTag, editing: false } : t));
} }
return [...prev, { value: normalizedTag, editing: false }]; return [...prev, { value: normalizedTag, editing: false }];
}); });
}; };
const removeTag = (index) => { const removeTag = (index)=>{
setTagList((prev) => prev.filter((_, i) => i !== index)); setTagList((prev)=>prev.filter((_, i)=>i !== index));
}; };
const editTag = (index) => { const editTag = (index)=>{
setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: true, draft: t.value } : t))); setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: true, draft: t.value } : t)));
}; };
const stopEditing = (index) => { const stopEditing = (index)=>{
setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: false, draft: "" } : t))); setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: false, draft: '' } : t)));
}; };
const suggestionOptions = tagSuggestionList.map((tag) => { const suggestionOptions = tagSuggestionList.map((tag)=>{
const tagType = tag.split(":"); const tagType = tag.split(':');
let classes = "item"; let classes = 'item';
switch (tagType[0]) { switch (tagType[0]) {
case "type": case 'type':
classes = "item type"; classes = 'item type';
break; break;
case "group": case 'group':
classes = "item group"; classes = 'item group';
break; break;
case "meta": case 'meta':
classes = "item meta"; classes = 'item meta';
break; break;
case "system": case 'system':
classes = "item system"; classes = 'item system';
break; break;
default: default:
classes = "item"; classes = 'item';
break; break;
} }
return ( return (
@@ -135,73 +126,69 @@ const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, pl
}); });
return ( return (
<div className="tagInputWrap"> <div className='tagInputWrap'>
<Combobox <Combobox
trigger="click" trigger='click'
className="tagInput-dropdown" className='tagInput-dropdown'
default="" default=''
placeholder={placeholder} placeholder={placeholder}
options={label === "tags" ? suggestionOptions : []} options={label === 'tags' ? suggestionOptions : []}
tooltip={tooltip} tooltip={tooltip}
autoSuggest={ autoSuggest={
label === "tags" label === 'tags'
? { ? {
suggestMethod: "startsWith", suggestMethod : 'startsWith',
clearAutoSuggestOnClick: true, clearAutoSuggestOnClick : true,
filterOn: ["value", "title"], filterOn : ['value', 'title'],
} }
: { suggestMethod: "includes", clearAutoSuggestOnClick: true, filterOn: [] } : { suggestMethod: 'includes', clearAutoSuggestOnClick: true, filterOn: [] }
} }
valuePatterns={valuePatterns.source} valuePatterns={valuePatterns.source}
onSelect={(value) => submitTag(value)} onSelect={(value)=>submitTag(value)}
onEntry={(e) => { onEntry={(e)=>{
if (e.key === "Enter") { if(e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
submitTag(e.target.value); submitTag(e.target.value);
} }
}} }}
/> />
<ul className="list"> <ul className='list'>
{tagList.map((t, i) => {tagList.map((t, i)=>t.editing ? (
t.editing ? ( <input
<input key={i}
key={i} type='text'
type="text" value={t.draft} // always use draft
value={t.draft} // always use draft pattern={valuePatterns.source}
pattern={valuePatterns.source} onChange={(e)=>setTagList((prev)=>prev.map((tag, idx)=>(idx === i ? { ...tag, draft: e.target.value } : tag)),
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)),
);
} }
onKeyDown={(e) => { if(e.key === 'Escape') {
if (e.key === "Enter") { stopEditing(i);
e.preventDefault(); e.target.blur();
submitTag(t.draft, i); // submit draft }
setTagList((prev) => }}
prev.map((tag, idx) => (idx === i ? { ...tag, draft: "" } : tag)), autoFocus
); />
} ) : (
if (e.key === "Escape") { <li key={i} className='tag' onClick={()=>editTag(i)}>
stopEditing(i); {t.value}
e.target.blur(); <button
} type='button'
}} onClick={(e)=>{
autoFocus e.stopPropagation();
/> removeTag(i);
) : ( }}>
<li key={i} className="tag" onClick={() => editTag(i)}> <i className='fa fa-times fa-fw' />
{t.value} </button>
<button </li>
type="button" ),
onClick={(e) => {
e.stopPropagation();
removeTag(i);
}}>
<i className="fa fa-times fa-fw" />
</button>
</li>
),
)} )}
</ul> </ul>
</div> </div>
File diff suppressed because it is too large Load Diff
+24 -15
View File
@@ -1,8 +1,7 @@
import 'core-js/es/string/to-well-formed.js'; // Polyfill for older browsers
import 'core-js/es/string/to-well-formed.js'; //Polyfill for older browsers
import './homebrew.less'; import './homebrew.less';
import React from 'react'; import 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'; import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js';
@@ -41,24 +40,34 @@ const Homebrew = (props)=>{
brews brews
} = props; } = props;
global.account = account;
global.version = version;
global.config = config;
const backgroundObject = ()=>{ const backgroundObject = ()=>{
if(global.config.deployment || (config.local && config.development)){ if(config?.deployment || (config?.local && config?.development)) {
const bgText = global.config.deployment || 'Local'; const bgText = config?.deployment || 'Local';
return { 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>")` 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; return null;
}; };
updateLocalStorage(); updateLocalStorage();
if(brew.pureError) {
return (
<Router>
<div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<Routes>
<Route path={brew.originalUrl} element={<WithRoute el={ErrorPage} brew={brew} />} />
</Routes>
</div>
</Router>
);
}
return ( return (
<Router location={url}> <Router>
<div className={`homebrew${(config.deployment || config.local) ? ' deployment' : ''}`} style={backgroundObject()}> <div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<Routes> <Routes>
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} /> <Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} share={true} />} /> <Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} share={true} />} />
@@ -81,4 +90,4 @@ const Homebrew = (props)=>{
); );
}; };
module.exports = Homebrew; export default Homebrew;
+1 -1
View File
@@ -1,4 +1,4 @@
@import 'naturalcrit/styles/core.less'; @import '@sharedStyles/core.less';
.homebrew { .homebrew {
height : 100%; height : 100%;
background-color:@steel; background-color:@steel;
+6
View File
@@ -0,0 +1,6 @@
import { createRoot } from 'react-dom/client';
import Homebrew from './homebrew.jsx';
const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById('reactRoot')).render(<Homebrew {...props} />);
+1 -1
View File
@@ -97,7 +97,7 @@ const Account = createReactClass({
// Logged out // Logged out
// LOCAL ONLY // LOCAL ONLY
if(global.config.local) { if(global.config?.local) {
return <Nav.item color='teal' icon='fas fa-sign-in-alt' onClick={this.localLogin}> return <Nav.item color='teal' icon='fas fa-sign-in-alt' onClick={this.localLogin}>
login login
</Nav.item>; </Nav.item>;
@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.navItem.error { .navItem.error {
position : relative; position : relative;
background-color : @red; background-color : @red;
@@ -46,11 +46,6 @@ const MetadataNav = createReactClass({
</>; </>;
}, },
getSystems : function(){
if(!this.props.brew.systems || this.props.brew.systems.length == 0) return 'No systems';
return this.props.brew.systems.join(', ');
},
renderMetaWindow : function(){ renderMetaWindow : function(){
return <div className={`window ${this.state.showMetaWindow ? 'active' : 'inactive'}`}> return <div className={`window ${this.state.showMetaWindow ? 'active' : 'inactive'}`}>
<div className='row'> <div className='row'>
@@ -65,10 +60,6 @@ const MetadataNav = createReactClass({
<h4>Tags</h4> <h4>Tags</h4>
<p>{this.getTags()}</p> <p>{this.getTags()}</p>
</div> </div>
<div className='row'>
<h4>Systems</h4>
<p>{this.getSystems()}</p>
</div>
<div className='row'> <div className='row'>
<h4>Updated</h4> <h4>Updated</h4>
<p>{Moment(this.props.brew.updatedAt).fromNow()}</p> <p>{Moment(this.props.brew.updatedAt).fromNow()}</p>
+4 -10
View File
@@ -8,16 +8,10 @@ import PatreonNavItem from './patreon.navitem.jsx';
const Navbar = createReactClass({ const Navbar = createReactClass({
displayName : 'Navbar', displayName : 'Navbar',
getInitialState : function() { getInitialState : function() {
return { return {
//showNonChromeWarning : false, // showNonChromeWarning: false, // uncomment if needed
ver : '0.0.0' ver : global.version || '0.0.0'
}; };
},
getInitialState : function() {
return {
ver : global.version
};
}, },
/* /*
+1 -1
View File
@@ -1,4 +1,4 @@
@import 'naturalcrit/styles/colors.less'; @import '@sharedStyles/core.less';
@navbarHeight : 28px; @navbarHeight : 28px;
@viewerToolsHeight : 32px; @viewerToolsHeight : 32px;
+2 -2
View File
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import _ from 'lodash'; import _ from 'lodash';
import Nav from './nav.jsx'; import Nav from './nav.jsx';
import { splitTextStyleAndMetadata } from '../../../shared/helpers.js'; import { splitTextStyleAndMetadata } from '@shared/helpers.js';
const BREWKEY = 'HB_newPage_content'; const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style'; const STYLEKEY = 'HB_newPage_style';
@@ -24,7 +24,7 @@ const NewBrew = ()=>{
localStorage.setItem(BREWKEY, newBrew.text); localStorage.setItem(BREWKEY, newBrew.text);
localStorage.setItem(STYLEKEY, newBrew.style); localStorage.setItem(STYLEKEY, newBrew.style);
localStorage.setItem(METAKEY, JSON.stringify( localStorage.setItem(METAKEY, JSON.stringify(
_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang']) _.pick(newBrew, ['title', 'description', 'tags', 'renderer', 'theme', 'lang'])
)); ));
window.location.href = '/new'; window.location.href = '/new';
return; return;
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import Nav from './nav.jsx'; import Nav from './nav.jsx';
import { printCurrentBrew, scrapeBrewHTML, scrapeBrewZip } from '../../../shared/helpers.js'; import { printCurrentBrew, scrapeBrewHTML, scrapeBrewZip } from '@shared/helpers.js';
export default function(props){ export default function(props){
return <Nav.dropdown> return <Nav.dropdown>
@@ -1,3 +1,4 @@
@import '@sharedStyles/core.less';
.brewItem { .brewItem {
position : relative; position : relative;
+29 -28
View File
@@ -4,34 +4,35 @@ import './editPage.less';
// Common imports // Common imports
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import Markdown from '../../../../shared/markdown.js'; import Markdown from '@shared/markdown.js';
import _ from 'lodash'; import _ from 'lodash';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '../../../components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from '../../navbar/nav.jsx'; import Nav from '@navbar/nav.jsx';
import Navbar from '../../navbar/navbar.jsx'; import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '../../navbar/newbrew.navitem.jsx'; import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '../../navbar/account.navitem.jsx'; import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '../../navbar/error-navitem.jsx'; import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '../../navbar/help.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '../../navbar/vault.navitem.jsx'; import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '../../navbar/print.navitem.jsx'; import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '../../navbar/recent.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems; const { both: RecentNavItem } = RecentNavItems;
// Page specific imports // Page specific imports
import { Meta } from 'vitreum/headtags'; import Headtags from '../../../../vitreum/headtags.js';
const Meta = Headtags.Meta;
import { md5 } from 'hash-wasm'; import { md5 } from 'hash-wasm';
import { gzipSync, strToU8 } from 'fflate'; import { gzipSync, strToU8 } from 'fflate';
import { makePatches, stringifyPatches } from '@sanity/diff-match-patch'; 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 LockNotification from './lockNotification/lockNotification.jsx';
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js'; import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
import googleDriveIcon from '../../googleDrive.svg'; import googleDriveIcon from '../../googleDrive.svg';
@@ -56,28 +57,28 @@ const EditPage = (props)=>{
...props ...props
}; };
const [currentBrew , setCurrentBrew ] = useState(props.brew); const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving , setIsSaving ] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [lastSavedTime , setLastSavedTime ] = useState(new Date()); const [lastSavedTime, setLastSavedTime] = useState(new Date());
const [saveGoogle , setSaveGoogle ] = useState(!!props.brew.googleId); const [saveGoogle, setSaveGoogle] = useState(!!props.brew.googleId);
const [error , setError ] = useState(null); const [error, setError] = useState(null);
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text)); const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1); const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle ] = useState({}); const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges , setUnsavedChanges ] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(false);
const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed); const [alertTrashedGoogleBrew, setAlertTrashedGoogleBrew] = useState(props.brew.trashed);
const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false); const [alertLoginToTransfer, setAlertLoginToTransfer] = useState(false);
const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false); const [confirmGoogleTransfer, setConfirmGoogleTransfer] = useState(false);
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true); const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true); const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true);
const editorRef = useRef(null); const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const saveTimeout = useRef(null); const saveTimeout = useRef(null);
const warnUnsavedTimeout = 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 const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
useEffect(()=>{ useEffect(()=>{
@@ -1,7 +1,7 @@
import './errorPage.less'; import './errorPage.less';
import React from 'react'; import React from 'react';
import UIPage from '../basePages/uiPage/uiPage.jsx'; import UIPage from '../basePages/uiPage/uiPage.jsx';
import Markdown from '../../../../shared/markdown.js'; import Markdown from '@shared/markdown.js';
import ErrorIndex from './errors/errorIndex.js'; import ErrorIndex from './errors/errorIndex.js';
const ErrorPage = ({ brew })=>{ const ErrorPage = ({ brew })=>{
@@ -1,7 +1,6 @@
.homebrew { .homebrew {
.uiPage.sitePage { .uiPage.sitePage:has(.errorTitle) {
.errorTitle { .errorTitle {
//background-color: @orange;
color : #D02727; color : #D02727;
text-align : center; text-align : center;
} }
+22 -21
View File
@@ -1,33 +1,34 @@
/* eslint-disable max-lines */
import './homePage.less'; import './homePage.less';
// Common imports // Common imports
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import Markdown from '../../../../shared/markdown.js'; import Markdown from '@shared/markdown.js';
import _ from 'lodash'; import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '../../../components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from '../../navbar/nav.jsx'; import Nav from '@navbar/nav.jsx';
import Navbar from '../../navbar/navbar.jsx'; import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '../../navbar/newbrew.navitem.jsx'; import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '../../navbar/account.navitem.jsx'; import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '../../navbar/error-navitem.jsx'; import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '../../navbar/help.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '../../navbar/vault.navitem.jsx'; import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '../../navbar/print.navitem.jsx'; import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '../../navbar/recent.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems; const { both: RecentNavItem } = RecentNavItems;
// Page specific imports // Page specific imports
import { Meta } from 'vitreum/headtags'; import Headtags from '@vitreum/headtags.js';
const Meta = Headtags.Meta;
const BREWKEY = 'homebrewery-new'; const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style'; const STYLEKEY = 'homebrewery-new-style';
@@ -44,16 +45,16 @@ const HomePage =(props)=>{
...props ...props
}; };
const [currentBrew , setCurrentBrew] = useState(props.brew); const [currentBrew, setCurrentBrew] = useState(props.brew);
const [error , setError] = useState(undefined); const [error, setError] = useState(undefined);
const [HTMLErrors , setHTMLErrors] = useState(Markdown.validate(props.brew.text)); const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum] = useState(1); const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle] = useState({}); const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges , setUnsavedChanges] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(false);
const [isSaving , setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [autoSaveEnabled , setAutoSaveEnable] = useState(false); const [autoSaveEnabled, setAutoSaveEnable] = useState(false);
const editorRef = useRef(null); const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const lastSavedBrew = useRef(_.cloneDeep(props.brew));
@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.homePage { .homePage {
position : relative; position : relative;
a.floatingNewButton { a.floatingNewButton {
+21 -22
View File
@@ -4,29 +4,28 @@ import './newPage.less';
// Common imports // Common imports
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import Markdown from '../../../../shared/markdown.js'; import Markdown from '@shared/markdown.js';
import _ from 'lodash'; import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '../../../components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from '../../navbar/nav.jsx'; import Nav from '@navbar/nav.jsx';
import Navbar from '../../navbar/navbar.jsx'; import Navbar from '@navbar/navbar.jsx';
import NewBrewItem from '../../navbar/newbrew.navitem.jsx'; import NewBrewItem from '@navbar/newbrew.navitem.jsx';
import AccountNavItem from '../../navbar/account.navitem.jsx'; import AccountNavItem from '@navbar/account.navitem.jsx';
import ErrorNavItem from '../../navbar/error-navitem.jsx'; import ErrorNavItem from '@navbar/error-navitem.jsx';
import HelpNavItem from '../../navbar/help.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx';
import VaultNavItem from '../../navbar/vault.navitem.jsx'; import VaultNavItem from '@navbar/vault.navitem.jsx';
import PrintNavItem from '../../navbar/print.navitem.jsx'; import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '../../navbar/recent.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems; const { both: RecentNavItem } = RecentNavItems;
// Page specific imports // Page specific imports
import { Meta } from 'vitreum/headtags';
const BREWKEY = 'HB_newPage_content'; const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style'; const STYLEKEY = 'HB_newPage_style';
@@ -43,23 +42,23 @@ const NewPage = (props)=>{
...props ...props
}; };
const [currentBrew , setCurrentBrew ] = useState(props.brew); const [currentBrew, setCurrentBrew] = useState(props.brew);
const [isSaving , setIsSaving ] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveGoogle , setSaveGoogle ] = useState(global.account?.googleId ? true : false); const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false);
const [error , setError ] = useState(null); const [error, setError] = useState(null);
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text)); const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1); const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle ] = useState({}); const [themeBundle, setThemeBundle] = useState({});
const [unsavedChanges , setUnsavedChanges ] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(false);
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(false); const [autoSaveEnabled, setAutoSaveEnabled] = useState(false);
const editorRef = useRef(null); const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew)); const lastSavedBrew = useRef(_.cloneDeep(props.brew));
// const saveTimeout = useRef(null); // const saveTimeout = useRef(null);
// const warnUnsavedTimeout = 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 const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
useEffect(()=>{ useEffect(()=>{
@@ -1,3 +1,5 @@
@import '@sharedStyles/colors.less';
.newPage { .newPage {
.navItem.save { .navItem.save {
background-color : @orange; background-color : @orange;
@@ -1,18 +1,19 @@
import './sharePage.less'; import './sharePage.less';
import React, { useState, useEffect, useCallback } from 'react'; 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 Nav from '@navbar/nav.jsx';
import Navbar from '../../navbar/navbar.jsx'; import Navbar from '@navbar/navbar.jsx';
import MetadataNav from '../../navbar/metadata.navitem.jsx'; import MetadataNav from '@navbar/metadata.navitem.jsx';
import PrintNavItem from '../../navbar/print.navitem.jsx'; import PrintNavItem from '@navbar/print.navitem.jsx';
import RecentNavItems from '../../navbar/recent.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems; const { both: RecentNavItem } = RecentNavItems;
import Account from '../../navbar/account.navitem.jsx'; import Account from '@navbar/account.navitem.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '../../../../shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
const SharePage = (props)=>{ const SharePage = (props)=>{
const { brew = DEFAULT_BREW_LOAD, disableMeta = false, share = true } = props; const { brew = DEFAULT_BREW_LOAD, disableMeta = false, share = true } = props;
+8 -8
View File
@@ -3,15 +3,15 @@ import _ from 'lodash';
import ListPage from '../basePages/listPage/listPage.jsx'; import ListPage from '../basePages/listPage/listPage.jsx';
import Nav from '../../navbar/nav.jsx'; import Nav from '@navbar/nav.jsx';
import Navbar from '../../navbar/navbar.jsx'; import Navbar from '@navbar/navbar.jsx';
import RecentNavItems from '../../navbar/recent.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems; const { both: RecentNavItem } = RecentNavItems;
import Account from '../../navbar/account.navitem.jsx'; import Account from '@navbar/account.navitem.jsx';
import NewBrew from '../../navbar/newbrew.navitem.jsx'; import NewBrew from '@navbar/newbrew.navitem.jsx';
import HelpNavItem from '../../navbar/help.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx';
import ErrorNavItem from '../../navbar/error-navitem.jsx'; import ErrorNavItem from '@navbar/error-navitem.jsx';
import VaultNavitem from '../../navbar/vault.navitem.jsx'; import VaultNavitem from '@navbar/vault.navitem.jsx';
const UserPage = (props)=>{ const UserPage = (props)=>{
props = { props = {
@@ -3,13 +3,13 @@
import './vaultPage.less'; import './vaultPage.less';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import Nav from '../../navbar/nav.jsx'; import Nav from '@navbar/nav.jsx';
import Navbar from '../../navbar/navbar.jsx'; import Navbar from '@navbar/navbar.jsx';
import RecentNavItems from '../../navbar/recent.navitem.jsx'; import RecentNavItems from '@navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems; const { both: RecentNavItem } = RecentNavItems;
import Account from '../../navbar/account.navitem.jsx'; import Account from '@navbar/account.navitem.jsx';
import NewBrew from '../../navbar/newbrew.navitem.jsx'; import NewBrew from '@navbar/newbrew.navitem.jsx';
import HelpNavItem from '../../navbar/help.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx';
import BrewItem from '../basePages/listPage/brewItem/brewItem.jsx'; import BrewItem from '../basePages/listPage/brewItem/brewItem.jsx';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '../../../components/splitPane/splitPane.jsx';
import ErrorIndex from '../errorPage/errors/errorIndex.js'; import ErrorIndex from '../errorPage/errors/errorIndex.js';
@@ -1,3 +1,5 @@
@import '@sharedStyles/core.less';
.vaultPage { .vaultPage {
height : 100%; height : 100%;
overflow-y : hidden; overflow-y : hidden;
-33
View File
@@ -1,33 +0,0 @@
const template = async function(name, title='', props = {}){
const ogTags = [];
const ogMeta = props.ogMeta ?? {};
Object.entries(ogMeta).forEach(([key, value])=>{
if(!value) return;
const tag = `<meta property="og:${key}" content="${value}">`;
ogTags.push(tag);
});
const ogMetaTags = ogTags.join('\n');
const ssrModule = await import(`../build/${name}/ssr.cjs`);
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, height=device-height, interactive-widget=resizes-visual" />
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
<link href=${`/${name}/bundle.css`} type="text/css" rel='stylesheet' />
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" />
${ogMetaTags}
<meta name="twitter:card" content="summary">
<title>${title.length ? `${title} - The Homebrewery`: 'The Homebrewery - NaturalCrit'}</title>
</head>
<body>
<main id="reactRoot">${ssrModule.default(props)}</main>
<script src=${`/${name}/bundle.js`}></script>
<script>start_app(${JSON.stringify(props)})</script>
</body>
</html>
`;
};
export default template;
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, height=device-height, interactive-widget=resizes-visual" />
<link
href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700"
rel="stylesheet"
type="text/css" />
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" />
<meta name="twitter:card" content="summary" />
<title>The Homebrewery - NaturalCrit</title>
</head>
<body>
<main id="reactRoot"></main>
<script type="module">
if (window.location.pathname.startsWith('/admin')) {
import('/client/admin/main.jsx');
} else {
import('/client/homebrew/main.jsx');
}
</script>
</body>
</html>
+1743 -3972
View File
File diff suppressed because it is too large Load Diff
+20 -24
View File
@@ -4,18 +4,16 @@
"version": "3.20.1", "version": "3.20.1",
"type": "module", "type": "module",
"engines": { "engines": {
"npm": "^10.8.x", "npm": ">=10.8 <12",
"node": "^20.18.x" "node": ">=20.18 <25"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/naturalcrit/homebrewery.git" "url": "git://github.com/naturalcrit/homebrewery.git"
}, },
"scripts": { "scripts": {
"dev": "node --experimental-require-module scripts/dev.js", "start": "node server.js",
"quick": "node --experimental-require-module scripts/quick.js", "build": "vite build",
"build": "node --experimental-require-module scripts/buildHomebrew.js && node --experimental-require-module scripts/buildAdmin.js",
"builddev": "node --experimental-require-module scripts/buildHomebrew.js --dev",
"lint": "eslint --fix", "lint": "eslint --fix",
"lint:dry": "eslint", "lint:dry": "eslint",
"stylelint": "stylelint --fix **/*.{less}", "stylelint": "stylelint --fix **/*.{less}",
@@ -44,7 +42,6 @@
"phb": "node --experimental-require-module scripts/phb.js", "phb": "node --experimental-require-module scripts/phb.js",
"prod": "set NODE_ENV=production && npm run build", "prod": "set NODE_ENV=production && npm run build",
"postinstall": "npm run build", "postinstall": "npm run build",
"start": "node --experimental-require-module server.js",
"docker:build": "docker build -t ${DOCKERID}/homebrewery:$npm_package_version .", "docker:build": "docker build -t ${DOCKERID}/homebrewery:$npm_package_version .",
"docker:publish": "docker login && docker push ${DOCKERID}/homebrewery:$npm_package_version" "docker:publish": "docker login && docker push ${DOCKERID}/homebrewery:$npm_package_version"
}, },
@@ -93,10 +90,11 @@
"@babel/plugin-transform-runtime": "^7.29.0", "@babel/plugin-transform-runtime": "^7.29.0",
"@babel/preset-env": "^7.29.0", "@babel/preset-env": "^7.29.0",
"@babel/preset-react": "^7.28.5", "@babel/preset-react": "^7.28.5",
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.6",
"@dmsnell/diff-match-patch": "^1.1.0", "@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^20.1.0", "@googleapis/drive": "^20.1.0",
"@sanity/diff-match-patch": "^3.2.0", "@sanity/diff-match-patch": "^3.2.0",
"@vitejs/plugin-react": "^5.1.2",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror": "^5.65.6", "codemirror": "^5.65.6",
@@ -105,7 +103,6 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"create-react-class": "^15.7.0", "create-react-class": "^15.7.0",
"dedent": "^1.7.1", "dedent": "^1.7.1",
"expr-eval": "^2.0.2",
"express": "^5.1.0", "express": "^5.1.0",
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.0", "express-static-gzip": "3.0.0",
@@ -115,7 +112,7 @@
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jwt-simple": "^0.5.6", "jwt-simple": "^0.5.6",
"less": "^3.13.1", "less": "^4.5.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "15.0.12", "marked": "15.0.12",
"marked-alignment-paragraphs": "^1.0.0", "marked-alignment-paragraphs": "^1.0.0",
@@ -132,21 +129,19 @@
"mongoose": "^9.2.1", "mongoose": "^9.2.1",
"nanoid": "5.1.6", "nanoid": "5.1.6",
"nconf": "^0.13.0", "nconf": "^0.13.0",
"react": "^18.3.1", "node": "^25.7.0",
"react-dom": "^18.3.1", "react": "^19.2.4",
"react-frame-component": "^4.1.3", "react-dom": "^19.2.4",
"react-router": "^7.9.6", "react-frame-component": "^5.2.7",
"romans": "^3.1.0", "react-router": "^7.13.1",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"superagent": "^10.2.1", "superagent": "^10.2.1"
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git",
"written-number": "^0.11.1"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^4.0.0", "@stylistic/stylelint-plugin": "^5.0.1",
"babel-jest": "^30.2.0", "babel-jest": "^30.2.0",
"babel-plugin-transform-import-meta": "^2.3.3", "babel-plugin-transform-import-meta": "^2.3.3",
"eslint": "^9.39.1", "eslint": "9.7",
"eslint-plugin-jest": "^29.1.0", "eslint-plugin-jest": "^29.1.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^16.4.0", "globals": "^16.4.0",
@@ -155,9 +150,10 @@
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"jsdom-global": "^3.0.2", "jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^16.25.0", "stylelint": "^17.4.0",
"stylelint-config-recess-order": "^7.3.0", "stylelint-config-recess-order": "^7.6.1",
"stylelint-config-recommended": "^17.0.0", "stylelint-config-recommended": "^18.0.0",
"supertest": "^7.1.4" "supertest": "^7.1.4",
"vite": "^7.3.1"
} }
} }
-32
View File
@@ -1,32 +0,0 @@
import fs from 'fs-extra';
import Proj from './project.json' with { type: 'json' };
import vitreum from 'vitreum';
const { pack } = vitreum;
import lessTransform from 'vitreum/transforms/less.js';
import assetTransform from 'vitreum/transforms/asset.js';
const isDev = !!process.argv.find((arg)=>arg=='--dev');
const transforms = {
'.less' : lessTransform,
'*' : assetTransform('./build')
};
const build = async ({ bundle, render, ssr })=>{
const css = await lessTransform.generate({ paths: './shared' });
await fs.outputFile('./build/admin/bundle.css', css);
await fs.outputFile('./build/admin/bundle.js', bundle);
await fs.outputFile('./build/admin/ssr.cjs', ssr);
};
fs.emptyDirSync('./build/admin');
pack('./client/admin/admin.jsx', {
paths : ['./shared'],
libs : Proj.libs,
dev : isDev && build,
transforms
})
.then(build)
.catch(console.error);
-169
View File
@@ -1,169 +0,0 @@
import fs from 'fs-extra';
import zlib from 'zlib';
import Proj from './project.json' with { type: 'json' };
import vitreum from 'vitreum';
const { pack, watchFile, livereload } = vitreum;
import lessTransform from 'vitreum/transforms/less.js';
import assetTransform from 'vitreum/transforms/asset.js';
import babel from '@babel/core';
import babelConfig from '../babel.config.json' with { type : 'json' };
import less from 'less';
const isDev = !!process.argv.find((arg)=>arg === '--dev');
const babelify = async (code)=>(await babel.transformAsync(code, babelConfig)).code;
const transforms = {
'.js' : (code, filename, opts)=>babelify(code),
'.jsx' : (code, filename, opts)=>babelify(code),
'.less' : lessTransform,
'*' : assetTransform('./build')
};
const build = async ({ bundle, render, ssr })=>{
const css = await lessTransform.generate({ paths: './shared' });
//css = `@layer bundle {\n${css}\n}`;
await fs.outputFile('./build/homebrew/bundle.css', css);
await fs.outputFile('./build/homebrew/bundle.js', bundle);
await fs.outputFile('./build/homebrew/ssr.cjs', ssr);
await fs.copy('./client/homebrew/favicon.ico', './build/assets/favicon.ico');
//compress files in production
if(!isDev){
await fs.outputFile('./build/homebrew/bundle.css.br', zlib.brotliCompressSync(css));
await fs.outputFile('./build/homebrew/bundle.js.br', zlib.brotliCompressSync(bundle));
await fs.outputFile('./build/homebrew/ssr.js.br', zlib.brotliCompressSync(ssr));
} else {
await fs.remove('./build/homebrew/bundle.css.br');
await fs.remove('./build/homebrew/bundle.js.br');
await fs.remove('./build/homebrew/ssr.js.br');
}
};
fs.emptyDirSync('./build');
(async ()=>{
//v==----------------------------- COMPILE THEMES --------------------------------==v//
// Update list of all Theme files
const themes = { Legacy: {}, V3: {} };
let themeFiles = fs.readdirSync('./themes/Legacy');
for (const dir of themeFiles) {
const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`).toString());
themeData.path = dir;
themes.Legacy[dir] = (themeData);
//fs.copy(`./themes/Legacy/${dir}/dropdownTexture.png`, `./build/themes/Legacy/${dir}/dropdownTexture.png`);
const src = `./themes/Legacy/${dir}/style.less`;
((outputDirectory)=>{
less.render(fs.readFileSync(src).toString(), {
compress : !isDev
}, function(e, output) {
fs.outputFile(outputDirectory, output.css);
});
})(`./build/themes/Legacy/${dir}/style.css`);
}
themeFiles = fs.readdirSync('./themes/V3');
for (const dir of themeFiles) {
const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`).toString());
themeData.path = dir;
themes.V3[dir] = (themeData);
fs.copy(`./themes/V3/${dir}/dropdownTexture.png`, `./build/themes/V3/${dir}/dropdownTexture.png`);
fs.copy(`./themes/V3/${dir}/dropdownPreview.png`, `./build/themes/V3/${dir}/dropdownPreview.png`);
const src = `./themes/V3/${dir}/style.less`;
((outputDirectory)=>{
less.render(fs.readFileSync(src).toString(), {
compress : !isDev
}, function(e, output) {
fs.outputFile(outputDirectory, output.css);
});
})(`./build/themes/V3/${dir}/style.css`);
}
await fs.outputFile('./themes/themes.json', JSON.stringify(themes, null, 2));
// await less.render(lessCode, {
// compress : !dev,
// sourceMap : (dev ? {
// sourceMapFileInline: true,
// outputSourceFiles: true
// } : false),
// })
// Move assets
await fs.copy('./themes/fonts', './build/fonts');
await fs.copy('./themes/assets', './build/assets');
await fs.copy('./client/icons', './build/icons');
//v==---------------------------MOVE CM EDITOR THEMES -----------------------------==v//
const editorThemesBuildDir = './build/homebrew/cm-themes';
await fs.copy('./node_modules/codemirror/theme', editorThemesBuildDir);
await fs.copy('./themes/codeMirror/customThemes', editorThemesBuildDir);
const editorThemeFiles = fs.readdirSync(editorThemesBuildDir);
const editorThemeFile = './themes/codeMirror/editorThemes.json';
if(fs.existsSync(editorThemeFile)) fs.rmSync(editorThemeFile);
const stream = fs.createWriteStream(editorThemeFile, { flags: 'a' });
stream.write('[\n"default"');
for (const themeFile of editorThemeFiles) {
stream.write(`,\n"${themeFile.slice(0, -4)}"`);
}
stream.write('\n]\n');
stream.end();
await fs.copy('./themes/codeMirror', './build/homebrew/codeMirror');
//v==----------------------------- BUNDLE PACKAGES --------------------------------==v//
const bundles = await pack('./client/homebrew/homebrew.jsx', {
paths : ['./shared', './'],
libs : Proj.libs,
dev : isDev && build,
transforms
});
build(bundles);
// Possible method for generating separate bundles for theme snippets: factor-bundle first sending all common files to bundle.js, then again using default settings, keeping only snippet bundles
// await fs.outputFile('./build/junk.js', '');
// await fs.outputFile('./build/themes/Legacy/5ePHB/snippets.js', '');
//
// const files = ['./client/homebrew/homebrew.jsx','./themes/Legacy/5ePHB/snippets.js'];
//
// bundles = await pack(files, {
// dedupe: false,
// plugin : [['factor-bundle', { outputs: [ './build/junk.js','./build/themes/Legacy/5ePHB/snippets.js'], threshold : function(row, groups) {
// console.log(groups);
// if (groups.some(group => /.*homebrew.jsx$/.test(group))) {
// console.log("found homebrewery")
// return true;
// }
// return this._defaultThreshold(row, groups);
// }}]],
// paths : ['./shared','./','./build'],
// libs : Proj.libs,
// dev : isDev && build,
// transforms
// });
// build(bundles);
//
//In development, set up LiveReload (refreshes browser), and Nodemon (restarts server)
if(isDev){
livereload('./build'); // Install the Chrome extension LiveReload to automatically refresh the browser
watchFile('./server.js', { // Restart server when change detected to this file or any nested directory from here
ignore : ['./build', './client', './themes'], // Ignore folders that are not running server code / avoids unneeded restarts
ext : 'js json' // Extensions to watch (only .js/.json by default)
//watch : ['./server', './themes'], // Watch additional folders if needed
});
}
})().catch(console.error);
-22
View File
@@ -1,22 +0,0 @@
const label = 'dev';
console.time(label);
const jsx = require('vitreum/steps/jsx.watch.js');
const less = require('vitreum/steps/less.watch.js');
const assets = require('vitreum/steps/assets.watch.js');
const server = require('vitreum/steps/server.watch.js');
const livereload = require('vitreum/steps/livereload.js');
const Proj = require('./project.json');
Promise.resolve()
.then(()=>jsx('homebrew', './client/homebrew/homebrew.jsx', { libs: Proj.libs, shared: ['./shared'] }))
.then((deps)=>less('homebrew', { shared: ['./shared'] }, deps))
.then(()=>jsx('admin', './client/admin/admin.jsx', { libs: Proj.libs, shared: ['./shared'] }))
.then((deps)=>less('admin', { shared: ['./shared'] }, deps))
.then(()=>assets(Proj.assets, ['./shared', './client']))
.then(()=>livereload())
.then(()=>server('./server.js', ['server']))
.then(console.timeEnd.bind(console, label))
.catch(console.error);
-17
View File
@@ -1,17 +0,0 @@
const label = 'quick';
console.time(label);
const jsx = require('vitreum/steps/jsx.js').partial;
const less = require('vitreum/steps/less.js').partial;
const server = require('vitreum/steps/server.watch.js').partial;
const Proj = require('./project.json');
Promise.resolve()
.then(jsx('homebrew', './client/homebrew/homebrew.jsx', Proj.libs, ['./shared']))
.then(less('homebrew', ['./shared']))
.then(jsx('admin', './client/admin/admin.jsx', Proj.libs, ['./shared']))
.then(less('admin', ['./shared']))
.then(server('./server.js', ['server']))
.then(console.timeEnd.bind(console, label))
.catch(console.error);
+30 -10
View File
@@ -1,12 +1,29 @@
import DB from './server/db.js'; 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 config from './server/config.js';
import { createServer as createViteServer } from 'vite';
DB.connect(config).then(()=>{ const isDev = process.env.NODE_ENV === 'local';
// Ensure that we have successfully connected to the database
// before launching server async function start() {
const PORT = process.env.PORT || config.get('web_port') || 8000; let vite;
server.listen(PORT, ()=>{
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 reset = '\x1b[0m'; // Reset to default style
const bright = '\x1b[1m'; // Bright (bold) style const bright = '\x1b[1m'; // Bright (bold) style
const cyan = '\x1b[36m'; // Cyan color 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(`\n\tserver started at: ${new Date().toLocaleString()}`);
console.log(`\tserver on port: ${PORT}`); 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();
+245 -227
View File
@@ -4,64 +4,69 @@ import { model as NotificationModel } from './notifications.model.js';
import express from 'express'; import express from 'express';
import Moment from 'moment'; import Moment from 'moment';
import zlib from 'zlib'; 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 HomebrewAPI from './homebrew.api.js';
import asyncHandler from 'express-async-handler'; import asyncHandler from 'express-async-handler';
import { splitTextStyleAndMetadata } from '../shared/helpers.js'; import { splitTextStyleAndMetadata } from '../shared/helpers.js';
const router = express.Router();
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin'; process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3'; process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
const mw = { export default function createAdminApi(vite) {
adminOnly : (req, res, next)=>{ const router = express.Router();
if(!req.get('authorization')){
return res const mw = {
adminOnly : (req, res, next)=>{
if(!req.get('authorization')){
return res
.set('WWW-Authenticate', 'Basic realm="Authorization Required"') .set('WWW-Authenticate', 'Basic realm="Authorization Required"')
.status(401) .status(401)
.send('Authorization Required'); .send('Authorization Required');
} }
const [username, password] = Buffer.from(req.get('authorization').split(' ').pop(), 'base64') const [username, password] = Buffer.from(req.get('authorization').split(' ').pop(), 'base64')
.toString('ascii') .toString('ascii')
.split(':'); .split(':');
if(process.env.ADMIN_USER === username && process.env.ADMIN_PASS === password){ if(process.env.ADMIN_USER === username && process.env.ADMIN_PASS === password){
return next(); return next();
}
throw { HBErrorCode: '52', code: 401, message: 'Access denied' };
} }
throw { HBErrorCode: '52', code: 401, message: 'Access denied' }; };
}
};
const junkBrewPipeline = [ const junkBrewPipeline = [
{ $match : { { $match : {
updatedAt : { $lt: Moment().subtract(30, 'days').toDate() }, updatedAt : { $lt: Moment().subtract(30, 'days').toDate() },
lastViewed : { $lt: Moment().subtract(30, 'days').toDate() } lastViewed : { $lt: Moment().subtract(30, 'days').toDate() }
} }, } },
{ $project: { textBinSize: { $binarySize: '$textBin' } } }, { $project: { textBinSize: { $binarySize: '$textBin' } } },
{ $match: { textBinSize: { $lt: 140 } } }, { $match: { textBinSize: { $lt: 140 } } },
{ $limit: 100 } { $limit: 100 }
]; ];
/* Search for brews that aren't compressed (missing the compressed text field) */ /* Search for brews that aren't compressed (missing the compressed text field) */
const uncompressedBrewQuery = HomebrewModel.find({ const uncompressedBrewQuery = HomebrewModel.find({
'text' : { '$exists': true } 'text' : { '$exists': true }
}).lean().limit(10000).select('_id'); }).lean().limit(10000).select('_id');
// Search for up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes // Search for up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.get('/admin/cleanup', mw.adminOnly, (req, res)=>{ router.get('/admin/cleanup', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 }) HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 })
.then((objs)=>res.json({ count: objs.length })) .then((objs)=>res.json({ count: objs.length }))
.catch((error)=>{ .catch((error)=>{
console.error(error); console.error(error);
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({ error: 'Internal Server Error' });
}); });
}); });
// Delete up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes // Delete up to 100 brews that have not been viewed or updated in 30 days and are shorter than 140 bytes
router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{ router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 }) HomebrewModel.aggregate(junkBrewPipeline).option({ maxTimeMS: 60000 })
.then((docs)=>{ .then((docs)=>{
const ids = docs.map((doc)=>doc._id); const ids = docs.map((doc)=>doc._id);
return HomebrewModel.deleteMany({ _id: { $in: ids } }); return HomebrewModel.deleteMany({ _id: { $in: ids } });
@@ -71,18 +76,18 @@ router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
console.error(error); console.error(error);
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({ error: 'Internal Server Error' });
}); });
}); });
/* Searches for matching edit or share id, also attempts to partial match */ /* Searches for matching edit or share id, also attempts to partial match */
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{ router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{
return res.json(req.brew); return res.json(req.brew);
}); });
/* Find 50 brews that aren't compressed yet */ /* Find 50 brews that aren't compressed yet */
router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
const query = uncompressedBrewQuery.clone(); const query = uncompressedBrewQuery.clone();
query.exec() query.exec()
.then((objs)=>{ .then((objs)=>{
const ids = objs.map((obj)=>obj._id); const ids = objs.map((obj)=>obj._id);
res.json({ count: ids.length, ids }); res.json({ count: ids.length, ids });
@@ -91,46 +96,46 @@ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
console.error(err); console.error(err);
res.status(500).send(err.message || 'Internal Server Error'); res.status(500).send(err.message || 'Internal Server Error');
}); });
});
/* Cleans `<script` and `</script>` from the "text" field of a brew */
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Cleaning script tags from ShareID ${req.params.id}`);
function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');};
const brew = req.brew;
const properties = ['text', 'description', 'title'];
properties.forEach((property)=>{
brew[property] = cleanText(brew[property]);
}); });
splitTextStyleAndMetadata(brew); /* Cleans `<script` and `</script>` from the "text" field of a brew */
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Cleaning script tags from ShareID ${req.params.id}`);
req.body = brew; function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');};
// Remove Account from request to prevent Admin user from being added to brew as an Author const brew = req.brew;
req.account = undefined;
return await HomebrewAPI.updateBrew(req, res); const properties = ['text', 'description', 'title'];
}); properties.forEach((property)=>{
brew[property] = cleanText(brew[property]);
});
/* Get list of a user's documents */ splitTextStyleAndMetadata(brew);
router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
const username = req.params.user;
const fields = { _id: 0, text: 0, textBin: 0 }; // Remove unnecessary fields from document lists
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Get brew list for ${username}`); req.body = brew;
const brews = await HomebrewModel.getByUser(username, true, fields); // Remove Account from request to prevent Admin user from being added to brew as an Author
req.account = undefined;
return res.json(brews); return await HomebrewAPI.updateBrew(req, res);
}); });
/* Compresses the "text" field of a brew to binary */ /* Get list of a user's documents */
router.put('/admin/compress/:id', (req, res)=>{ router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
HomebrewModel.findOne({ _id: req.params.id }) const username = req.params.user;
const fields = { _id: 0, text: 0, textBin: 0 }; // Remove unnecessary fields from document lists
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Get brew list for ${username}`);
const brews = await HomebrewModel.getByUser(username, true, fields);
return res.json(brews);
});
/* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{
HomebrewModel.findOne({ _id: req.params.id })
.then((brew)=>{ .then((brew)=>{
if(!brew) if(!brew)
return res.status(404).send('Brew not found'); return res.status(404).send('Brew not found');
@@ -147,239 +152,252 @@ router.put('/admin/compress/:id', (req, res)=>{
console.error(err); console.error(err);
res.status(500).send('Error while saving'); res.status(500).send('Error while saving');
}); });
}); });
router.get('/admin/stats', mw.adminOnly, async (req, res)=>{ router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
try { try {
const totalBrewsCount = await HomebrewModel.countDocuments({}); const totalBrewsCount = await HomebrewModel.countDocuments({});
const publishedBrewsCount = await HomebrewModel.countDocuments({ published: true }); const publishedBrewsCount = await HomebrewModel.countDocuments({ published: true });
return res.json({ return res.json({
totalBrews : totalBrewsCount, totalBrews : totalBrewsCount,
totalPublishedBrews : publishedBrewsCount totalPublishedBrews : publishedBrewsCount
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return res.status(500).json({ error: 'Internal Server Error' }); return res.status(500).json({ error: 'Internal Server Error' });
} }
}); });
// ####################### LOCKS // ####################### LOCKS
router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{ router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksQuery = { const countLocksQuery = {
lock : { $exists: true } lock : { $exists: true }
}; };
const count = await HomebrewModel.countDocuments(countLocksQuery) const count = await HomebrewModel.countDocuments(countLocksQuery)
.catch((error)=>{ .catch((error)=>{
throw { name: 'Lock Count Error', message: 'Unable to get lock count', status: 500, HBErrorCode: '61', error }; throw { name: 'Lock Count Error', message: 'Unable to get lock count', status: 500, HBErrorCode: '61', error };
}); });
return res.json({ count }); return res.json({ count });
})); }));
router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{ router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksPipeline = [ const countLocksPipeline = [
{ {
$match : $match :
{ {
'lock' : { '$exists': 1 } 'lock' : { '$exists': 1 }
}, },
}, },
{ {
$project : { $project : {
shareId : 1, shareId : 1,
editId : 1, editId : 1,
title : 1, title : 1,
lock : 1 lock : 1
}
} }
} ];
]; const lockedDocuments = await HomebrewModel.aggregate(countLocksPipeline)
const lockedDocuments = await HomebrewModel.aggregate(countLocksPipeline)
.catch((error)=>{ .catch((error)=>{
throw { name: 'Can Not Get Locked Brews', message: 'Unable to get locked brew collection', status: 500, HBErrorCode: '68', error }; throw { name: 'Can Not Get Locked Brews', message: 'Unable to get locked brew collection', status: 500, HBErrorCode: '68', error };
}); });
return res.json({ return res.json({
lockedDocuments lockedDocuments
}); });
})); }));
router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{ router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const lock = req.body; const lock = req.body;
lock.applied = new Date; lock.applied = new Date;
const filter = { const filter = {
shareId : req.params.id shareId : req.params.id
}; };
const brew = await HomebrewModel.findOne(filter); const brew = await HomebrewModel.findOne(filter);
if(!brew) throw { name: 'Brew Not Found', message: 'Cannot find brew to lock', shareId: req.params.id, status: 500, HBErrorCode: '63' }; if(!brew) throw { name: 'Brew Not Found', message: 'Cannot find brew to lock', shareId: req.params.id, status: 500, HBErrorCode: '63' };
if(brew.lock && !lock.overwrite) { if(brew.lock && !lock.overwrite) {
throw { name: 'Already Locked', message: 'Lock already exists on brew', shareId: req.params.id, title: brew.title, status: 500, HBErrorCode: '64' }; throw { name: 'Already Locked', message: 'Lock already exists on brew', shareId: req.params.id, title: brew.title, status: 500, HBErrorCode: '64' };
} }
lock.overwrite = undefined; lock.overwrite = undefined;
brew.lock = lock; brew.lock = lock;
brew.markModified('lock'); brew.markModified('lock');
await brew.save() await brew.save()
.catch((error)=>{ .catch((error)=>{
throw { name: 'Lock Error', message: 'Unable to set lock', shareId: req.params.id, status: 500, HBErrorCode: '62', error }; throw { name: 'Lock Error', message: 'Unable to set lock', shareId: req.params.id, status: 500, HBErrorCode: '62', error };
}); });
return res.json({ name: 'LOCKED', message: `Lock applied to brew ID ${brew.shareId} - ${brew.title}`, ...lock }); return res.json({ name: 'LOCKED', message: `Lock applied to brew ID ${brew.shareId} - ${brew.title}`, ...lock });
})); }));
router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{ router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const filter = { const filter = {
shareId : req.params.id shareId : req.params.id
}; };
const brew = await HomebrewModel.findOne(filter); const brew = await HomebrewModel.findOne(filter);
if(!brew) throw { name: 'Brew Not Found', message: 'Cannot find brew to unlock', shareId: req.params.id, status: 500, HBErrorCode: '66' }; if(!brew) throw { name: 'Brew Not Found', message: 'Cannot find brew to unlock', shareId: req.params.id, status: 500, HBErrorCode: '66' };
if(!brew.lock) throw { name: 'Not Locked', message: 'Cannot unlock as brew is not locked', shareId: req.params.id, status: 500, HBErrorCode: '67' }; if(!brew.lock) throw { name: 'Not Locked', message: 'Cannot unlock as brew is not locked', shareId: req.params.id, status: 500, HBErrorCode: '67' };
brew.lock = undefined; brew.lock = undefined;
brew.markModified('lock'); brew.markModified('lock');
await brew.save() await brew.save()
.catch((error)=>{ .catch((error)=>{
throw { name: 'Cannot Unlock', message: 'Unable to clear lock', shareId: req.params.id, status: 500, HBErrorCode: '65', error }; throw { name: 'Cannot Unlock', message: 'Unable to clear lock', shareId: req.params.id, status: 500, HBErrorCode: '65', error };
}); });
return res.json({ name: 'Unlocked', message: `Lock removed from brew ID ${req.params.id}` }); return res.json({ name: 'Unlocked', message: `Lock removed from brew ID ${req.params.id}` });
})); }));
router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{ router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{
const countReviewsPipeline = [ const countReviewsPipeline = [
{ {
$match : $match :
{ {
'lock.reviewRequested' : { '$exists': 1 } 'lock.reviewRequested' : { '$exists': 1 }
}, },
}, },
{ {
$project : { $project : {
shareId : 1, shareId : 1,
editId : 1, editId : 1,
title : 1, title : 1,
lock : 1 lock : 1
}
} }
} ];
]; const reviewDocuments = await HomebrewModel.aggregate(countReviewsPipeline)
const reviewDocuments = await HomebrewModel.aggregate(countReviewsPipeline)
.catch((error)=>{ .catch((error)=>{
throw { name: 'Can Not Get Reviews', message: 'Unable to get review collection', status: 500, HBErrorCode: '68', error }; throw { name: 'Can Not Get Reviews', message: 'Unable to get review collection', status: 500, HBErrorCode: '68', error };
}); });
return res.json({ return res.json({
reviewDocuments reviewDocuments
}); });
})); }));
router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{ router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{
// === This route is NOT Admin only === // === This route is NOT Admin only ===
// Any user can request a review of their document // Any user can request a review of their document
const filter = { const filter = {
shareId : req.params.id, shareId : req.params.id,
lock : { $exists: 1 } lock : { $exists: 1 }
}; };
const brew = await HomebrewModel.findOne(filter); const brew = await HomebrewModel.findOne(filter);
if(!brew) { throw { name: 'Brew Not Found', message: `Cannot find a locked brew with ID ${req.params.id}`, code: 500, HBErrorCode: '70' }; }; if(!brew) { throw { name: 'Brew Not Found', message: `Cannot find a locked brew with ID ${req.params.id}`, code: 500, HBErrorCode: '70' }; };
if(brew.lock.reviewRequested){ if(brew.lock.reviewRequested){
throw { name: 'Review Already Requested', message: `Review already requested for brew ${brew.shareId} - ${brew.title}`, code: 500, HBErrorCode: '71' }; throw { name: 'Review Already Requested', message: `Review already requested for brew ${brew.shareId} - ${brew.title}`, code: 500, HBErrorCode: '71' };
}; };
brew.lock.reviewRequested = new Date(); brew.lock.reviewRequested = new Date();
brew.markModified('lock'); brew.markModified('lock');
await brew.save() await brew.save()
.catch((error)=>{ .catch((error)=>{
throw { name: 'Can Not Set Review Request', message: `Unable to set request for review on brew ID ${req.params.id}`, code: 500, HBErrorCode: '69', error }; throw { name: 'Can Not Set Review Request', message: `Unable to set request for review on brew ID ${req.params.id}`, code: 500, HBErrorCode: '69', error };
}); });
return res.json({ name: 'Review Requested', message: `Review requested on brew ID ${brew.shareId} - ${brew.title}` }); return res.json({ name: 'Review Requested', message: `Review requested on brew ID ${brew.shareId} - ${brew.title}` });
})); }));
router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req, res)=>{ router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const filter = { const filter = {
shareId : req.params.id, shareId : req.params.id,
'lock.reviewRequested' : { $exists: 1 } 'lock.reviewRequested' : { $exists: 1 }
}; };
const brew = await HomebrewModel.findOne(filter); const brew = await HomebrewModel.findOne(filter);
if(!brew) { throw { name: 'Can Not Clear Review Request', message: `Brew ID ${req.params.id} does not have a review pending!`, HBErrorCode: '73' }; }; if(!brew) { throw { name: 'Can Not Clear Review Request', message: `Brew ID ${req.params.id} does not have a review pending!`, HBErrorCode: '73' }; };
brew.lock.reviewRequested = undefined; brew.lock.reviewRequested = undefined;
brew.markModified('lock'); brew.markModified('lock');
await brew.save() await brew.save()
.catch((error)=>{ .catch((error)=>{
throw { name: 'Can Not Clear Review Request', message: `Unable to remove request for review on brew ID ${req.params.id}`, HBErrorCode: '72', error }; throw { name: 'Can Not Clear Review Request', message: `Unable to remove request for review on brew ID ${req.params.id}`, HBErrorCode: '72', error };
}); });
return res.json({ name: 'Review Request Cleared', message: `Review request removed for brew ID ${brew.shareId} - ${brew.title}` }); return res.json({ name: 'Review Request Cleared', message: `Review request removed for brew ID ${brew.shareId} - ${brew.title}` });
})); }));
// ####################### NOTIFICATIONS // ####################### NOTIFICATIONS
router.get('/admin/notification/all', async (req, res, next)=>{ router.get('/admin/notification/all', async (req, res, next)=>{
try { try {
const notifications = await NotificationModel.getAll(); const notifications = await NotificationModel.getAll();
return res.json(notifications); return res.json(notifications);
} catch (error) { } catch (error) {
console.log('Error getting all notifications: ', error.message); console.log('Error getting all notifications: ', error.message);
return res.status(500).json({ message: error.message }); return res.status(500).json({ message: error.message });
} }
});
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.addNotification(req.body);
return res.status(201).json(notification);
} catch (error) {
console.log('Error adding notification: ', error.message);
return res.status(500).json({ message: error.message });
}
});
router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.deleteNotification(req.params.id);
return res.json(notification);
} catch (error) {
console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }');
return res.status(500).json({ message: error.message });
}
});
router.get('/admin', mw.adminOnly, (req, res)=>{
templateFn('admin', {
url : req.originalUrl
})
.then((page)=>res.send(page))
.catch((err)=>{
console.log(err);
res.sendStatus(500);
}); });
});
export default router; router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.addNotification(req.body);
return res.status(201).json(notification);
} catch (error) {
console.log('Error adding notification: ', error.message);
return res.status(500).json({ message: error.message });
}
});
router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{
try {
const notification = await NotificationModel.deleteNotification(req.params.id);
return res.json(notification);
} catch (error) {
console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }');
return res.status(500).json({ message: error.message });
}
});
router.get('/admin', mw.adminOnly, asyncHandler(async (req, res)=>{
const props = {
url : req.originalUrl
};
const htmlPath = isProd
? path.resolve('build', 'index.html')
: path.resolve('index.html');
let html = fs.readFileSync(htmlPath, 'utf-8');
if(!isProd && vite?.transformIndexHtml) {
html = await vite.transformIndexHtml(req.originalUrl, html);
}
res.send(html.replace(
'<head>',
`<head>\n<script id="props">window.__INITIAL_PROPS__ = ${JSON.stringify(props)}</script>`
));
}));
return router;
}
+160 -228
View File
@@ -1,42 +1,45 @@
/*eslint max-lines: ["warn", {"max": 1000, "skipBlankLines": true, "skipComments": true}]*/ /*eslint max-lines: ["warn", {"max": 1000, "skipBlankLines": true, "skipComments": true}]*/
import mongoose from 'mongoose'; import mongoose from 'mongoose';
import supertest from 'supertest'; 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 NotificationModel } from './notifications.model.js';
import { model as HomebrewModel } from './homebrew.model.js'; import { model as HomebrewModel } from './homebrew.model.js';
let app;
// Mimic https responses to avoid being redirected all the time let request;
const app = supertest.agent(HBApp).set('X-Forwarded-Proto', 'https');
let dbState; let dbState;
beforeAll(async ()=>{
app = await createApp();
request = supertest.agent(app).set('X-Forwarded-Proto', 'https');
});
describe('Tests for admin api', ()=>{ describe('Tests for admin api', ()=>{
beforeEach(()=>{ beforeEach(()=>{
// Mock DB ready (for dbCheck middleware)
dbState = mongoose.connection.readyState; dbState = mongoose.connection.readyState;
mongoose.connection.readyState = 1; mongoose.connection.readyState = 1;
}); });
afterEach(()=>{ afterEach(()=>{
// Restore DB ready state
mongoose.connection.readyState = dbState; mongoose.connection.readyState = dbState;
jest.resetAllMocks(); jest.resetAllMocks();
}); });
afterAll(async ()=>{
await mongoose.connection.close();
});
describe('Notifications', ()=>{ describe('Notifications', ()=>{
it('should return list of all notifications', async ()=>{ it('should return list of all notifications', async ()=>{
const testNotifications = ['a', 'b']; const testNotifications = ['a', 'b'];
jest.spyOn(NotificationModel, 'find') jest.spyOn(NotificationModel, 'find').mockImplementationOnce(()=>{
.mockImplementationOnce(()=>{
return { exec: jest.fn().mockResolvedValue(testNotifications) }; return { exec: jest.fn().mockResolvedValue(testNotifications) };
}); });
const response = await app const response = await request
.get('/admin/notification/all') .get('/admin/notification/all')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`); .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual(testNotifications); expect(response.body).toEqual(testNotifications);
@@ -56,18 +59,17 @@ describe('Tests for admin api', ()=>{
_id : expect.any(String), _id : expect.any(String),
createdAt : expect.any(String), createdAt : expect.any(String),
startAt : inputNotification.startAt, startAt : inputNotification.startAt,
stopAt : inputNotification.stopAt, stopAt : inputNotification.stopAt
}; };
jest.spyOn(NotificationModel.prototype, 'save') jest.spyOn(NotificationModel.prototype, 'save').mockImplementationOnce(function () {
.mockImplementationOnce(function() { return Promise.resolve(this);
return Promise.resolve(this); });
});
const response = await app const response = await request
.post('/admin/notification/add') .post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(inputNotification); .send(inputNotification);
expect(response.status).toBe(201); expect(response.status).toBe(201);
expect(response.body).toEqual(savedNotification); expect(response.body).toEqual(savedNotification);
@@ -81,16 +83,14 @@ describe('Tests for admin api', ()=>{
stopAt : new Date().toISOString() 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') return Promise.resolve(this);
.mockImplementationOnce(function() { });
return Promise.resolve(this);
});
const response = await app const response = await request
.post('/admin/notification/add') .post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(inputNotification); .send(inputNotification);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Dismiss key is required!' }); expect(response.body).toEqual({ message: 'Dismiss key is required!' });
@@ -99,15 +99,15 @@ describe('Tests for admin api', ()=>{
it('should delete a notification based on its dismiss key', async ()=>{ it('should delete a notification based on its dismiss key', async ()=>{
const dismissKey = 'testKey'; const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete') jest.spyOn(NotificationModel, 'findOneAndDelete').mockImplementationOnce((key)=>{
.mockImplementationOnce((key)=>{ return { exec: jest.fn().mockResolvedValue(key) };
return { exec: jest.fn().mockResolvedValue(key) }; });
});
const response = await app
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ 'dismissKey': 'testKey' }); 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(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual({ dismissKey: 'testKey' }); expect(response.body).toEqual({ dismissKey: 'testKey' });
}); });
@@ -115,15 +115,15 @@ describe('Tests for admin api', ()=>{
it('should handle error deleting a notification that doesnt exist', async ()=>{ it('should handle error deleting a notification that doesnt exist', async ()=>{
const dismissKey = 'testKey'; const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete') jest.spyOn(NotificationModel, 'findOneAndDelete').mockImplementationOnce(()=>{
.mockImplementationOnce(()=>{ return { exec: jest.fn().mockResolvedValue() };
return { exec: jest.fn().mockResolvedValue() }; });
});
const response = await app
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ 'dismissKey': 'testKey' }); 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(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Notification not found' }); expect(response.body).toEqual({ message: 'Notification not found' });
}); });
@@ -132,30 +132,24 @@ describe('Tests for admin api', ()=>{
describe('Locks', ()=>{ describe('Locks', ()=>{
describe('Count', ()=>{ describe('Count', ()=>{
it('Count of all locked documents', async ()=>{ it('Count of all locked documents', async ()=>{
const testNumber = 16777216; // 8^8, because why not const testNumber = 16777216;
jest.spyOn(HomebrewModel, 'countDocuments') jest.spyOn(HomebrewModel, 'countDocuments').mockImplementationOnce(()=>Promise.resolve(testNumber));
.mockImplementationOnce(()=>{
return Promise.resolve(testNumber);
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .get('/api/lock/count')
.get('/api/lock/count'); .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual({ count: testNumber }); expect(response.body).toEqual({ count: testNumber });
}); });
it('Handle error while fetching count of locked documents', async ()=>{ it('Handle error while fetching count of locked documents', async ()=>{
jest.spyOn(HomebrewModel, 'countDocuments') jest.spyOn(HomebrewModel, 'countDocuments').mockImplementationOnce(()=>Promise.reject());
.mockImplementationOnce(()=>{
return Promise.reject();
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .get('/api/lock/count')
.get('/api/lock/count'); .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -163,7 +157,7 @@ describe('Tests for admin api', ()=>{
message : 'Unable to get lock count', message : 'Unable to get lock count',
name : 'Lock Count Error', name : 'Lock Count Error',
originalUrl : '/api/lock/count', originalUrl : '/api/lock/count',
status : 500, status : 500
}); });
}); });
}); });
@@ -172,28 +166,22 @@ describe('Tests for admin api', ()=>{
it('Get list of all locked documents', async ()=>{ it('Get list of all locked documents', async ()=>{
const testLocks = ['a', 'b']; const testLocks = ['a', 'b'];
jest.spyOn(HomebrewModel, 'aggregate') jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.resolve(testLocks));
.mockImplementationOnce(()=>{
return Promise.resolve(testLocks);
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .get('/api/locks')
.get('/api/locks'); .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual({ lockedDocuments: testLocks }); expect(response.body).toEqual({ lockedDocuments: testLocks });
}); });
it('Handle error while fetching list of all locked documents', async ()=>{ it('Handle error while fetching list of all locked documents', async ()=>{
jest.spyOn(HomebrewModel, 'aggregate') jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.reject());
.mockImplementationOnce(()=>{
return Promise.reject();
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .get('/api/locks')
.get('/api/locks'); .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -208,28 +196,22 @@ describe('Tests for admin api', ()=>{
it('Get list of all locked documents with pending review requests', async ()=>{ it('Get list of all locked documents with pending review requests', async ()=>{
const testLocks = ['a', 'b']; const testLocks = ['a', 'b'];
jest.spyOn(HomebrewModel, 'aggregate') jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.resolve(testLocks));
.mockImplementationOnce(()=>{
return Promise.resolve(testLocks);
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .get('/api/lock/reviews')
.get('/api/lock/reviews'); .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual({ reviewDocuments: testLocks }); expect(response.body).toEqual({ reviewDocuments: testLocks });
}); });
it('Handle error while fetching list of all locked documents with pending review requests', async ()=>{ it('Handle error while fetching list of all locked documents with pending review requests', async ()=>{
jest.spyOn(HomebrewModel, 'aggregate') jest.spyOn(HomebrewModel, 'aggregate').mockImplementationOnce(()=>Promise.reject());
.mockImplementationOnce(()=>{
return Promise.reject();
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .get('/api/lock/reviews')
.get('/api/lock/reviews'); .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -247,8 +229,8 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.resolve(); } save : ()=>Promise.resolve()
}; };
const testLock = { const testLock = {
@@ -257,15 +239,12 @@ describe('Tests for admin api', ()=>{
shareMessage : 'share' shareMessage : 'share'
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .post(`/api/lock/${testBrew.shareId}`)
.post(`/api/lock/${testBrew.shareId}`) .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock); .send(testLock);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toMatchObject({ expect(response.body).toMatchObject({
@@ -289,24 +268,21 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.resolve(); }, save : ()=>Promise.resolve(),
lock : { lock : {
code : 1, code : 1,
editMessage : 'oldEdit', editMessage : 'oldEdit',
shareMessage : 'oldShare', shareMessage : 'oldShare'
} }
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .post(`/api/lock/${testBrew.shareId}`)
.post(`/api/lock/${testBrew.shareId}`) .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock); .send(testLock);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toMatchObject({ expect(response.body).toMatchObject({
@@ -329,24 +305,21 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.resolve(); }, save : ()=>Promise.resolve(),
lock : { lock : {
code : 1, code : 1,
editMessage : 'oldEdit', editMessage : 'oldEdit',
shareMessage : 'oldShare', shareMessage : 'oldShare'
} }
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .post(`/api/lock/${testBrew.shareId}`)
.post(`/api/lock/${testBrew.shareId}`) .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock); .send(testLock);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -364,8 +337,8 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.reject(); } save : ()=>Promise.reject()
}; };
const testLock = { const testLock = {
@@ -374,15 +347,12 @@ describe('Tests for admin api', ()=>{
shareMessage : 'share' shareMessage : 'share'
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .post(`/api/lock/${testBrew.shareId}`)
.post(`/api/lock/${testBrew.shareId}`) .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.send(testLock); .send(testLock);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -408,19 +378,17 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.resolve(); }, save : ()=>Promise.resolve(),
lock : testLock lock : testLock
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request.put(`/api/unlock/${testBrew.shareId}`).set(
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) 'Authorization',
.put(`/api/unlock/${testBrew.shareId}`); `Basic ${Buffer.from('admin:password3').toString('base64')}`
);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -433,18 +401,16 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.resolve(); }, save : ()=>Promise.resolve()
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request.put(`/api/unlock/${testBrew.shareId}`).set(
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) 'Authorization',
.put(`/api/unlock/${testBrew.shareId}`); `Basic ${Buffer.from('admin:password3').toString('base64')}`
);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -453,7 +419,7 @@ describe('Tests for admin api', ()=>{
name : 'Not Locked', name : 'Not Locked',
originalUrl : `/api/unlock/${testBrew.shareId}`, originalUrl : `/api/unlock/${testBrew.shareId}`,
shareId : testBrew.shareId, shareId : testBrew.shareId,
status : 500, status : 500
}); });
}); });
@@ -468,19 +434,17 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.reject(); }, save : ()=>Promise.reject(),
lock : testLock lock : testLock
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request.put(`/api/unlock/${testBrew.shareId}`).set(
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) 'Authorization',
.put(`/api/unlock/${testBrew.shareId}`); `Basic ${Buffer.from('admin:password3').toString('base64')}`
);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -506,40 +470,28 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.resolve(); }, save : ()=>Promise.resolve(),
lock : testLock lock : testLock
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request.put(`/api/lock/review/request/${testBrew.shareId}`);
.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
message : `Review requested on brew ID ${testBrew.shareId} - ${testBrew.title}`, 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 ()=>{ it('Error when cannot find a locked brew', async ()=>{
const testBrew = { const testBrew = { shareId: 'shareId' };
shareId : 'shareId'
};
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(false));
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
const response = await request.put(`/api/lock/review/request/${testBrew.shareId}`);
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`)
.catch((err)=>{return err;});
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -569,25 +521,20 @@ describe('Tests for admin api', ()=>{
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{ .mockImplementationOnce(()=>Promise.resolve(testBrew));
return Promise.resolve(false);
});
const response = await request
const response = await app .put(`/api/lock/review/request/${testBrew.shareId}`);
.put(`/api/lock/review/request/${testBrew.shareId}`)
.catch((err)=>{return err;});
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
HBErrorCode : '70', HBErrorCode : '71',
code : 500, code : 500,
message : `Cannot find a locked brew with ID ${testBrew.shareId}`, message : `Review already requested for brew ${testBrew.shareId} - ${testBrew.title}`,
name : 'Brew Not Found', name : 'Review Already Requested',
originalUrl : `/api/lock/review/request/${testBrew.shareId}` originalUrl : `/api/lock/review/request/${testBrew.shareId}`
}); });
}); });
it('Handle error while adding review request to a locked brew', async ()=>{ it('Handle error while adding review request to a locked brew', async ()=>{
const testLock = { const testLock = {
applied : 'YES', applied : 'YES',
@@ -599,18 +546,14 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.reject(); }, save : ()=>Promise.reject(),
lock : testLock lock : testLock
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request.put(`/api/lock/review/request/${testBrew.shareId}`);
.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -634,19 +577,16 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.resolve(); }, save : ()=>Promise.resolve(),
lock : testLock lock : testLock
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .put(`/api/lock/review/remove/${testBrew.shareId}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`); .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -656,18 +596,13 @@ describe('Tests for admin api', ()=>{
}); });
it('Error when clearing review request from a brew with no review request', async ()=>{ it('Error when clearing review request from a brew with no review request', async ()=>{
const testBrew = { const testBrew = { shareId: 'shareId' };
shareId : 'shareId',
};
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(false));
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .put(`/api/lock/review/remove/${testBrew.shareId}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`); .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
@@ -690,19 +625,16 @@ describe('Tests for admin api', ()=>{
const testBrew = { const testBrew = {
shareId : 'shareId', shareId : 'shareId',
title : 'title', title : 'title',
markModified : ()=>{ return true; }, markModified : ()=>true,
save : ()=>{ return Promise.reject(); }, save : ()=>Promise.reject(),
lock : testLock lock : testLock
}; };
jest.spyOn(HomebrewModel, 'findOne') jest.spyOn(HomebrewModel, 'findOne').mockImplementationOnce(()=>Promise.resolve(testBrew));
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app const response = await request
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .put(`/api/lock/review/remove/${testBrew.shareId}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`); .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body).toEqual({ expect(response.body).toEqual({
+565 -537
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -14,7 +14,6 @@ const DEFAULT_BREW = {
theme : '5ePHB', theme : '5ePHB',
authors : [], authors : [],
tags : [], tags : [],
systems : [],
lang : 'en', lang : 'en',
thumbnail : '', thumbnail : '',
views : 0, views : 0,
-2
View File
@@ -151,7 +151,6 @@ const GoogleActions = {
description : file.description, description : file.description,
views : parseInt(file.properties.views), views : parseInt(file.properties.views),
published : file.properties.published ? file.properties.published == 'true' : false, published : file.properties.published ? file.properties.published == 'true' : false,
systems : [],
lang : file.properties.lang, lang : file.properties.lang,
thumbnail : file.properties.thumbnail, thumbnail : file.properties.thumbnail,
webViewLink : file.webViewLink webViewLink : file.webViewLink
@@ -298,7 +297,6 @@ const GoogleActions = {
text : file.data, text : file.data,
description : obj.data.description, description : obj.data.description,
systems : obj.data.properties.systems ? obj.data.properties.systems.split(',') : [],
authors : [], authors : [],
lang : obj.data.properties.lang, lang : obj.data.properties.lang,
published : obj.data.properties.published ? obj.data.properties.published == 'true' : false, published : obj.data.properties.published ? obj.data.properties.published == 'true' : false,
+30 -3
View File
@@ -31,6 +31,27 @@ const isStaticTheme = (renderer, themeName)=>{
// }); // });
// }; // };
const migrateSystemsToTags = (brew) => {
if (!('systems' in brew)) return brew;
if (!Array.isArray(brew.systems) || brew.systems.length === 0) {
brew.systems = undefined;
return brew;
}
const systemMap = {
'5e': 'system:D&D 5e',
'4e': 'system:D&D 4e',
'3.5e': 'system:D&D 3.5e',
'Pathfinder': 'system:Pathfinder 2e'
};
const systemTags = brew.systems.map(s => systemMap[s]);
brew.tags = _.uniq([...(brew.tags || []), ...systemTags]);
brew.systems = undefined;
return brew;
};
const MAX_TITLE_LENGTH = 100; const MAX_TITLE_LENGTH = 100;
const api = { const api = {
@@ -167,7 +188,10 @@ const api = {
stub.renderer = stub.renderer || undefined; // Clear empty strings stub.renderer = stub.renderer || undefined; // Clear empty strings
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
req.brew = stub;
const fixedStub = migrateSystemsToTags(stub);
req.brew = fixedStub;
next(); next();
}; };
}, },
@@ -193,7 +217,7 @@ const api = {
`\`\`\`\n\n` + `\`\`\`\n\n` +
`${text}`; `${text}`;
} }
const metadata = _.pick(brew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme']); const metadata = _.pick(brew, ['title', 'description', 'tags', 'renderer', 'theme']);
const snippetsArray = brewSnippetsToJSON('brew_snippets', brew.snippets, null, false).snippets; const snippetsArray = brewSnippetsToJSON('brew_snippets', brew.snippets, null, false).snippets;
metadata.snippets = snippetsArray.length > 0 ? snippetsArray : undefined; metadata.snippets = snippetsArray.length > 0 ? snippetsArray : undefined;
text = `\`\`\`metadata\n` + text = `\`\`\`metadata\n` +
@@ -392,6 +416,9 @@ const api = {
} }
let brew = _.assign(brewFromServer, brewFromClient); let brew = _.assign(brewFromServer, brewFromClient);
migrateSystemsToTags(brew);
brew.title = brew.title.trim(); brew.title = brew.title.trim();
brew.description = brew.description.trim() || ''; brew.description = brew.description.trim() || '';
brew.text = api.mergeBrewText(brew); brew.text = api.mergeBrewText(brew);
@@ -481,7 +508,7 @@ const api = {
await HomebrewModel.deleteOne({ editId: id }); await HomebrewModel.deleteOne({ editId: id });
return next(); return next();
} }
throw(err); throw (err);
} }
let brew = req.brew; let brew = req.brew;
-27
View File
@@ -63,7 +63,6 @@ describe('Tests for api', ()=>{
title : 'some title', title : 'some title',
description : 'this is a description', description : 'this is a description',
tags : ['something', 'fun'], tags : ['something', 'fun'],
systems : ['D&D 5e'],
lang : 'en', lang : 'en',
renderer : 'v3', renderer : 'v3',
theme : 'phb', theme : 'phb',
@@ -351,7 +350,6 @@ describe('Tests for api', ()=>{
renderer : 'legacy', renderer : 'legacy',
lang : 'en', lang : 'en',
shareId : undefined, shareId : undefined,
systems : [],
tags : [], tags : [],
theme : '5ePHB', theme : '5ePHB',
thumbnail : '', thumbnail : '',
@@ -390,7 +388,6 @@ describe('Tests for api', ()=>{
title : 'some title', title : 'some title',
description : 'this is a description', description : 'this is a description',
tags : ['something', 'fun'], tags : ['something', 'fun'],
systems : ['D&D 5e'],
renderer : 'v3', renderer : 'v3',
theme : 'phb', theme : 'phb',
googleId : '12345' googleId : '12345'
@@ -402,8 +399,6 @@ description: this is a description
tags: tags:
- something - something
- fun - fun
systems:
- D&D 5e
renderer: v3 renderer: v3
theme: phb theme: phb
@@ -419,7 +414,6 @@ brew`);
title : 'some title', title : 'some title',
description : 'this is a description', description : 'this is a description',
tags : ['something', 'fun'], tags : ['something', 'fun'],
systems : ['D&D 5e'],
renderer : 'v3', renderer : 'v3',
theme : 'phb', theme : 'phb',
googleId : '12345' googleId : '12345'
@@ -431,8 +425,6 @@ description: this is a description
tags: tags:
- something - something
- fun - fun
systems:
- D&D 5e
renderer: v3 renderer: v3
theme: phb theme: phb
@@ -463,7 +455,6 @@ brew`);
expect(sent).toEqual(googleBrew); expect(sent).toEqual(googleBrew);
expect(result.tags).toBeUndefined(); expect(result.tags).toBeUndefined();
expect(result.systems).toBeUndefined();
expect(result.published).toBeUndefined(); expect(result.published).toBeUndefined();
expect(result.authors).toBeUndefined(); expect(result.authors).toBeUndefined();
expect(result.owner).toBeUndefined(); expect(result.owner).toBeUndefined();
@@ -558,7 +549,6 @@ brew`);
lang : 'en', lang : 'en',
shareId : expect.any(String), shareId : expect.any(String),
style : undefined, style : undefined,
systems : [],
tags : [], tags : [],
text : undefined, text : undefined,
textBin : expect.objectContaining({}), textBin : expect.objectContaining({}),
@@ -618,7 +608,6 @@ brew`);
shareId : expect.any(String), shareId : expect.any(String),
googleId : expect.any(String), googleId : expect.any(String),
style : undefined, style : undefined,
systems : [],
tags : [], tags : [],
text : undefined, text : undefined,
textBin : undefined, textBin : undefined,
@@ -1076,7 +1065,6 @@ brew`);
'title: title\n' + 'title: title\n' +
'description: description\n' + 'description: description\n' +
'tags: [ \'tag a\' , \'tag b\' ]\n' + 'tags: [ \'tag a\' , \'tag b\' ]\n' +
'systems: [ test system ]\n' +
'renderer: legacy\n' + 'renderer: legacy\n' +
'theme: 5ePHB\n' + 'theme: 5ePHB\n' +
'lang: en\n' + 'lang: en\n' +
@@ -1097,8 +1085,6 @@ brew`);
// Metadata // Metadata
expect(testBrew.title).toEqual('title'); expect(testBrew.title).toEqual('title');
expect(testBrew.description).toEqual('description'); expect(testBrew.description).toEqual('description');
expect(testBrew.tags).toEqual(['tag a', 'tag b']);
expect(testBrew.systems).toEqual(['test system']);
expect(testBrew.renderer).toEqual('legacy'); expect(testBrew.renderer).toEqual('legacy');
expect(testBrew.theme).toEqual('5ePHB'); expect(testBrew.theme).toEqual('5ePHB');
expect(testBrew.lang).toEqual('en'); expect(testBrew.lang).toEqual('en');
@@ -1107,19 +1093,6 @@ brew`);
// Text // Text
expect(testBrew.text).toEqual('text\n'); expect(testBrew.text).toEqual('text\n');
}); });
it('convert tags string to array', async ()=>{
const testBrew = {
text : '```metadata\n' +
'tags: tag a\n' +
'```\n\n'
};
splitTextStyleAndMetadata(testBrew);
// Metadata
expect(testBrew.tags).toEqual(['tag a']);
});
}); });
describe('updateBrew', ()=>{ describe('updateBrew', ()=>{
+1 -1
View File
@@ -15,7 +15,7 @@ const HomebrewSchema = mongoose.Schema({
description : { type: String, default: '' }, description : { type: String, default: '' },
tags : { type: [String], index: true }, tags : { type: [String], index: true },
systems : [String], systems : { type: [String], default: undefined },
lang : { type: String, default: 'en', index: true }, lang : { type: String, default: 'en', index: true },
renderer : { type: String, default: '', index: true }, renderer : { type: String, default: '', index: true },
authors : { type: [String], index: true }, authors : { type: [String], index: true },
+3 -3
View File
@@ -27,7 +27,7 @@ const brewSnippetsToJSON = (menuTitle, userBrewSnippets, themeBundleSnippets=nul
userSnippets.push({ userSnippets.push({
name : snippetName, name : snippetName,
icon : '', icon : '',
gen : snipSplit[snips + 1], gen : snipSplit[snips + 1].replace(/\n$/, ''),
}); });
} }
} }
@@ -52,7 +52,7 @@ const brewSnippetsToJSON = (menuTitle, userBrewSnippets, themeBundleSnippets=nul
if(snippetName.length != 0) { if(snippetName.length != 0) {
const subSnip = { const subSnip = {
name : snippetName, name : snippetName,
gen : snipSplit[snips + 1], gen : snipSplit[snips + 1].replace(/\n$/, ''),
}; };
// if(full) subSnip.icon = ''; // if(full) subSnip.icon = '';
userSnippets.push(subSnip); userSnippets.push(subSnip);
@@ -99,7 +99,7 @@ const splitTextStyleAndMetadata = (brew)=>{
const index = brew.text.indexOf('\n```\n\n'); const index = brew.text.indexOf('\n```\n\n');
const metadataSection = brew.text.slice(11, index + 1); const metadataSection = brew.text.slice(11, index + 1);
const metadata = yaml.load(metadataSection); const metadata = yaml.load(metadataSection);
Object.assign(brew, _.pick(metadata, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])); Object.assign(brew, _.pick(metadata, ['title', 'description', 'renderer', 'theme', 'lang']));
brew.snippets = yamlSnippetsToText(_.pick(metadata, ['snippets']).snippets || ''); brew.snippets = yamlSnippetsToText(_.pick(metadata, ['snippets']).snippets || '');
brew.text = brew.text.slice(index + 6); brew.text = brew.text.slice(index + 6);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
/* eslint-disable max-depth */
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import _ from 'lodash'; import _ from 'lodash';
import { marked as Marked } from 'marked'; import { marked as Marked } from 'marked';
+7 -7
View File
@@ -1,16 +1,16 @@
@import 'naturalcrit/styles/reset.less'; @import './reset.less';
//@import 'naturalcrit/styles/elements.less'; //@import './elements.less';
@import 'naturalcrit/styles/animations.less'; @import './animations.less';
@import 'naturalcrit/styles/colors.less'; @import './colors.less';
@import 'naturalcrit/styles/tooltip.less'; @import './tooltip.less';
@font-face { @font-face {
font-family : 'CodeLight'; font-family : 'CodeLight';
src : data-uri('naturalcrit/styles/CODE Light.otf') format('opentype'); src : url('./CODE Light.otf') format('opentype');
} }
@font-face { @font-face {
font-family : 'CodeBold'; font-family : 'CodeBold';
src : data-uri('naturalcrit/styles/CODE Bold.otf') format('opentype'); src : url('./CODE Bold.otf') format('opentype');
} }
html,body, #reactRoot { html,body, #reactRoot {
height : 100vh; height : 100vh;
+1 -1
View File
@@ -1,6 +1,6 @@
import globalJsdom from 'jsdom-global'; import globalJsdom from 'jsdom-global';
globalJsdom(); globalJsdom();
import { safeHTML } from '../../client/homebrew/brewRenderer/safeHTML'; import safeHTML from '../../client/homebrew/brewRenderer/safeHTML';
test('Exit if no document', function() { test('Exit if no document', function() {
const doc = document; const doc = document;
+1 -1
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() { test('Processes the markdown within an HTML block if its just a class wrapper', function() {
const source = '<div>*Bold text*</div>'; const source = '<div>*Bold text*</div>';
+1 -1
View File
@@ -1,6 +1,6 @@
import Markdown from 'markdown.js'; import Markdown from '../../shared/markdown.js';
describe('Inline Definition Lists', ()=>{ describe('Inline Definition Lists', ()=>{
test('No Term 1 Definition', function() { test('No Term 1 Definition', function() {
+1 -1
View File
@@ -1,4 +1,4 @@
import Markdown from 'markdown.js'; import Markdown from '../../shared/markdown.js';
import dedent from 'dedent'; import dedent from 'dedent';
// Marked.js adds line returns after closing tags on some default tokens. // Marked.js adds line returns after closing tags on some default tokens.
+1 -1
View File
@@ -1,6 +1,6 @@
import Markdown from 'markdown.js'; import Markdown from '../../shared/markdown.js';
describe('Hard Breaks', ()=>{ describe('Hard Breaks', ()=>{
test('Single Break', function() { test('Single Break', function() {
+1 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import dedent from 'dedent'; import dedent from 'dedent';
import Markdown from 'markdown.js'; import Markdown from '../../shared/markdown.js';
// Marked.js adds line returns after closing tags on some default tokens. // Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake. // This removes those line returns for comparison sake.
+1 -1
View File
@@ -1,6 +1,6 @@
import Markdown from 'markdown.js'; import Markdown from '../../shared/markdown.js';
describe('Non-Breaking Spaces Interactions', ()=>{ describe('Non-Breaking Spaces Interactions', ()=>{
test('I am actually a single-line definition list!', function() { test('I am actually a single-line definition list!', function() {
@@ -1,6 +1,6 @@
import Markdown from 'markdown.js'; import Markdown from '../../shared/markdown.js';
describe('Justification', ()=>{ describe('Justification', ()=>{
test('Left Justify', function() { test('Left Justify', function() {
+1 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import dedent from 'dedent'; import dedent from 'dedent';
import Markdown from 'markdown.js'; import Markdown from '../../shared/markdown.js';
// Marked.js adds line returns after closing tags on some default tokens. // Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake. // This removes those line returns for comparison sake.
+18 -13
View File
@@ -1,27 +1,32 @@
import supertest from 'supertest'; 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 let app;
const app = supertest.agent(HBApp).set('X-Forwarded-Proto', 'https'); let request;
beforeAll(async ()=>{
app = await createApp();
request = supertest.agent(app).set('X-Forwarded-Proto', 'https');
});
describe('Tests for static pages', ()=>{ describe('Tests for static pages', ()=>{
it('Home page works', ()=>{ it('Home page works', async ()=>{
return app.get('/').expect(200); await request.get('/').expect(200);
}); });
it('Home page legacy works', ()=>{ it('Home page legacy works', async ()=>{
return app.get('/legacy').expect(200); await request.get('/legacy').expect(200);
}); });
it('Changelog page works', ()=>{ it('Changelog page works', async ()=>{
return app.get('/changelog').expect(200); await request.get('/changelog').expect(200);
}); });
it('FAQ page works', ()=>{ it('FAQ page works', async ()=>{
return app.get('/faq').expect(200); await request.get('/faq').expect(200);
}); });
it('robots.txt works', ()=>{ it('robots.txt works', async ()=>{
return app.get('/robots.txt').expect(200); await request.get('/robots.txt').expect(200);
}); });
}); });
+2 -2
View File
@@ -1,12 +1,12 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import MagicGen from './snippets/magic.gen.js'; import MagicGen from './snippets/magic.gen.js';
import ClassTableGen from './snippets/classtable.gen.js'; import ClassTableGen from './snippets/classtable.gen.js';
import MonsterBlockGen from './snippets/monsterblock.gen.js'; import MonsterBlockGen from './snippets/monsterblock.gen.js';
import ClassFeatureGen from './snippets/classfeature.gen.js'; import ClassFeatureGen from './snippets/classfeature.gen.js';
import CoverPageGen from './snippets/coverpage.gen.js'; import CoverPageGen from './snippets/coverpage.gen.js';
import TableOfContentsGen from './snippets/tableOfContents.gen.js'; import TableOfContentsGen from './snippets/tableOfContents.gen.js';
import dedent from 'dedent'; import dedent from 'dedent';
export default [ export default [
@@ -1,6 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
export default function(classname){ function classFeatureGen(classname) {
classname = _.sample(['archivist', 'fancyman', 'linguist', 'fletcher', classname = _.sample(['archivist', 'fancyman', 'linguist', 'fletcher',
'notary', 'berserker-typist', 'fishmongerer', 'manicurist', 'haberdasher', 'concierge']); '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'])}`, `- ${_.sample(['10 lint fluffs', '1 button', 'a cherished lost sock'])}`,
'\n\n\n' '\n\n\n'
].join('\n'); ].join('\n');
}; }
export default classFeatureGen;
@@ -98,7 +98,7 @@ const subtitles = [
]; ];
export default ()=>{ function coverPageGen() {
return `<style> return `<style>
.phb#p1{ text-align:center; } .phb#p1{ text-align:center; }
.phb#p1:after{ display:none; } .phb#p1:after{ display:none; }
@@ -114,4 +114,6 @@ export default ()=>{
</div> </div>
\\page`; \\page`;
}; }
export default coverPageGen;
@@ -4,7 +4,7 @@ import ClassFeatureGen from './classfeature.gen.js';
import ClassTableGen from './classtable.gen.js'; import ClassTableGen from './classtable.gen.js';
export default function(){ function fullClassGen(){
const classname = _.sample(['Archivist', 'Fancyman', 'Linguist', 'Fletcher', const classname = _.sample(['Archivist', 'Fancyman', 'Linguist', 'Fletcher',
'Notary', 'Berserker-Typist', 'Fishmongerer', 'Manicurist', 'Haberdasher', 'Concierge']); 'Notary', 'Berserker-Typist', 'Fishmongerer', 'Manicurist', 'Haberdasher', 'Concierge']);
@@ -40,4 +40,6 @@ export default function(){
].join('\n')}\n\n\n`; ].join('\n')}\n\n\n`;
}; }
export default fullClassGen;
@@ -47,7 +47,8 @@ const getTOC = (pages)=>{
return res; return res;
}; };
export default function(props){ function tableOfContentsGen(props){
const pages = props.brew.text.split('\\page'); const pages = props.brew.text.split('\\page');
const TOC = getTOC(pages); const TOC = getTOC(pages);
const markdown = _.reduce(TOC, (r, g1, idx1)=>{ const markdown = _.reduce(TOC, (r, g1, idx1)=>{
@@ -69,4 +70,6 @@ export default function(props){
##### Table Of Contents ##### Table Of Contents
${markdown} ${markdown}
</div>\n`; </div>\n`;
}; }
export default tableOfContentsGen;
+1 -1
View File
@@ -1,4 +1,4 @@
import Markdown from '../../../../shared/markdown.js'; import Markdown from '@shared/markdown.js';
export default { export default {
createFooterFunc : function(headerSize=1){ createFooterFunc : function(headerSize=1){
File diff suppressed because one or more lines are too long
@@ -179,10 +179,10 @@ export default {
`; `;
}, },
// Verify Logo redistribution // Verify Logo redistribution
monteCookLogoDarkLarge : `![Cypher System Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/CSCDarkLarge.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)`, 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)`, 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)`, monteCookLogoLightSmall : `![Cypher System Compatible](https://homebrewery.naturalcrit.com/assets/license_logos/CSCLightSmall.png)`,
// Onyx Path Canis Minor - Verify logos and access // Onyx Path Canis Minor - Verify logos and access
onyxPathCanisMinorColophon : function () { onyxPathCanisMinorColophon : function () {
return dedent` return dedent`
@@ -1,6 +1,3 @@
import dedent from 'dedent';
// Mongoose Publishing Licenses // Mongoose Publishing Licenses
export default { export default {
+38
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 : ['.'],
},
},
});
+79
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
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,
};