diff --git a/changelog.md b/changelog.md index b331fc4f0..0cdb89c29 100644 --- a/changelog.md +++ b/changelog.md @@ -84,6 +84,34 @@ pre { ## changelog For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). +### Monday 7/29/2024 - v3.14.0 +{{taskList + +##### abquintic, calculuschild + +* [x] Alternative Brew Themes, including importing other brews as a base theme. + +- In the :fas_circle_info: **Properties** menu, find the new {{openSans **THEME**}} dropdown. It lists Brew Themes, including a new **Blank** theme as a simpler basis for custom styling. +- Brews tagged with `meta:theme` will appear in the Brew Themes list. Selecting one loads its :fas_paintbrush: **Style** tab contents as the CSS basis for the current brew, allowing one brew to style multiple documents. +- Brews with `meta:theme` can also select their own Theme, i.e. layering Themes on top of each other. +- The next goal is to make **Published** Themes shareable between users. + + +Fixes issues [#1899](https://github.com/naturalcrit/homebrewery/issues/1899), [#3085](https://github.com/naturalcrit/homebrewery/issues/3085) + +##### G-Ambatte + +* [x] Fix Drop-cap font becoming corrupted when Bold + +Fixes issues [#3551](https://github.com/naturalcrit/homebrewery/issues/3551) + +* [x] Fixes to UI styling + +Fixes issues [#3568](https://github.com/naturalcrit/homebrewery/issues/3568) + +}} + + ### Saturday 6/7/2024 - v3.13.1 {{taskList @@ -131,8 +159,6 @@ Fixes issue [#3298](https://github.com/naturalcrit/homebrewery/issues/3298) Fixes issue [#3397](https://github.com/naturalcrit/homebrewery/issues/3397) }} -\column - ### Monday 18/3/2024 - v3.12.0 {{taskList diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx index 3c36244c1..4b82c6bc0 100644 --- a/client/homebrew/brewRenderer/brewRenderer.jsx +++ b/client/homebrew/brewRenderer/brewRenderer.jsx @@ -18,8 +18,6 @@ const { printCurrentBrew } = require('../../../shared/helpers.js'); const DOMPurify = require('dompurify'); const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false }; -const Themes = require('themes/themes.json'); - const PAGE_HEIGHT = 1056; const INITIAL_CONTENT = dedent` @@ -57,6 +55,7 @@ const BrewRenderer = (props)=>{ lang : '', errors : [], currentEditorPage : 0, + themeBundle : {}, ...props }; @@ -125,10 +124,9 @@ const BrewRenderer = (props)=>{ }; const renderStyle = ()=>{ - if(!props.style) return; const cleanStyle = props.style; //DOMPurify.sanitize(props.style, purifyConfig); - //return
@layer styleTab {\n${sanitizeScriptTags(props.style)}\n} ` }} />; - return
${cleanStyle} ` }} />; + const themeStyles = props.themeBundle?.joinedStyles ?? ''; + return
${cleanStyle} ` }} />; }; const renderPage = (pageText, index)=>{ @@ -188,10 +186,6 @@ const BrewRenderer = (props)=>{ document.dispatchEvent(new MouseEvent('click')); }; - const rendererPath = props.renderer == 'V3' ? 'V3' : 'Legacy'; - const themePath = props.theme ?? '5ePHB'; - const baseThemePath = Themes[rendererPath][themePath].baseTheme; - return ( <> {/*render dummy page while iFrame is mounting.*/} @@ -220,13 +214,6 @@ const BrewRenderer = (props)=>{ onKeyDown={handleControlKeys} tabIndex={-1} style={{ height: state.height }}> - - - {baseThemePath && - - } - - {/* Apply CSS from Style tab and render pages from Markdown tab */} {state.isMounted && diff --git a/client/homebrew/editor/editor.jsx b/client/homebrew/editor/editor.jsx index 06375d21d..f1ed28440 100644 --- a/client/homebrew/editor/editor.jsx +++ b/client/homebrew/editor/editor.jsx @@ -447,7 +447,8 @@ const Editor = createClass({ + reportError={this.props.reportError} + userThemes={this.props.userThemes}/> ; } }, @@ -490,6 +491,7 @@ const Editor = createClass({ historySize={this.historySize()} currentEditorTheme={this.state.editorTheme} updateEditorTheme={this.updateEditorTheme} + snippetBundle={this.props.snippetBundle} cursorPos={this.codeEditor.current?.getCursorPosition() || {}} /> {this.renderEditor()} diff --git a/client/homebrew/editor/metadataEditor/metadataEditor.jsx b/client/homebrew/editor/metadataEditor/metadataEditor.jsx index a59a50f74..4b48c2617 100644 --- a/client/homebrew/editor/metadataEditor/metadataEditor.jsx +++ b/client/homebrew/editor/metadataEditor/metadataEditor.jsx @@ -8,6 +8,7 @@ const Nav = require('naturalcrit/nav/nav.jsx'); const Combobox = require('client/components/combobox.jsx'); const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx'); + const Themes = require('themes/themes.json'); const validations = require('./validations.js'); @@ -98,7 +99,7 @@ const MetadataEditor = createClass({ if(renderer == 'legacy') this.props.metadata.theme = '5ePHB'; } - this.props.onChange(this.props.metadata); + this.props.onChange(this.props.metadata, 'renderer'); }, handlePublish : function(val){ this.props.onChange({ @@ -110,7 +111,7 @@ const MetadataEditor = createClass({ handleTheme : function(theme){ this.props.metadata.renderer = theme.renderer; this.props.metadata.theme = theme.path; - this.props.onChange(this.props.metadata); + this.props.onChange(this.props.metadata, 'theme'); }, handleLanguage : function(languageCode){ @@ -191,37 +192,41 @@ const MetadataEditor = createClass({ renderThemeDropdown : function(){ if(!global.enable_themes) return; + const mergedThemes = _.merge(Themes, this.props.userThemes); + const listThemes = (renderer)=>{ - return _.map(_.values(Themes[renderer]), (theme)=>{ - return
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" }