this.handleTheme(theme)} title={''}>
- {`${theme.renderer} : ${theme.name}`}
-

+ return _.map(_.values(mergedThemes[renderer]), (theme)=>{
+ const preview = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownPreview.png`;
+ const texture = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownTexture.png`;
+ return
this.handleTheme(theme)} title={''}>
+ {theme.author ?? renderer} : {theme.name}
+
+

+
-
{`${theme.name}`} preview
-

+
{theme.name} preview
+
;
});
};
- const currentTheme = Themes[`${_.upperFirst(this.props.metadata.renderer)}`][this.props.metadata.theme];
+ const currentRenderer = this.props.metadata.renderer;
+ const currentTheme = mergedThemes[`${_.upperFirst(this.props.metadata.renderer)}`][this.props.metadata.theme]
+ ?? { name: `!!! THEME MISSING !!! ID=${this.props.metadata.theme}` };
let dropdown;
- if(this.props.metadata.renderer == 'legacy') {
+ if(currentRenderer == 'legacy') {
dropdown =
-
- {`Themes are not supported in the Legacy Renderer`}
-
+ {`Themes are not supported in the Legacy Renderer`}
;
} else {
dropdown =
-
- {`${_.upperFirst(currentTheme.renderer)} : ${currentTheme.name}`}
-
- {/*listThemes('Legacy')*/}
- {listThemes('V3')}
+ {currentTheme.author ?? _.upperFirst(currentRenderer)} : {currentTheme.name}
+
+ {listThemes(currentRenderer)}
;
}
diff --git a/client/homebrew/editor/metadataEditor/metadataEditor.less b/client/homebrew/editor/metadataEditor/metadataEditor.less
index 7f7ce3060..5d1d8ae9f 100644
--- a/client/homebrew/editor/metadataEditor/metadataEditor.less
+++ b/client/homebrew/editor/metadataEditor/metadataEditor.less
@@ -191,6 +191,13 @@
color : white;
}
}
+ .navDropdown .item > p {
+ width: 45%;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ height: 1.1em;
+ }
.navDropdown {
box-shadow : 0px 5px 10px rgba(0, 0, 0, 0.3);
position : absolute;
@@ -230,14 +237,23 @@
&:hover > .preview {
opacity: 1;
}
- >img {
- mask-image : linear-gradient(90deg, transparent, black 20%);
- -webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
- position : absolute;
- right : 0;
- top : 0px;
- width : 50%;
- height : 100%;
+ .texture-container {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ min-height: 100%;
+ top: 0;
+ left: 0;
+ overflow: hidden;
+ > img {
+ mask-image : linear-gradient(90deg, transparent, black 20%);
+ -webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
+ position : absolute;
+ right : 0;
+ top : 0px;
+ width : 50%;
+ min-height : 100%;
+ }
}
}
}
diff --git a/client/homebrew/editor/snippetbar/snippetbar.jsx b/client/homebrew/editor/snippetbar/snippetbar.jsx
index 80a97f49e..af493c961 100644
--- a/client/homebrew/editor/snippetbar/snippetbar.jsx
+++ b/client/homebrew/editor/snippetbar/snippetbar.jsx
@@ -6,9 +6,6 @@ const _ = require('lodash');
const cx = require('classnames');
//Import all themes
-
-const Themes = require('themes/themes.json');
-
const ThemeSnippets = {};
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
ThemeSnippets['V3_5ePHB'] = require('themes/V3/5ePHB/snippets.js');
@@ -40,7 +37,8 @@ const Snippetbar = createClass({
foldCode : ()=>{},
unfoldCode : ()=>{},
updateEditorTheme : ()=>{},
- cursorPos : {}
+ cursorPos : {},
+ snippetBundle : []
};
},
@@ -53,21 +51,15 @@ const Snippetbar = createClass({
},
componentDidMount : async function() {
- const rendererPath = this.props.renderer == 'V3' ? 'V3' : 'Legacy';
- const themePath = this.props.theme ?? '5ePHB';
- let snippets = _.cloneDeep(ThemeSnippets[`${rendererPath}_${themePath}`]);
- snippets = this.compileSnippets(rendererPath, themePath, snippets);
+ const snippets = this.compileSnippets();
this.setState({
snippets : snippets
});
},
componentDidUpdate : async function(prevProps) {
- if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme) {
- const rendererPath = this.props.renderer == 'V3' ? 'V3' : 'Legacy';
- const themePath = this.props.theme ?? '5ePHB';
- let snippets = _.cloneDeep(ThemeSnippets[`${rendererPath}_${themePath}`]);
- snippets = this.compileSnippets(rendererPath, themePath, snippets);
+ if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
+ const snippets = this.compileSnippets();
this.setState({
snippets : snippets
});
@@ -75,26 +67,26 @@ const Snippetbar = createClass({
},
- mergeCustomizer : function(valueA, valueB, key) {
+ mergeCustomizer : function(oldValue, newValue, key) {
if(key == 'snippets') {
- const result = _.reverse(_.unionBy(_.reverse(valueB), _.reverse(valueA), 'name')); // Join snippets together, with preference for the current theme over the base theme
+ const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme
return _.filter(result, 'gen'); //Only keep snippets with a 'gen' property.
}
},
- compileSnippets : function(rendererPath, themePath, snippets) {
- let compiledSnippets = snippets;
- const baseSnippetsPath = Themes[rendererPath][themePath].baseSnippets;
+ compileSnippets : function() {
+ let compiledSnippets = [];
- const objB = _.keyBy(compiledSnippets, 'groupName');
+ let oldSnippets = _.keyBy(compiledSnippets, 'groupName');
- if(baseSnippetsPath) {
- const objA = _.keyBy(_.cloneDeep(ThemeSnippets[`${rendererPath}_${baseSnippetsPath}`]), 'groupName');
- compiledSnippets = _.values(_.mergeWith(objA, objB, this.mergeCustomizer));
- compiledSnippets = this.compileSnippets(rendererPath, baseSnippetsPath, _.cloneDeep(compiledSnippets));
- } else {
- const objA = _.keyBy(_.cloneDeep(ThemeSnippets[`${rendererPath}_Blank`]), 'groupName');
- compiledSnippets = _.values(_.mergeWith(objA, objB, this.mergeCustomizer));
+ for (let snippets of this.props.snippetBundle) {
+ if(typeof(snippets) == 'string') // load staticThemes as needed; they were sent as just a file name
+ snippets = ThemeSnippets[snippets];
+
+ const newSnippets = _.keyBy(_.cloneDeep(snippets), 'groupName');
+ compiledSnippets = _.values(_.mergeWith(oldSnippets, newSnippets, this.mergeCustomizer));
+
+ oldSnippets = _.keyBy(compiledSnippets, 'groupName');
}
return compiledSnippets;
},
diff --git a/client/homebrew/homebrew.jsx b/client/homebrew/homebrew.jsx
index 2489bc1ca..1df417872 100644
--- a/client/homebrew/homebrew.jsx
+++ b/client/homebrew/homebrew.jsx
@@ -66,10 +66,10 @@ const Homebrew = createClass({
- } />
+ } />
} />
- } />
- } />
+ } />
+ } />
} />
} />
} />
diff --git a/client/homebrew/navbar/error-navitem.jsx b/client/homebrew/navbar/error-navitem.jsx
index 59e05a253..5dd5c1eb9 100644
--- a/client/homebrew/navbar/error-navitem.jsx
+++ b/client/homebrew/navbar/error-navitem.jsx
@@ -104,6 +104,18 @@ const ErrorNavItem = createClass({
;
}
+ if(HBErrorCode === '09') {
+ return
+ Oops!
+
+ Looks like there was a problem retreiving
+ the theme, or a theme that it inherits,
+ for this brew. Verify that brew
+ {response.body.brewId} still exists!
+
+ ;
+ }
+
return
Oops!
diff --git a/client/homebrew/navbar/error-navitem.less b/client/homebrew/navbar/error-navitem.less
index 7e7dab772..be138dca4 100644
--- a/client/homebrew/navbar/error-navitem.less
+++ b/client/homebrew/navbar/error-navitem.less
@@ -21,6 +21,9 @@
font-size : 10px;
font-weight : 800;
text-transform : uppercase;
+ .lowercase {
+ text-transform : none;
+ }
a{
color : @teal;
}
diff --git a/client/homebrew/pages/basePages/listPage/brewItem/brewItem.less b/client/homebrew/pages/basePages/listPage/brewItem/brewItem.less
index 9bee4e5eb..a3c17215e 100644
--- a/client/homebrew/pages/basePages/listPage/brewItem/brewItem.less
+++ b/client/homebrew/pages/basePages/listPage/brewItem/brewItem.less
@@ -119,11 +119,12 @@
text-align : center;
a{
.animate(opacity);
- display : block;
- margin : 8px 0px;
- opacity : 0.6;
- font-size : 1.3em;
- color : white;
+ display : block;
+ margin : 8px 0px;
+ opacity : 0.6;
+ font-size : 1.3em;
+ color : white;
+ text-decoration : unset;
&:hover{
opacity : 1;
}
diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx
index 48d0f3fe5..39a6d1931 100644
--- a/client/homebrew/pages/editPage/editPage.jsx
+++ b/client/homebrew/pages/editPage/editPage.jsx
@@ -25,7 +25,7 @@ const LockNotification = require('./lockNotification/lockNotification.jsx');
const Markdown = require('naturalcrit/markdown.js');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
-const { printCurrentBrew } = require('../../../../shared/helpers.js');
+const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const googleDriveIcon = require('../../googleDrive.svg');
@@ -55,7 +55,8 @@ const EditPage = createClass({
autoSaveWarning : false,
unsavedTime : new Date(),
currentEditorPage : 0,
- displayLockMessage : this.props.brew.lock || false
+ displayLockMessage : this.props.brew.lock || false,
+ themeBundle : {}
};
},
@@ -87,6 +88,8 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text)
}));
+ fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
+
document.addEventListener('keydown', this.handleControlKeys);
},
componentWillUnmount : function() {
@@ -130,7 +133,10 @@ const EditPage = createClass({
}), ()=>{if(this.state.autoSave) this.trySave();});
},
- handleMetaChange : function(metadata){
+ handleMetaChange : function(metadata, field=undefined){
+ if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
+ fetchThemeBundle(this, metadata.renderer, metadata.theme);
+
this.setState((prevState)=>({
brew : {
...prevState.brew,
@@ -138,7 +144,6 @@ const EditPage = createClass({
},
isPending : true,
}), ()=>{if(this.state.autoSave) this.trySave();});
-
},
hasChanges : function(){
@@ -406,12 +411,15 @@ const EditPage = createClass({
onMetaChange={this.handleMetaChange}
reportError={this.errorReported}
renderer={this.state.brew.renderer}
+ userThemes={this.props.userThemes}
+ snippetBundle={this.state.themeBundle.snippets}
/>
{
**Brew ID:** ${props.brew.brewId}`,
+ // Theme load error
+ '09' : dedent`
+ ## No Homebrewery theme document could be found.
+
+ The server could not locate the Homebrewery document. It was likely deleted by
+ its owner.
+
+ :
+
+ **Requested access:** ${props.brew.accessType}
+
+ **Brew ID:** ${props.brew.brewId}`,
+
// Brew locked by Administrators error
'100' : dedent`
## This brew has been locked.
diff --git a/client/homebrew/pages/homePage/homePage.jsx b/client/homebrew/pages/homePage/homePage.jsx
index 1aa816df2..bcfd237b4 100644
--- a/client/homebrew/pages/homePage/homePage.jsx
+++ b/client/homebrew/pages/homePage/homePage.jsx
@@ -13,6 +13,7 @@ const HelpNavItem = require('../../navbar/help.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const AccountNavItem = require('../../navbar/account.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
+const { fetchThemeBundle } = require('../../../../shared/helpers.js');
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
@@ -34,12 +35,17 @@ const HomePage = createClass({
brew : this.props.brew,
welcomeText : this.props.brew.text,
error : undefined,
- currentEditorPage : 0
+ currentEditorPage : 0,
+ themeBundle : {}
};
},
editor : React.createRef(null),
+ componentDidMount : function() {
+ fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
+ },
+
handleSave : function(){
request.post('/api')
.send(this.state.brew)
@@ -95,6 +101,7 @@ const HomePage = createClass({
style={this.state.brew.style}
renderer={this.state.brew.renderer}
currentEditorPage={this.state.currentEditorPage}
+ themeBundle={this.state.themeBundle}
/>
diff --git a/client/homebrew/pages/newPage/newPage.jsx b/client/homebrew/pages/newPage/newPage.jsx
index f2525c425..c9160062f 100644
--- a/client/homebrew/pages/newPage/newPage.jsx
+++ b/client/homebrew/pages/newPage/newPage.jsx
@@ -19,7 +19,7 @@ const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
-const { printCurrentBrew } = require('../../../../shared/helpers.js');
+const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style';
@@ -44,7 +44,8 @@ const NewPage = createClass({
saveGoogle : (global.account && global.account.googleId ? true : false),
error : null,
htmlErrors : Markdown.validate(brew.text),
- currentEditorPage : 0
+ currentEditorPage : 0,
+ themeBundle : {}
};
},
@@ -77,6 +78,8 @@ const NewPage = createClass({
saveGoogle : (saveStorage == 'GOOGLE-DRIVE' && this.state.saveGoogle)
});
+ fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
+
localStorage.setItem(BREWKEY, brew.text);
if(brew.style)
localStorage.setItem(STYLEKEY, brew.style);
@@ -122,7 +125,10 @@ const NewPage = createClass({
localStorage.setItem(STYLEKEY, style);
},
- handleMetaChange : function(metadata){
+ handleMetaChange : function(metadata, field=undefined){
+ if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
+ fetchThemeBundle(this, metadata.renderer, metadata.theme);
+
this.setState((prevState)=>({
brew : { ...prevState.brew, ...metadata },
}), ()=>{
@@ -142,8 +148,6 @@ const NewPage = createClass({
isSaving : true
});
- console.log('saving new brew');
-
let brew = this.state.brew;
// Split out CSS to Style if CSS codefence exists
if(brew.text.startsWith('```css') && brew.text.indexOf('```\n\n') > 0) {
@@ -153,12 +157,10 @@ const NewPage = createClass({
}
brew.pageCount=((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
-
const res = await request
.post(`/api${this.state.saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(brew)
.catch((err)=>{
- console.log(err);
this.setState({ isSaving: false, error: err });
});
if(!res) return;
@@ -214,12 +216,14 @@ const NewPage = createClass({
onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange}
renderer={this.state.brew.renderer}
+ userThemes={this.props.userThemes}
/>
diff --git a/config/default.json b/config/default.json
index 70c90593e..12b35e6cf 100644
--- a/config/default.json
+++ b/config/default.json
@@ -4,6 +4,7 @@
"secret" : "secret",
"web_port" : 8000,
"enable_v3" : true,
+ "enable_themes" : true,
"local_environments" : ["docker", "local"],
"publicUrl" : "https://homebrewery.naturalcrit.com"
}
diff --git a/package-lock.json b/package-lock.json
index cce4d5577..64ce3c77c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "homebrewery",
- "version": "3.13.1",
+ "version": "3.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "homebrewery",
- "version": "3.13.1",
+ "version": "3.14.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 321f9afbe..7f07d8164 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown",
- "version": "3.13.1",
+ "version": "3.14.0",
"engines": {
"npm": "^10.2.x",
"node": "^20.8.x"
@@ -22,7 +22,8 @@
"circleci": "npm test && eslint **/*.{js,jsx} --max-warnings=0",
"verify": "npm run lint && npm test",
"test": "jest --runInBand",
- "test:api-unit": "jest server/*.spec.js --verbose",
+ "test:api-unit": "jest \"server/.*.spec.js\" --verbose",
+ "test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose",
"test:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose",
@@ -56,15 +57,15 @@
],
"coverageThreshold": {
"global": {
- "statements": 25,
- "branches": 10,
- "functions": 22,
- "lines": 25
+ "statements": 50,
+ "branches": 40,
+ "functions": 40,
+ "lines": 50
},
"server/homebrew.api.js": {
- "statements": 65,
+ "statements": 70,
"branches": 50,
- "functions": 60,
+ "functions": 65,
"lines": 70
}
},
diff --git a/server/app.js b/server/app.js
index e26c98f54..6863bc7cb 100644
--- a/server/app.js
+++ b/server/app.js
@@ -9,7 +9,7 @@ const yaml = require('js-yaml');
const app = express();
const config = require('./config.js');
-const { homebrewApi, getBrew } = require('./homebrew.api.js');
+const { homebrewApi, getBrew, getUsersBrewThemes } = require('./homebrew.api.js');
const GoogleActions = require('./googleActions.js');
const serveCompressedStaticAssets = require('./static-assets.mv.js');
const sanitizeFilename = require('sanitize-filename');
@@ -81,7 +81,8 @@ app.get('/robots.txt', (req, res)=>{
app.get('/', (req, res, next)=>{
req.brew = {
text : welcomeText,
- renderer : 'V3'
+ renderer : 'V3',
+ theme : '5ePHB'
},
req.ogMeta = { ...defaultMetaTags,
@@ -97,7 +98,8 @@ app.get('/', (req, res, next)=>{
app.get('/legacy', (req, res, next)=>{
req.brew = {
text : welcomeTextLegacy,
- renderer : 'legacy'
+ renderer : 'legacy',
+ theme : '5ePHB'
},
req.ogMeta = { ...defaultMetaTags,
@@ -265,9 +267,11 @@ app.get('/user/:username', async (req, res, next)=>{
});
//Edit Page
-app.get('/edit/:id', asyncHandler(getBrew('edit')), (req, res, next)=>{
+app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
+ req.userThemes = await(getUsersBrewThemes(req.account?.username));
+
req.ogMeta = { ...defaultMetaTags,
title : req.brew.title || 'Untitled Brew',
description : req.brew.description || 'No description.',
@@ -279,10 +283,10 @@ app.get('/edit/:id', asyncHandler(getBrew('edit')), (req, res, next)=>{
splitTextStyleAndMetadata(req.brew);
res.header('Cache-Control', 'no-cache, no-store'); //reload the latest saved brew when pressing back button, not the cached version before save.
return next();
-});
+}));
-//New Page
-app.get('/new/:id', asyncHandler(getBrew('share')), (req, res, next)=>{
+//New Page from ID
+app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res, next)=>{
sanitizeBrew(req.brew, 'share');
splitTextStyleAndMetadata(req.brew);
const brew = {
@@ -292,17 +296,31 @@ app.get('/new/:id', asyncHandler(getBrew('share')), (req, res, next)=>{
style : req.brew.style,
renderer : req.brew.renderer,
theme : req.brew.theme,
- tags : req.brew.tags
+ tags : req.brew.tags,
};
req.brew = _.defaults(brew, DEFAULT_BREW);
+ req.userThemes = await(getUsersBrewThemes(req.account?.username));
+
req.ogMeta = { ...defaultMetaTags,
title : 'New',
description : 'Start crafting your homebrew on the Homebrewery!'
};
return next();
-});
+}));
+
+//New Page
+app.get('/new', asyncHandler(async(req, res, next)=>{
+ req.userThemes = await(getUsersBrewThemes(req.account?.username));
+
+ req.ogMeta = { ...defaultMetaTags,
+ title : 'New',
+ description : 'Start crafting your homebrew on the Homebrewery!'
+ };
+
+ return next();
+}));
//Share Page
app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
@@ -418,7 +436,8 @@ const renderPage = async (req, res)=>{
enable_v3 : config.get('enable_v3'),
enable_themes : config.get('enable_themes'),
config : configuration,
- ogMeta : req.ogMeta
+ ogMeta : req.ogMeta,
+ userThemes : req.userThemes
};
const title = req.brew ? req.brew.title : '';
const page = await templateFn('homebrew', title, props)
diff --git a/server/homebrew.api.js b/server/homebrew.api.js
index f755c8f23..c7484da92 100644
--- a/server/homebrew.api.js
+++ b/server/homebrew.api.js
@@ -8,9 +8,16 @@ const Markdown = require('../shared/naturalcrit/markdown.js');
const yaml = require('js-yaml');
const asyncHandler = require('express-async-handler');
const { nanoid } = require('nanoid');
+const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js');
+const Themes = require('../themes/themes.json');
+
+const isStaticTheme = (renderer, themeName)=>{
+ return Themes[renderer]?.[themeName] !== undefined;
+};
+
// const getTopBrews = (cb) => {
// HomebrewModel.find().sort({ views: -1 }).limit(5).exec(function(err, brews) {
// cb(brews);
@@ -37,6 +44,40 @@ const api = {
}
return { id, googleId };
},
+ //Get array of any of this user's brews tagged with `meta:theme`
+ getUsersBrewThemes : async (username)=>{
+ const fields = [
+ 'title',
+ 'tags',
+ 'shareId',
+ 'thumbnail',
+ 'textBin',
+ 'text',
+ 'authors',
+ 'renderer'
+ ];
+
+ const userThemes = {};
+
+ const brews = await HomebrewModel.getByUser(username, true, fields, { tags: { $in: ['meta:theme', 'meta:Theme'] } });
+
+ if(brews) {
+ for (const brew of brews) {
+ userThemes[brew.renderer] ??= {};
+ userThemes[brew.renderer][brew.shareId] = {
+ name : brew.title,
+ renderer : brew.renderer,
+ baseTheme : brew.theme,
+ baseSnippets : false,
+ author : brew.authors[0],
+ path : brew.shareId,
+ thumbnail : brew.thumbnail || '/assets/naturalCritLogoWhite.svg'
+ };
+ }
+ }
+
+ return userThemes;
+ },
getBrew : (accessType, stubOnly = false)=>{
// Create middleware with the accessType passed in as part of the scope
return async (req, res, next)=>{
@@ -209,6 +250,62 @@ const api = {
res.status(200).send(saved);
},
+ getThemeBundle : async(req, res)=>{
+ /*
+ getThemeBundle: Collects the theme and all parent themes
+ returns an object containing an array of css, in render order, and an array
+ of snippets ( currently empty )
+ Important parameter members:
+ req.params.id: This is the shareId ( User theme ) or name ( static theme )
+ loaded first.
+ req.params.renderer: This is the Markdown+ version for the static theme. If a
+ User theme the value will come from the User Theme metadata.
+ */
+ req.params.renderer = _.upperFirst(req.params.renderer);
+ let currentTheme;
+ const completeStyles = [];
+ const completeSnippets = [];
+
+ while (req.params.id) {
+ //=== User Themes ===//
+ if(!isStaticTheme(req.params.renderer, req.params.id)) {
+ await api.getBrew('share')(req, res, ()=>{})
+ .catch((err)=>{
+ if(err.HBErrorCode == '05')
+ err = { ...err, name: 'ThemeLoad Error', message: 'Theme Not Found', HBErrorCode: '09' };
+ throw err;
+ });
+
+ currentTheme = req.brew;
+ splitTextStyleAndMetadata(currentTheme);
+
+ // If there is anything in the snippets or style members, append them to the appropriate array
+ if(currentTheme?.snippets) completeSnippets.push(JSON.parse(currentTheme.snippets));
+ if(currentTheme?.style) completeStyles.push(`/* From Brew: ${req.protocol}://${req.get('host')}/share/${req.params.id} */\n\n${currentTheme.style}`);
+
+ req.params.id = currentTheme.theme;
+ req.params.renderer = currentTheme.renderer;
+ }
+ //=== Static Themes ===//
+ else {
+ const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client
+ const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`;
+ completeSnippets.push(localSnippets);
+ completeStyles.push(`/* From Theme ${req.params.id} */\n\n${localStyle}`);
+
+ req.params.id = Themes[req.params.renderer][req.params.id].baseTheme;
+ }
+ }
+
+ const returnObj = {
+ // Reverse the order of the arrays so they are listed oldest parent to youngest child.
+ styles : completeStyles.reverse(),
+ snippets : completeSnippets.reverse()
+ };
+
+ res.setHeader('Content-Type', 'application/json');
+ return res.status(200).send(returnObj);
+ },
updateBrew : async (req, res)=>{
// Initialize brew from request and body, destructure query params, and set the initial value for the after-save method
const brewFromClient = api.excludePropsFromUpdate(req.body);
@@ -369,5 +466,6 @@ router.put('/api/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api
router.put('/api/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.delete('/api/:id', asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
+router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
module.exports = api;
diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js
index c8539bf63..679301294 100644
--- a/server/homebrew.api.spec.js
+++ b/server/homebrew.api.spec.js
@@ -14,6 +14,9 @@ describe('Tests for api', ()=>{
let saved;
beforeEach(()=>{
+ jest.resetModules();
+ jest.restoreAllMocks();
+
saved = undefined;
saveFunc = jest.fn(async function() {
saved = { ...this, _id: '1' };
@@ -45,8 +48,9 @@ describe('Tests for api', ()=>{
model.mockImplementation((brew)=>modelBrew(brew));
res = {
- status : jest.fn(()=>res),
- send : jest.fn(()=>{})
+ status : jest.fn(()=>res),
+ send : jest.fn(()=>{}),
+ setHeader : jest.fn(()=>{})
};
api = require('./homebrew.api');
@@ -81,10 +85,6 @@ describe('Tests for api', ()=>{
};
});
- afterEach(()=>{
- jest.restoreAllMocks();
- });
-
describe('getId', ()=>{
it('should return only id if google id is not present', ()=>{
const { id, googleId } = api.getId({
@@ -581,6 +581,121 @@ brew`);
});
});
+ describe('Theme bundle', ()=>{
+ it('should return Theme Bundle for a User Theme', async ()=>{
+ const brews = {
+ userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: null, shareId: 'userThemeAID', style: 'User Theme A Style' }
+ };
+
+ const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
+ model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
+ const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
+
+ await api.getThemeBundle(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({
+ styles : ['/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'],
+ snippets : []
+ });
+ });
+
+ it('should return Theme Bundle for nested User Themes', async ()=>{
+ const brews = {
+ userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' },
+ userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' },
+ userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: null, shareId: 'userThemeCID', style: 'User Theme C Style' }
+ };
+
+ const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
+ model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
+ const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
+
+ await api.getThemeBundle(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({
+ styles : [
+ '/* From Brew: https://localhost/share/userThemeCID */\n\nUser Theme C Style',
+ '/* From Brew: https://localhost/share/userThemeBID */\n\nUser Theme B Style',
+ '/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'
+ ],
+ snippets : []
+ });
+ });
+
+ it('should return Theme Bundle for a Static Theme', async ()=>{
+ const req = { params: { renderer: 'V3', id: '5ePHB' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
+
+ await api.getThemeBundle(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({
+ styles : [
+ `/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`,
+ `/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`
+ ],
+ snippets : [
+ 'V3_Blank',
+ 'V3_5ePHB'
+ ]
+ });
+ });
+
+ it('should return Theme Bundle for nested User and Static Themes together', async ()=>{
+ const brews = {
+ userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' },
+ userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' },
+ userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: '5eDMG', shareId: 'userThemeCID', style: 'User Theme C Style' }
+ };
+
+ const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
+ model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
+ const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
+
+ await api.getThemeBundle(req, res);
+
+ expect(res.status).toHaveBeenCalledWith(200);
+ expect(res.send).toHaveBeenCalledWith({
+ styles : [
+ `/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`,
+ `/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`,
+ `/* From Theme 5eDMG */\n\n@import url("/themes/V3/5eDMG/style.css");`,
+ '/* From Brew: https://localhost/share/userThemeCID */\n\nUser Theme C Style',
+ '/* From Brew: https://localhost/share/userThemeBID */\n\nUser Theme B Style',
+ '/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'
+ ],
+ snippets : [
+ 'V3_Blank',
+ 'V3_5ePHB',
+ 'V3_5eDMG'
+ ]
+ });
+ });
+
+ it('should fail for an invalid Theme in the chain', async()=>{
+ const brews = {
+ userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'missingTheme', shareId: 'userThemeAID', style: 'User Theme A Style' },
+ };
+
+ const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
+ model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
+ const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
+
+ let err;
+ await api.getThemeBundle(req, res)
+ .catch((e)=>err = e);
+
+ expect(err).toEqual({
+ HBErrorCode : '09',
+ accessType : 'share',
+ brewId : 'missingTheme',
+ message : 'Theme Not Found',
+ name : 'ThemeLoad Error',
+ status : 404 });
+ });
+ });
+
describe('deleteBrew', ()=>{
it('should handle case where fetching the brew returns an error', async ()=>{
api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; });
diff --git a/server/homebrew.model.js b/server/homebrew.model.js
index 36c9aa192..c8db8fdcc 100644
--- a/server/homebrew.model.js
+++ b/server/homebrew.model.js
@@ -50,8 +50,8 @@ HomebrewSchema.statics.get = async function(query, fields=null){
return brew;
};
-HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, fields=null){
- const query = { authors: username, published: true };
+HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, fields=null, filter=null){
+ const query = { authors: username, published: true, ...filter };
if(allowAccess){
delete query.published;
}
diff --git a/shared/helpers.js b/shared/helpers.js
index 8ca185046..e5c1b7769 100644
--- a/shared/helpers.js
+++ b/shared/helpers.js
@@ -1,5 +1,6 @@
const _ = require('lodash');
const yaml = require('js-yaml');
+const request = require('../client/homebrew/utils/request-middleware.js');
const splitTextStyleAndMetadata = (brew)=>{
brew.text = brew.text.replaceAll('\r\n', '\n');
@@ -15,6 +16,11 @@ const splitTextStyleAndMetadata = (brew)=>{
brew.style = brew.text.slice(7, index - 1);
brew.text = brew.text.slice(index + 5);
}
+ if(brew.text.startsWith('```snippets')) {
+ const index = brew.text.indexOf('```\n\n');
+ brew.snippets = brew.text.slice(11, index - 1);
+ brew.text = brew.text.slice(index + 5);
+ }
};
const printCurrentBrew = ()=>{
@@ -28,7 +34,24 @@ const printCurrentBrew = ()=>{
}
};
+const fetchThemeBundle = async (obj, renderer, theme)=>{
+ const res = await request
+ .get(`/api/theme/${renderer}/${theme}`)
+ .catch((err)=>{
+ obj.setState({ error: err });
+ });
+ if(!res) return;
+
+ const themeBundle = res.body;
+ themeBundle.joinedStyles = themeBundle.styles.map((style)=>``).join('\n\n');
+ obj.setState((prevState)=>({
+ ...prevState,
+ themeBundle : themeBundle
+ }));
+};
+
module.exports = {
splitTextStyleAndMetadata,
- printCurrentBrew
+ printCurrentBrew,
+ fetchThemeBundle,
};
diff --git a/themes/V3/5ePHB/settings.json b/themes/V3/5ePHB/settings.json
index 499096a05..53329ce4a 100644
--- a/themes/V3/5ePHB/settings.json
+++ b/themes/V3/5ePHB/settings.json
@@ -1,6 +1,6 @@
{
"name" : "5e PHB",
"renderer" : "V3",
- "baseTheme" : false,
+ "baseTheme" : "Blank",
"baseSnippets" : false
}
diff --git a/themes/V3/Journal/settings.json b/themes/V3/Journal/settings.json
index 069bdb270..74700cc8c 100644
--- a/themes/V3/Journal/settings.json
+++ b/themes/V3/Journal/settings.json
@@ -1,6 +1,6 @@
{
"name" : "Journal",
"renderer" : "V3",
- "baseTheme" : false,
+ "baseTheme" : "Blank",
"baseSnippets" : "5ePHB"
}
diff --git a/themes/fonts/5e/fonts.less b/themes/fonts/5e/fonts.less
index 8f089b51c..c028b06f9 100644
--- a/themes/fonts/5e/fonts.less
+++ b/themes/fonts/5e/fonts.less
@@ -74,8 +74,9 @@
@font-face {
font-family: SolberaImitationRemake; //Tweaked 5e version
src: url('../../../fonts/5e/Solbera Imitation Tweak.woff2');
- font-weight: normal;
+ font-weight: 100 1000;
font-style: normal;
+ font-style: italic;
}
/* Cover Page */
diff --git a/themes/themes.json b/themes/themes.json
index 0d28c7394..16a4b9b13 100644
--- a/themes/themes.json
+++ b/themes/themes.json
@@ -18,7 +18,7 @@
"5ePHB": {
"name": "5e PHB",
"renderer": "V3",
- "baseTheme": false,
+ "baseTheme": "Blank",
"baseSnippets": false,
"path": "5ePHB"
},
@@ -32,7 +32,7 @@
"Journal": {
"name": "Journal",
"renderer": "V3",
- "baseTheme": false,
+ "baseTheme": "Blank",
"baseSnippets": "5ePHB",
"path": "Journal"
}