diff --git a/.circleci/config.yml b/.circleci/config.yml index fb239ceb3..5effc0bb2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,7 +10,7 @@ orbs: jobs: build: docker: - - image: cimg/node:20.18.0 + - image: cimg/node:26.4 - image: mongo:4.4 working_directory: ~/homebrewery @@ -27,7 +27,7 @@ jobs: # fallback to using the latest cache if no exact match is found - v1-dependencies- - - run: sudo npm install -g npm@10.8.2 + - run: sudo npm install -g npm@11.17.0 - node/install-packages: app-dir: ~/homebrewery cache-path: node_modules @@ -45,7 +45,7 @@ jobs: test: docker: - - image: cimg/node:20.17.0 + - image: cimg/node:26.4 working_directory: ~/homebrewery parallelism: 1 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2204679a6..8915c39dd 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -66,10 +66,6 @@ updates: - dependency-name: "@babel/preset-react" versions: - 7.13.13 - - dependency-name: codemirror - versions: - - 5.59.3 - - 5.60.0 - dependency-name: classnames versions: - 2.3.0 diff --git a/Dockerfile b/Dockerfile index 17d02b01f..023644346 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine +FROM node:26.4.0-alpine RUN apk --no-cache add git ENV NODE_ENV=docker diff --git a/changelog.md b/changelog.md index 1e1ac70e2..136f6346a 100644 --- a/changelog.md +++ b/changelog.md @@ -85,14 +85,52 @@ pre { } .page .df { - font-size: 2em; - vertical-align: middle; + font-size: 2em; + vertical-align: middle; } ``` ## changelog For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). +### Saturday 4/20/2026 - v3.22.0 + +{{taskList +##### 5e-Cleric +* [x] Major update to editor framework (Codemirror 6) +Fixes issues [#3511](https://github.com/naturalcrit/homebrewery/issues/3511), [#4590](https://github.com/naturalcrit/homebrewery/issues/4590), [#4563](https://github.com/naturalcrit/homebrewery/issues/4653), [#4655](https://github.com/naturalcrit/homebrewery/issues/4655) +* [x] Fix to Admin page tab names + +##### G-Ambatte +* [x] Fix white page crash on certain browsers +}} + +### Saturday 4/04/2026 - v3.21.0 + +{{taskList +##### Gazook89 +* [x] Allow custom {{openSans **:fas_table_list: SNIPPETS**}} to be inserted mid-line + +##### abquintic +* [x] Move example snippet images out of imgur (for folks without imgur access) + +##### 5e-Cleric +* [x] Add auto-suggest to tag entry input box +* [x] Replace all example artwork with +* [x] Added tooltips to the {{openSans :fas_circle_info: **Properties**}} menu +* [x] Removed {{openSans **SYSTEMS**}} checkboxes from {{openSans :fas_circle_info: **Properties**}} menu; instead {{openSans **TAGS**}} should be used for this purpose +* [x] Replace all AI-generated art with public domain art +* [x] Major backend refactor to use Vite + +##### A1Asriel (new contributor!) +* [x] Add fix for column breaks on Firefox + +Fixes issues [#543](https://github.com/naturalcrit/homebrewery/issues/543), [#2473](https://github.com/naturalcrit/homebrewery/issues/2473), [#3712](https://github.com/naturalcrit/homebrewery/issues/3712) + +##### G-Ambatte, abquintic, 5e-Cleric +* [x] Multiple other backend fixes and refactors +}} + ### Friday 1/11/2026 - v3.20.1 {{taskList @@ -2358,4 +2396,4 @@ Massive changelog incoming: * Added `phb.standalone.css` plus a build system for creating it * Added page numbers and footer text -* Page accent now flips each page +* Page accent now flips each page \ No newline at end of file diff --git a/client/admin/admin.less b/client/admin/admin.less index 432f92e8b..e66ead5e3 100644 --- a/client/admin/admin.less +++ b/client/admin/admin.less @@ -111,6 +111,10 @@ body { vertical-align : middle; text-align : center; border-right : 1px solid; + max-width:50ch; + overflow:hidden; + text-overflow: ellipsis; + white-space: nowrap; &:last-child { border-right : none; } } diff --git a/client/admin/brewUtils/brewCleanup/brewCleanup.jsx b/client/admin/brewUtils/brewCleanup/brewCleanup.jsx index 6cec01178..7d6c34f55 100644 --- a/client/admin/brewUtils/brewCleanup/brewCleanup.jsx +++ b/client/admin/brewUtils/brewCleanup/brewCleanup.jsx @@ -1,71 +1,176 @@ -import React from 'react'; -import createReactClass from 'create-react-class'; +import React, { useState } from 'react'; import request from 'superagent'; +import Moment from 'moment'; -const BrewCleanup = createReactClass({ - displayName : 'BrewCleanup', - getDefaultProps(){ - return {}; - }, - getInitialState() { - return { - count : 0, +const BrewCleanup = ({})=>{ + const [junkBrewCollection, setJunkBrewCollection] = useState([]); + const [lostBrewCollection, setLostBrewCollection] = useState([]); + const [pendingJunk, setPendingJunk] = useState(false); + const [pendingLost, setPendingLost] = useState(false); + const [error, setError] = useState(null); - pending : false, - primed : false, - err : null - }; - }, - prime(){ - this.setState({ pending: true }); + const find = async (type)=>{ + - request.get('/admin/cleanup') - .then((res)=>this.setState({ count: res.body.count, primed: true })) - .catch((err)=>this.setState({ error: err })) - .finally(()=>this.setState({ pending: false })); - }, - cleanup(){ - this.setState({ pending: true }); + if(type === 'junk') try { + setPendingJunk(true); + const res = await request.get('/admin/cleanupJunk'); - request.post('/admin/cleanup') - .then((res)=>this.setState({ count: res.body.count })) - .catch((err)=>this.setState({ error: err })) - .finally(()=>this.setState({ pending: false, primed: false })); - }, - renderPrimed(){ - if(!this.state.primed) return; - - if(!this.state.count){ - return
No Matching Brews found.
; + setJunkBrewCollection(res.body.brewCollection); + } catch (err) { + setError(err); + } finally { + setPendingJunk(false); } + + if(type === 'lost') try { + setPendingLost(true); + const res = await request.get('/admin/cleanupLost'); + + setLostBrewCollection(res.body.brewCollection); + } catch (err) { + setError(err); + } finally { + setPendingLost(false); + } + }; + + const cleanup = async (type)=>{ + + if(type === 'junk') try { + setPendingJunk(true); + console.log('deleting junk'); + const res = await request.post('/admin/cleanupJunk'); + + } catch (err) { + setError(err); + } finally { + setPendingJunk(false); + setJunkBrewCollection([]); + } + + if(type === 'lost') try { + setPendingLost(true); + const res = await request.post('/admin/cleanupLost'); + + } catch (err) { + setError(err); + } finally { + setPendingLost(false); + setLostBrewCollection([]); + } + }; + + const renderBrewList = (type)=>{ + const brewList = type === 'lost' ? lostBrewCollection : junkBrewCollection; + + if(!brewList || brewList.length === 0) { + return <> +

{`Results - No brews found` }

+ + + + + + + + + + + + + + +
TitleLast Updatelast viewedStorage
"No brews found"
+ ; + } + console.log(type); + console.log(brewList); + return <> +

{`Results - ${brewList.length} brews` }

+ + + + + + + + + + + {brewList + .sort((a, b)=>{ // Sort brews from most recently updated + if(a.lastViewed > b.lastViewed) return -1; + return 1; + }) + .map((brew, idx)=>{ + return + + + + + + })} + +
TitleLast Updatelast viewedStorage
{brew.title || 'No Title'}{Moment(brew.updatedAt).fromNow()}{brew.lastViewed ? Moment(brew.lastViewed).fromNow() : 'No last viewed date'}{brew.googleId ? 'Google' : 'Homebrewery'}
+ ; + }; + const renderFound = (type)=>{ + const deleteButton = !(type === 'junk' && junkBrewCollection.length === 0 || type === 'lost' && lostBrewCollection.length === 0); + return
- - Found {this.state.count} Brews that could be removed. + } + {renderBrewList(type)}
; - }, - render(){ - return
-

Brew Cleanup

-

Removes very short brews to tidy up the database

+ }; + const renderJunkBrewCleanup = ()=>{ + return
+

Junk brews

+

Queries unauthored brews that have not been viewed or
updated in 30 days and are shorter than 140 bytes (up to 300)

- - {this.renderPrimed()} + {renderFound('junk')} - {this.state.error - &&
{this.state.error.toString()}
- } + {error &&
{error.toString()}
}
; - } -}); + }; + const renderLostBrewCleanup = ()=>{ + return
+

Lost brews

+

Queries unauthored brews that have not been
updated or viewed for 2 years (up to 500)

+ + + {renderFound('lost')} + + {error &&
{error.toString()}
} +
; + }; + + return
+

Brew Cleanup

+ {renderJunkBrewCleanup()} +
+
+ {renderLostBrewCleanup()} + +
; + +}; export default BrewCleanup; diff --git a/client/admin/main.jsx b/client/admin/main.jsx index bd380789a..ce031c6c8 100644 --- a/client/admin/main.jsx +++ b/client/admin/main.jsx @@ -1,6 +1,8 @@ -import { createRoot } from "react-dom/client"; -import Admin from "./admin.jsx"; +import { createRoot } from 'react-dom/client'; +import Admin from './admin.jsx'; +import { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js'; const props = window.__INITIAL_PROPS__ || {}; -createRoot(document.getElementById("reactRoot")).render(); +createRoot(document.getElementById('reactRoot')).render(); +bootstrapAnchorPositioningPolyfill(); diff --git a/client/components/Anchored.jsx b/client/components/Anchored.jsx index 87af5a6e1..2e7189a16 100644 --- a/client/components/Anchored.jsx +++ b/client/components/Anchored.jsx @@ -71,10 +71,14 @@ const Anchored = ({ children })=>{ // forward ref for AnchoredTrigger const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>( + + + +
+ ); +}; + +export { Dropdown }; \ No newline at end of file diff --git a/client/components/dropdown/dropdown.less b/client/components/dropdown/dropdown.less new file mode 100644 index 000000000..28a26954c --- /dev/null +++ b/client/components/dropdown/dropdown.less @@ -0,0 +1,24 @@ +.menu-wrapper { + position: relative; + &:is(.menu-bar > .menu-section > .menu-wrapper){ + display: inline-block; + } +} + +.menu-list { + position : fixed; + z-index : 1000; + top : anchor(bottom); + left : anchor(left); + position-try: flip-inline flip-block; + color: inherit; // [popover] gets a `canvastext` color value from useragent. + > .menu-wrapper { + position:relative; + > .menu-list { + margin: 0 0px; + top : anchor(top); + left : anchor(right); + position-try: flip-inline; + } + } +} \ No newline at end of file diff --git a/client/components/splitPane/splitPane.jsx b/client/components/splitPane/splitPane.jsx index 7cbfe2066..5eef6bd30 100644 --- a/client/components/splitPane/splitPane.jsx +++ b/client/components/splitPane/splitPane.jsx @@ -18,8 +18,7 @@ const SplitPane = (props)=>{ const [liveScroll, setLiveScroll] = useState(false); useEffect(()=>{ - const savedPos = window.localStorage.getItem(PANE_WIDTH_KEY); - setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2); + handleResize(); setLiveScroll(window.localStorage.getItem(LIVE_SCROLL_KEY) === 'true'); window.addEventListener('resize', handleResize); @@ -29,7 +28,10 @@ const SplitPane = (props)=>{ const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x))); //when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position - const handleResize = ()=>setDividerPos(limitPosition(window.localStorage.getItem(PANE_WIDTH_KEY), 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13))); + const handleResize = ()=>{ + const savedPos = window.localStorage.getItem(PANE_WIDTH_KEY); + setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2); + }; const handleUp =(e)=>{ e.preventDefault(); diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx index 8e74473b3..05ca73bea 100644 --- a/client/homebrew/brewRenderer/brewRenderer.jsx +++ b/client/homebrew/brewRenderer/brewRenderer.jsx @@ -11,7 +11,7 @@ import ErrorBar from './errorBar/errorBar.jsx'; import ToolBar from './toolBar/toolBar.jsx'; //TODO: move to the brew renderer -import RenderWarnings from '../../components/renderWarnings/renderWarnings.jsx'; +import RenderWarnings from '@components/renderWarnings/renderWarnings.jsx'; import NotificationPopup from './notificationPopup/notificationPopup.jsx'; import Frame from 'react-frame-component'; import dedent from 'dedent'; @@ -29,11 +29,11 @@ const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState'; const INITIAL_CONTENT = dedent` - + Rendered Brew Content - +
`; @@ -42,6 +42,7 @@ const BrewPage = (props)=>{ props = { contents : '', index : 0, + hoisted : false, ...props }; const pageRef = useRef(null); @@ -91,6 +92,7 @@ const BrewPage = (props)=>{ //v=====--------------------< Brew Renderer Component >-------------------=====v// let renderedPages = []; +let pageTemplates = []; let rawPages = []; const BrewRenderer = (props)=>{ @@ -135,6 +137,7 @@ const BrewRenderer = (props)=>{ const mainRef = useRef(null); const pagesRef = useRef(null); + const urlRef = useRef(''); if(props.renderer == 'legacy') { rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY); @@ -207,6 +210,20 @@ const BrewRenderer = (props)=>{ styles = _.mapKeys(styles, (v, k)=>k.startsWith('--') ? k : _.camelCase(k)); // Convert CSS to camelCase for React classes = [classes, injectedTags.classes].join(' ').trim(); attributes = injectedTags.attributes; + if(global.enablev4) { + if(attributes && Object.hasOwn(attributes, 'hbtemplate')) { + pageTemplates[index] = attributes['hbtemplate']; + } + } + } + if(global.enablev4) { + // If we don't have a template for this page, look backwards until one is found or the first page. + if(!pageTemplates[index]) { + for (let i=index;i>=0; i--) { + // If one is found, add the template attribute + if(pageTemplates[i]) attributes['hbtemplate'] = pageTemplates[i]; + } + } } pageText = pageText.includes('\n') ? pageText.substring(pageText.indexOf('\n') + 1) : ''; // Remove the \page line } @@ -220,22 +237,31 @@ const BrewRenderer = (props)=>{ } }; - const renderPages = ()=>{ + const renderPages = (checkHoists = false)=>{ + if(props.errors && props.errors.length) return renderedPages; - if(rawPages.length != renderedPages.length) // Re-render all pages when page count changes + if(rawPages.length != renderedPages.length) { // Re-render all pages when page count changes renderedPages.length = 0; + pageTemplates.length = 0; + } // Render currently-edited page first so cross-page effects (variables, links) can propagate out first if(rawPages.length > props.currentEditorCursorPageNum -1) renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1); _.forEach(rawPages, (page, index)=>{ - if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){ + const varsOnPageRegex = /([!$]?)\[((?!\s*\])(?:\\.|[^\[\]\\])+)\]/g; // Find out if there are any vars on the page. + const forceRender = checkHoists && + !props.hoisted && + (page.match(varsOnPageRegex)); // forceRender forces pages outside of the PPR range to render if true. + // This is necessary on the first load to fully populate the variable table. + if((isInView(index) || !renderedPages[index] || forceRender) && typeof window !== 'undefined'){ renderedPages[index] = renderPage(page, index); // Render any page not yet rendered, but only re-render those in PPR range } }); + if(!props.hoisted) { props.hoisted = true; } // Only fully hoist once. return renderedPages; }; @@ -272,8 +298,10 @@ const BrewRenderer = (props)=>{ const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount" scrollToHash(window.location.hash); + window.addEventListener('hashchange', ()=>scrollToHash(window.location.hash)); + setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame - renderPages(); //Make sure page is renderable before showing + renderPages(true); //Make sure page is renderable before showing setState((prevState)=>({ ...prevState, isMounted : true, @@ -321,10 +349,11 @@ const BrewRenderer = (props)=>{ 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/> {/*render in iFrame so broken code doesn't crash the site.*/} - {emitClick();}} + sandbox="allow-same-origin allow-modals allow-top-navigation" >
; diff --git a/client/homebrew/brewRenderer/headerNav/headerNav.jsx b/client/homebrew/brewRenderer/headerNav/headerNav.jsx index 3b184aff0..080ce5ad5 100644 --- a/client/homebrew/brewRenderer/headerNav/headerNav.jsx +++ b/client/homebrew/brewRenderer/headerNav/headerNav.jsx @@ -104,7 +104,7 @@ const HeaderNavItem = ({ link, text, depth, className })=>{ if(!link || !text) return; return
  • - + {trimString(text, depth)}
  • ; diff --git a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx index 5f4fc5608..9ef30917d 100644 --- a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx +++ b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'; import request from '../../utils/request-middleware.js'; import Markdown from '@shared/markdown.js'; -import Dialog from '../../../components/dialog.jsx'; +import Dialog from '@components/dialog.jsx'; const DISMISS_BUTTON = ; diff --git a/client/homebrew/brewRenderer/toolBar/toolBar.jsx b/client/homebrew/brewRenderer/toolBar/toolBar.jsx index 97d996633..16c89ea59 100644 --- a/client/homebrew/brewRenderer/toolBar/toolBar.jsx +++ b/client/homebrew/brewRenderer/toolBar/toolBar.jsx @@ -3,7 +3,7 @@ import './toolBar.less'; import React, { useState, useEffect } from 'react'; import _ from 'lodash'; -import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx'; +import { Anchored, AnchoredBox, AnchoredTrigger } from '@components/Anchored.jsx'; const MAX_ZOOM = 300; const MIN_ZOOM = 10; diff --git a/client/homebrew/editor/editor.jsx b/client/homebrew/editor/editor.jsx index 7f55ebf08..f500bebeb 100644 --- a/client/homebrew/editor/editor.jsx +++ b/client/homebrew/editor/editor.jsx @@ -4,16 +4,29 @@ import React from 'react'; import createReactClass from 'create-react-class'; import _ from 'lodash'; import dedent from 'dedent'; -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 MetadataEditor from './metadataEditor/metadataEditor.jsx'; const EDITOR_THEME_KEY = 'HB_editor_theme'; -const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; -const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/; +import defaultCM5Theme from '@themes/codeMirror/default.js'; +import darkbrewery from '@themes/codeMirror/darkbrewery.js'; +import cm5Themes from 'codemirror-5-themes'; + +const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery }; + +const EditorThemes = Object.entries(themes) + .filter(([name, value])=>Array.isArray(value) && + !name.endsWith('Init') && + !name.endsWith('Style') + ) + .map(([name])=>name); + + +//const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; +//const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/; const DEFAULT_STYLE_TEXT = dedent` /*=======--- Example CSS styling ---=======*/ /* Any CSS here will apply to your document! */ @@ -30,6 +43,7 @@ const DEFAULT_SNIPPET_TEXT = dedent` This snippet is accessible in the brew tab, and will be inherited if the brew is used as a theme. `; let isJumping = false; +let jumpSource = null; const Editor = createReactClass({ displayName : 'Editor', @@ -72,23 +86,20 @@ const Editor = createReactClass({ componentDidMount : function() { - this.highlightCustomMarkdown(); - document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys); + const brewRenderer = document.getElementById('BrewRenderer'); + brewRenderer.onload = ()=>brewRenderer.contentDocument?.addEventListener('keydown', this.handleControlKeys); document.addEventListener('keydown', this.handleControlKeys); - this.codeEditor.current.codeMirror?.on('cursorActivity', (cm)=>{this.updateCurrentCursorPage(cm.getCursor());}); - this.codeEditor.current.codeMirror?.on('scroll', _.throttle(()=>{this.updateCurrentViewPage(this.codeEditor.current.getTopVisibleLine());}, 200)); - const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY); - if(editorTheme) { - this.setState({ - editorTheme : editorTheme - }); + if(editorTheme && EditorThemes.includes(editorTheme)) { + this.setState({ editorTheme }); + } else { + this.setState({ editorTheme: 'default' }); } const snippetBar = document.querySelector('.editor > .snippetBar'); if(!snippetBar) return; - this.resizeObserver = new ResizeObserver(entries=>{ + this.resizeObserver = new ResizeObserver((entries)=>{ const height = document.querySelector('.editor > .snippetBar').offsetHeight; this.setState({ snippetBarHeight: height }); }); @@ -98,7 +109,6 @@ const Editor = createReactClass({ componentDidUpdate : function(prevProps, prevState, snapshot) { - this.highlightCustomMarkdown(); if(prevProps.moveBrew !== this.props.moveBrew) this.brewJump(); @@ -132,22 +142,16 @@ const Editor = createReactClass({ } }, - updateCurrentCursorPage : function(cursor) { - const lines = this.props.brew.text.split('\n').slice(1, cursor.line + 1); - const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/; - const currentPage = lines.reduce((count, line)=>count + (pageRegex.test(line) ? 1 : 0), 1); - this.props.onCursorPageChange(currentPage); + updateCurrentCursorPage : function(pageNumber) { + this.props.onCursorPageChange(pageNumber); }, - updateCurrentViewPage : function(topScrollLine) { - const lines = this.props.brew.text.split('\n').slice(1, topScrollLine + 1); - const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/; - const currentPage = lines.reduce((count, line)=>count + (pageRegex.test(line) ? 1 : 0), 1); - this.props.onViewPageChange(currentPage); + updateCurrentViewPage : function(pageNumber) { + this.props.onViewPageChange(pageNumber); }, handleInject : function(injectText){ - this.codeEditor.current?.injectText(injectText, false); + this.codeEditor.current?.injectText(injectText); }, handleViewChange : function(newView){ @@ -156,181 +160,12 @@ const Editor = createReactClass({ this.setState({ view : newView }, ()=>{ - this.codeEditor.current?.codeMirror?.focus(); + this.codeEditor.current?.focus(); }); }, - highlightCustomMarkdown : function(){ - if(!this.codeEditor.current?.codeMirror) return; - if((this.state.view === 'text') ||(this.state.view === 'snippet')) { - const codeMirror = this.codeEditor.current.codeMirror; - - codeMirror?.operation(()=>{ // Batch CodeMirror styling - - const foldLines = []; - - //reset custom text styles - const customHighlights = codeMirror?.getAllMarks().filter((mark)=>{ - // Record details of folded sections - if(mark.__isFold) { - const fold = mark.find(); - foldLines.push({ from: fold.from?.line, to: fold.to?.line }); - } - return !mark.__isFold; - }); //Don't undo code folding - - for (let i=customHighlights.length - 1;i>=0;i--) customHighlights[i].clear(); - - let userSnippetCount = 1; // start snippet count from snippet 1 - let editorPageCount = 1; // start page count from page 1 - - const whichSource = this.state.view === 'text' ? this.props.brew.text : this.props.brew.snippets; - _.forEach(whichSource?.split('\n'), (line, lineNumber)=>{ - - const tabHighlight = this.state.view === 'text' ? 'pageLine' : 'snippetLine'; - const textOrSnip = this.state.view === 'text'; - - //reset custom line styles - codeMirror?.removeLineClass(lineNumber, 'background', 'pageLine'); - codeMirror?.removeLineClass(lineNumber, 'background', 'snippetLine'); - codeMirror?.removeLineClass(lineNumber, 'text'); - codeMirror?.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash'); - - // Don't process lines inside folded text - // If the current lineNumber is inside any folded marks, skip line styling - if(foldLines.some((fold)=>lineNumber >= fold.from && lineNumber <= fold.to)) - return; - - // Styling for \page breaks - if((this.props.renderer == 'legacy' && line.includes('\\page')) || - (this.props.renderer == 'V3' && line.match(textOrSnip ? PAGEBREAK_REGEX_V3 : SNIPPETBREAK_REGEX_V3))) { - - if((lineNumber > 0) && (textOrSnip)) // Since \page is optional on first line of document, - editorPageCount += 1; // don't use it to increment page count; stay at 1 - else if(this.state.view !== 'text') userSnippetCount += 1; - - // add back the original class 'background' but also add the new class '.pageline' - codeMirror?.addLineClass(lineNumber, 'background', tabHighlight); - const pageCountElement = Object.assign(document.createElement('span'), { - className : 'editor-page-count', - textContent : textOrSnip ? editorPageCount : userSnippetCount - }); - codeMirror?.setBookmark({ line: lineNumber, ch: line.length }, pageCountElement); - }; - - - // New CodeMirror styling for V3 renderer - if(this.props.renderer === 'V3') { - if(line.match(/^\\column(?:break)?$/)){ - codeMirror?.addLineClass(lineNumber, 'text', 'columnSplit'); - } - - // definition lists - if(line.includes('::')){ - if(/^:*$/.test(line) == true){ return; }; - const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error. - let match; - while ((match = regex.exec(line)) != null){ - codeMirror?.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' }); - codeMirror?.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' }); - codeMirror?.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' }); - const ddIndex = match.indices[2][0]; - const colons = /::/g; - const colonMatches = colons.exec(match[2]); - if(colonMatches !== null){ - codeMirror?.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' }); - } - } - } - - // Subscript & Superscript - if(line.includes('^')) { - let startIndex = line.indexOf('^'); - const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy; - const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy; - - while (startIndex >= 0) { - superRegex.lastIndex = subRegex.lastIndex = startIndex; - let isSuper = false; - const match = subRegex.exec(line) || superRegex.exec(line); - if(match) { - isSuper = !subRegex.lastIndex; - codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' }); - } - startIndex = line.indexOf('^', Math.max(startIndex + 1, subRegex.lastIndex, superRegex.lastIndex)); - } - } - - // Highlight injectors {style} - if(line.includes('{') && line.includes('}')){ - const regex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gm; - let match; - while ((match = regex.exec(line)) != null) { - codeMirror?.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'injection' }); - } - } - // Highlight inline spans {{content}} - if(line.includes('{{') && line.includes('}}')){ - const regex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g; - let match; - let blockCount = 0; - while ((match = regex.exec(line)) != null) { - if(match[0].startsWith('{')) { - blockCount += 1; - } else { - blockCount -= 1; - } - if(blockCount < 0) { - blockCount = 0; - continue; - } - codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: 'inline-block' }); - } - } else if(line.trimLeft().startsWith('{{') || line.trimLeft().startsWith('}}')){ - // Highlight block divs {{\n Content \n}} - let endCh = line.length+1; - - const match = line.match(/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/); - if(match) - endCh = match.index+match[0].length; - codeMirror?.markText({ line: lineNumber, ch: 0 }, { line: lineNumber, ch: endCh }, { className: 'block' }); - } - - // Emojis - if(line.match(/:[^\s:]+:/g)) { - let startIndex = line.indexOf(':'); - const emojiRegex = /:[^\s:]+:/gy; - - while (startIndex >= 0) { - emojiRegex.lastIndex = startIndex; - const match = emojiRegex.exec(line); - if(match) { - let tokens = Markdown.marked.lexer(match[0]); - tokens = tokens[0].tokens.filter((t)=>t.type == 'emoji'); - if(!tokens.length) - return; - - const startPos = { line: lineNumber, ch: match.index }; - const endPos = { line: lineNumber, ch: match.index + match[0].length }; - - // Iterate over conflicting marks and clear them - const marks = codeMirror?.findMarks(startPos, endPos); - marks.forEach(function(marker) { - if(!marker.__isFold) marker.clear(); - }); - codeMirror?.markText(startPos, endPos, { className: 'emoji' }); - } - startIndex = line.indexOf(':', Math.max(startIndex + 1, emojiRegex.lastIndex)); - } - } - } - }); - }); - } - }, - brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){ - if(!window || !this.isText() || isJumping) + if(!window || !this.isText() || isJumping || jumpSource === 'source') return; // Get current brewRenderer scroll position and calculate target position @@ -343,11 +178,13 @@ const Editor = createReactClass({ clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs scrollingTimeout = setTimeout(()=>{ isJumping = false; + jumpSource = null; brewRenderer.removeEventListener('scroll', checkIfScrollComplete); }, 150); // If 150 ms pass without a brewRenderer scroll event, assume scrolling is done }; isJumping = true; + jumpSource = 'brew'; checkIfScrollComplete(); brewRenderer.addEventListener('scroll', checkIfScrollComplete); @@ -371,54 +208,17 @@ const Editor = createReactClass({ }, sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){ - if(!this.isText() || isJumping) + if(!this.isText() || isJumping || jumpSource === 'brew') return; - const textSplit = this.props.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/; - const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit); - const targetLine = textString.match('\n') ? textString.split('\n').length - 1 : -1; + const editor = this.codeEditor.current; + if(!editor) return; + jumpSource = 'source'; - let currentY = this.codeEditor.current.codeMirror?.getScrollInfo().top; - let targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true); - - let scrollingTimeout; - const checkIfScrollComplete = ()=>{ // Prevent interrupting a scroll in progress if user clicks multiple times - clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs - scrollingTimeout = setTimeout(()=>{ - isJumping = false; - this.codeEditor.current.codeMirror?.off('scroll', checkIfScrollComplete); - }, 150); // If 150 ms pass without a scroll event, assume scrolling is done - }; - - isJumping = true; - checkIfScrollComplete(); - if(this.codeEditor.current?.codeMirror) { - this.codeEditor.current.codeMirror?.on('scroll', checkIfScrollComplete); - } - - if(smooth) { - //Scroll 1/10 of the way every 10ms until 1px off. - const incrementalScroll = setInterval(()=>{ - currentY += (targetY - currentY) / 10; - this.codeEditor.current.codeMirror?.scrollTo(null, currentY); - - // Update target: target height is not accurate until within +-10 lines of the visible window - if(Math.abs(targetY - currentY > 100)) - targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true); - - // End when close enough - if(Math.abs(targetY - currentY) < 1) { - this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference - this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 }); - this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash'); - clearInterval(incrementalScroll); - } - }, 10); - } else { - this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference - this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 }); - this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash'); - } + editor.scrollToPage(targetPage); + setTimeout(()=>{ + jumpSource = null; + }, 200); }, //Called when there are changes to the editor's dimensions @@ -446,9 +246,11 @@ const Editor = createReactClass({ view={this.state.view} value={this.props.brew.text} onChange={this.props.onBrewChange('text')} + onCursorChange={(page)=>this.updateCurrentCursorPage(page)} + onViewChange={(page)=>this.updateCurrentViewPage(page)} editorTheme={this.state.editorTheme} - rerenderParent={this.rerenderParent} - style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} /> + renderer={this.props.brew.renderer} + style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }}/> ; } if(this.isStyle()){ @@ -460,18 +262,16 @@ const Editor = createReactClass({ view={this.state.view} value={this.props.brew.style ?? DEFAULT_STYLE_TEXT} onChange={this.props.onBrewChange('style')} - enableFolding={true} editorTheme={this.state.editorTheme} - rerenderParent={this.rerenderParent} - style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} /> + renderer={this.props.brew.renderer} + style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }}/> ; } if(this.isMeta()){ return <> + style={{ display: 'none' }}/> + style={{ height: `calc(100% - 25px)` }}/> ; } }, @@ -510,14 +311,13 @@ const Editor = createReactClass({ return this.codeEditor.current?.undo(); }, - foldCode : function(){ - return this.codeEditor.current?.foldAllCode(); + foldCode : function() { + return this.codeEditor.current?.foldAll(); }, - unfoldCode : function(){ - return this.codeEditor.current?.unfoldAllCode(); + unfoldCode : function() { + return this.codeEditor.current?.unfoldAll(); }, - render : function(){ return (
    @@ -547,4 +347,4 @@ const Editor = createReactClass({ } }); -export default Editor; +export default Editor; \ No newline at end of file diff --git a/client/homebrew/editor/editor.less b/client/homebrew/editor/editor.less index 3851b50c5..a55fad852 100644 --- a/client/homebrew/editor/editor.less +++ b/client/homebrew/editor/editor.less @@ -1,90 +1,11 @@ @import '@sharedStyles/core.less'; -@import '@themes/codeMirror/customEditorStyles.less'; -.editor { - position : relative; - width : 100%; - height : 100%; - container : editor / inline-size; - background:white; - .codeEditor { - height : calc(100% - 25px); - .CodeMirror { height : 100%; } - .pageLine, .snippetLine { - background : #33333328; - border-top : #333399 solid 1px; - } - .editor-page-count { - float : right; - color : grey; - } - .editor-snippet-count { - float : right; - color : grey; - } - .columnSplit { - font-style : italic; - color : grey; - background-color : fade(#229999, 15%); - border-bottom : #229999 solid 1px; - } - .define { - &:not(.term):not(.definition) { - font-weight : bold; - color : #949494; - background : #E5E5E5; - border-radius : 3px; - } - &.term { color : rgb(96, 117, 143); } - &.definition { color : rgb(97, 57, 178); } - } - .block:not(.cm-comment) { - font-weight : bold; - color : purple; - //font-style: italic; - } - .inline-block:not(.cm-comment) { - font-weight : bold; - color : red; - //font-style: italic; - } - .injection:not(.cm-comment) { - font-weight : bold; - color : green; - } - .emoji:not(.cm-comment) { - padding-bottom : 1px; - margin-left : 2px; - font-weight : bold; - color : #360034; - outline : solid 2px #FF96FC; - outline-offset : -2px; - background : #FFC8FF; - border-radius : 6px; - } - .superscript:not(.cm-comment) { - font-size : 0.9em; - font-weight : bold; - vertical-align : super; - color : goldenrod; - } - .subscript:not(.cm-comment) { - font-size : 0.9em; - font-weight : bold; - vertical-align : sub; - color : rgb(123, 123, 15); - } - .dl-highlight { - &.dl-colon-highlight { - font-weight : bold; - color : #949494; - background : #E5E5E5; - border-radius : 3px; - } - &.dt-highlight { color : rgb(96, 117, 143); } - &.dd-highlight { color : rgb(97, 57, 178); } - } - } +:where(.editor) { + position : relative; + width : 100%; + height : 100%; + container : editor / inline-size; + background : white; .brewJump { position : absolute; diff --git a/client/homebrew/editor/metadataEditor/metadataEditor.jsx b/client/homebrew/editor/metadataEditor/metadataEditor.jsx index 21e65b57d..98f952e2d 100644 --- a/client/homebrew/editor/metadataEditor/metadataEditor.jsx +++ b/client/homebrew/editor/metadataEditor/metadataEditor.jsx @@ -4,7 +4,7 @@ import React from 'react'; import createReactClass from 'create-react-class'; import _ from 'lodash'; 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'; @@ -386,9 +386,9 @@ const MetadataEditor = createReactClass({ {this.renderThumbnail()}
    -
    +
    -
    +
    - + {this.renderLanguageDropdown()} @@ -411,9 +411,9 @@ const MetadataEditor = createReactClass({ {this.renderAuthors()} -
    +
    -
    +
    - +

    Privacy

    diff --git a/client/homebrew/editor/snippetbar/snippetbar.jsx b/client/homebrew/editor/snippetbar/snippetbar.jsx index 304664ff5..dc921ffbb 100644 --- a/client/homebrew/editor/snippetbar/snippetbar.jsx +++ b/client/homebrew/editor/snippetbar/snippetbar.jsx @@ -2,6 +2,7 @@ import './snippetbar.less'; import React from 'react'; import createReactClass from 'create-react-class'; +import { Dropdown } from '@components/dropdown/dropdown.jsx'; import _ from 'lodash'; import cx from 'classnames'; @@ -23,7 +24,25 @@ const ThemeSnippets = { V3_Blank : V3_Blank, }; -import EditorThemes from '../../../../build/homebrew/codeMirror/editorThemes.json'; +import defaultCM5Theme from '@themes/codeMirror/default.js'; +import darkbrewery from '@themes/codeMirror/darkbrewery.js'; +import cm5Themes from 'codemirror-5-themes'; + +const themes = { default: defaultCM5Theme, ...cm5Themes, darkbrewery }; + +const themeNames = Object.entries(themes) + .filter(([name, value])=>Array.isArray(value) && + !name.endsWith('Init') && + !name.endsWith('Style') + ) + .map(([name])=>name); + +const EditorThemes = [ + 'default', + ...themeNames + .filter((name)=>name !== 'default') + .sort((a, b)=>a.localeCompare(b)) +]; const execute = function(val, props){ if(_.isFunction(val)) return val(props); @@ -151,7 +170,7 @@ const Snippetbar = createReactClass({ this.props.updateEditorTheme(e.target.value); this.setState({ - showThemeSelector : false, + themeSelector : false, }); }, @@ -232,11 +251,11 @@ const Snippetbar = createReactClass({ { this.state.showHistory && this.renderHistoryItems() }
    -
    -
    @@ -302,36 +321,33 @@ const SnippetGroup = createReactClass({ }; }, handleSnippetClick : function(e, snippet){ - e.stopPropagation(); this.props.onSnippetClick(execute(snippet.gen, this.props)); }, renderSnippets : function(snippets){ return _.map(snippets, (snippet)=>{ - return
    this.handleSnippetClick(e, snippet)}> - - {snippet.name} - {snippet.experimental && beta} - {snippet.disabled && disabled} - {snippet.subsnippets && <> - -
    + if(!snippet.subsnippets){ + return ( + + ); + } else if(snippet.subsnippets){ + return ( + {this.renderSnippets(snippet.subsnippets)} -
    } -
    ; + + ) + } }); }, render : function(){ - const snippetGroup = `snippetGroup snippetBarButton ${this.props.snippets.length === 0 ? 'disabledSnippets' : ''}`; - return
    -
    - - {this.props.groupName} -
    -
    - {this.renderSnippets(this.props.snippets)} -
    -
    ; + return + {this.renderSnippets(this.props.snippets)} + ; }, }); diff --git a/client/homebrew/editor/snippetbar/snippetbar.less b/client/homebrew/editor/snippetbar/snippetbar.less index 37853ca75..88cdcbc00 100644 --- a/client/homebrew/editor/snippetbar/snippetbar.less +++ b/client/homebrew/editor/snippetbar/snippetbar.less @@ -11,6 +11,10 @@ height : auto; color : black; background-color : #DDDDDD; + font-size : .65rem; + font-family: 'Open Sans', sans-serif; + text-transform: uppercase; + font-weight: 800; .snippets { display : flex; @@ -22,7 +26,7 @@ display : flex; justify-content : flex-end; min-width : 250px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied - + font-size: .85rem; &:only-child {min-width : unset; margin-left : auto;} >div { @@ -57,32 +61,27 @@ } &.undo { .tooltipLeft('Undo'); - font-size : 0.75em; color : grey; &.active { color : inherit; } } &.redo { .tooltipLeft('Redo'); - font-size : 0.75em; color : grey; &.active { color : inherit; } } &.foldAll { .tooltipLeft('Fold All'); - font-size : 0.75em; color : grey; &.active { color : inherit; } } &.unfoldAll { .tooltipLeft('Unfold All'); - font-size : 0.75em; color : grey; &.active { color : inherit; } } &.history { .tooltipLeft('History'); position : relative; - font-size : 0.75em; color : grey; border : none; &.active { color : inherit; } @@ -93,7 +92,6 @@ } &.editorTheme { .tooltipLeft('Editor Themes'); - font-size : 0.75em; color : inherit; &.active { position : relative; @@ -144,91 +142,97 @@ border-left : 1px solid black; .tooltipLeft('Edit Brew Properties'); } - .snippetGroup { - &:hover { - & > .dropdown { visibility : visible; } - } - .dropdown { - position : absolute; - top : 100%; - z-index : 1000; - visibility : hidden; - padding : 0px; - margin-left : -5px; - background-color : #DDDDDD; - .snippet { - position : relative; - display : flex; - align-items : center; - min-width : max-content; - padding : 5px; - font-size : 10px; - cursor : pointer; - .animate(background-color); - i { - min-width : 25px; - height : 1.2em; - margin-right : 8px; - font-size : 1.2em; - text-align : center; - & ~ i { - margin-right : 0; - margin-left : 5px; - } - /* Fonts */ - &.font { - height : auto; - &::before { - font-size : 1em; - content : 'ABC'; - } - - &.OpenSans {font-family : 'OpenSans';} - &.CodeBold {font-family : 'CodeBold';} - &.CodeLight {font-family : 'CodeLight';} - &.ScalySansRemake {font-family : 'ScalySansRemake';} - &.BookInsanityRemake {font-family : 'BookInsanityRemake';} - &.MrEavesRemake {font-family : 'MrEavesRemake';} - &.SolberaImitationRemake {font-family : 'SolberaImitationRemake';} - &.ScalySansSmallCapsRemake {font-family : 'ScalySansSmallCapsRemake';} - &.WalterTurncoat {font-family : 'WalterTurncoat';} - &.Lato {font-family : 'Lato';} - &.Courier {font-family : 'Courier';} - &.NodestoCapsCondensed {font-family : 'NodestoCapsCondensed';} - &.Overpass {font-family : 'Overpass';} - &.Davek {font-family : 'Davek';} - &.Iokharic {font-family : 'Iokharic';} - &.Rellanic {font-family : 'Rellanic';} - &.TimesNewRoman {font-family : 'Times New Roman';} - } - } - .name { margin-right : auto; } - .disabled { text-decoration : line-through; } - .beta { - align-self : center; - padding : 4px 6px; - margin-left : 5px; - font-family : monospace; - line-height : 1em; - color : white; - background : grey; - border-radius : 12px; - } - &:hover { - background-color : #999999; - & > .dropdown { - visibility : visible; - &.side { - top : 0%; - left : 100%; - margin-left : 0; - box-shadow : -1px 1px 2px 0px #999999; - } - } - } + + .menu-wrapper { + .menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child { + .caret { + display: none; } } + .menu-list { + padding : 0px; + background-color : #DDDDDD; + + } + } + + .menu-item { + position : relative; + display : flex; + justify-content: space-between; + align-items : center; + min-width : max-content; + padding : 5px; + cursor : pointer; + width: 100%; + .animate(background-color); + &:is(.menu-list .menu-item) [class*="name"] { + padding-inline: 8px; + } + .menu-name { + flex: 1; + text-align: left; + text-box-trim: trim-end; + } + i { + min-width : 25px; + height : .85rem; + font-size : 1.2em; + text-align : center; + &.caret { + margin-right: 0; + } + &.caret:is(.menu-wrapper .menu-wrapper * ) { + text-align: right; + } + /* Fonts */ + &.font { + height : auto; + &::before { + font-size : 1em; + content : 'ABC'; + } + + &.OpenSans {font-family : 'OpenSans';} + &.CodeBold {font-family : 'CodeBold';} + &.CodeLight {font-family : 'CodeLight';} + &.ScalySansRemake {font-family : 'ScalySansRemake';} + &.BookInsanityRemake {font-family : 'BookInsanityRemake';} + &.MrEavesRemake {font-family : 'MrEavesRemake';} + &.SolberaImitationRemake {font-family : 'SolberaImitationRemake';} + &.ScalySansSmallCapsRemake {font-family : 'ScalySansSmallCapsRemake';} + &.WalterTurncoat {font-family : 'WalterTurncoat';} + &.Lato {font-family : 'Lato';} + &.Courier {font-family : 'Courier';} + &.NodestoCapsCondensed {font-family : 'NodestoCapsCondensed';} + &.Overpass {font-family : 'Overpass';} + &.Davek {font-family : 'Davek';} + &.Iokharic {font-family : 'Iokharic';} + &.Rellanic {font-family : 'Rellanic';} + &.TimesNewRoman {font-family : 'Times New Roman';} + } + } + .name { margin-right : auto; } + .disabled { text-decoration : line-through; } + .beta { + align-self : center; + padding : 4px 6px; + margin-left : 5px; + font-family : monospace; + line-height : 1em; + color : white; + background : grey; + border-radius : 12px; + } + &:hover { + background-color : #999999; + } + &:disabled { + color: gray; + cursor: not-allowed; + &:hover { background-color: unset; } + } } .disabledSnippets { color: grey; diff --git a/client/homebrew/editor/tagInput/curatedTagSuggestionList.js b/client/homebrew/editor/tagInput/curatedTagSuggestionList.js index d433175ef..7d692ecb0 100644 --- a/client/homebrew/editor/tagInput/curatedTagSuggestionList.js +++ b/client/homebrew/editor/tagInput/curatedTagSuggestionList.js @@ -1,210 +1,219 @@ -export default [ +export const tagSuggestionList = [ // ############################## Systems // D&D - "system:D&D Original", - "system:D&D Basic", - "system:AD&D 1e", - "system:AD&D 2e", - "system:D&D 3e", - "system:D&D 3.5e", - "system:D&D 4e", - "system:D&D 5e", - "system:D&D 5e 2024", - "system:BD&D (B/X)", - "system:D&D Essentials", + 'system:D&D Original', + 'system:D&D Basic', + 'system:AD&D 1e', + 'system:AD&D 2e', + 'system:D&D 3e', + 'system:D&D 3.5e', + 'system:D&D 4e', + 'system:D&D 5e', + 'system:D&D 5e 2024', + 'system:BD&D (B/X)', + 'system:D&D Essentials', // Other Famous RPGs - "system:Pathfinder 1e", - "system:Pathfinder 2e", - "system:Vampire: The Masquerade", - "system:Werewolf: The Apocalypse", - "system:Mage: The Ascension", - "system:Call of Cthulhu", - "system:Shadowrun", - "system:Star Wars RPG (D6/D20/Edge of the Empire)", - "system:Warhammer Fantasy Roleplay", - "system:Cyberpunk 2020", - "system:Blades in the Dark", - "system:Daggerheart", - "system:Draw Steel", - "system:Mutants and Masterminds", + 'system:Pathfinder 1e', + 'system:Pathfinder 2e', + 'system:Vampire: The Masquerade', + 'system:Werewolf: The Apocalypse', + 'system:Mage: The Ascension', + 'system:Call of Cthulhu', + 'system:Shadowrun', + 'system:Star Wars RPG (D6/D20/Edge of the Empire)', + 'system:Warhammer Fantasy Roleplay', + 'system:Cyberpunk 2020', + 'system:Blades in the Dark', + 'system:Daggerheart', + 'system:Draw Steel', + 'system:Mutants and Masterminds', // Meta - "meta:V3", - "meta:Legacy", - "meta:Template", - "meta:Theme", - "meta:free", - "meta:Character Sheet", - "meta:Documentation", - "meta:NPC", - "meta:Guide", - "meta:Resource", - "meta:Notes", - "meta:Example", + 'meta:V3', + 'meta:Legacy', + 'meta:Template', + 'meta:Theme', + 'meta:free', + 'meta:Character Sheet', + 'meta:Documentation', + 'meta:NPC', + 'meta:Guide', + 'meta:Resource', + 'meta:Notes', + 'meta:Example', // Book type - "type:Campaign", - "type:Campaign Setting", - "type:Adventure", - "type:One-Shot", - "type:Setting", - "type:World", - "type:Lore", - "type:History", - "type:Dungeon Master", - "type:Encounter Pack", - "type:Encounter", - "type:Session Notes", - "type:reference", - "type:Handbook", - "type:Manual", - "type:Manuals", - "type:Compendium", - "type:Bestiary", + 'type:Campaign', + 'type:Campaign Setting', + 'type:Adventure', + 'type:One-Shot', + 'type:Setting', + 'type:World', + 'type:Lore', + 'type:History', + 'type:Dungeon Master', + 'type:Encounter Pack', + 'type:Encounter', + 'type:Session Notes', + 'type:reference', + 'type:Handbook', + 'type:Manual', + 'type:Manuals', + 'type:Compendium', + 'type:Bestiary', // ###################################### RPG Keywords // Classes / Subclasses / Archetypes - "Class", - "Subclass", - "Archetype", - "Martial", - "Half-Caster", - "Full Caster", - "Artificer", - "Barbarian", - "Bard", - "Cleric", - "Druid", - "Fighter", - "Monk", - "Paladin", - "Rogue", - "Sorcerer", - "Warlock", - "Wizard", + 'Class', + 'Subclass', + 'Archetype', + 'Martial', + 'Half-Caster', + 'Full Caster', + 'Artificer', + 'Barbarian', + 'Bard', + 'Cleric', + 'Druid', + 'Fighter', + 'Monk', + 'Paladin', + 'Rogue', + 'Sorcerer', + 'Warlock', + 'Wizard', // Races / Species / Lineages - "Race", - "Ancestry", - "Lineage", - "Aasimar", - "Beastfolk", - "Dragonborn", - "Dwarf", - "Elf", - "Goblin", - "Half-Elf", - "Half-Orc", - "Human", - "Kobold", - "Lizardfolk", - "Lycan", - "Orc", - "Tiefling", - "Vampire", - "Yuan-Ti", + 'Race', + 'Ancestry', + 'Lineage', + 'Aasimar', + 'Beastfolk', + 'Dragonborn', + 'Dwarf', + 'Elf', + 'Goblin', + 'Half-Elf', + 'Half-Orc', + 'Human', + 'Kobold', + 'Lizardfolk', + 'Lycan', + 'Orc', + 'Tiefling', + 'Vampire', + 'Yuan-Ti', // Magic / Spells / Items - "Magic", - "Magic Item", - "Magic Items", - "Wondrous Item", - "Magic Weapon", - "Artifact", - "Spell", - "Spells", - "Cantrip", - "Cantrips", - "Eldritch", - "Eldritch Invocation", - "Invocation", - "Invocations", - "Pact boon", - "Pact Boon", - "Spellcaster", - "Spellblade", - "Magical Tattoos", - "Enchantment", - "Enchanted", - "Attunement", - "Requires Attunement", - "Rune", - "Runes", - "Wand", - "Rod", - "Scroll", - "Potion", - "Potions", - "Item", - "Items", - "Bag of Holding", + 'Magic', + 'Magic Item', + 'Magic Items', + 'Wondrous Item', + 'Magic Weapon', + 'Artifact', + 'Spell', + 'Spells', + 'Cantrip', + 'Cantrips', + 'Eldritch', + 'Eldritch Invocation', + 'Invocation', + 'Invocations', + 'Pact boon', + 'Pact Boon', + 'Spellcaster', + 'Spellblade', + 'Magical Tattoos', + 'Enchantment', + 'Enchanted', + 'Attunement', + 'Requires Attunement', + 'Rune', + 'Runes', + 'Wand', + 'Rod', + 'Scroll', + 'Potion', + 'Potions', + 'Item', + 'Items', + 'Bag of Holding', // Monsters / Creatures / Enemies - "Monster", - "Creatures", - "Creature", - "Beast", - "Beasts", - "Humanoid", - "Undead", - "Fiend", - "Aberration", - "Ooze", - "Giant", - "Dragon", - "Monstrosity", - "Demon", - "Devil", - "Elemental", - "Construct", - "Constructs", - "Boss", - "BBEG", + 'Monster', + 'Creatures', + 'Creature', + 'Beast', + 'Beasts', + 'Humanoid', + 'Undead', + 'Fiend', + 'Aberration', + 'Ooze', + 'Giant', + 'Dragon', + 'Monstrosity', + 'Demon', + 'Devil', + 'Elemental', + 'Construct', + 'Constructs', + 'Boss', + 'BBEG', // ############################# Media / Pop Culture - "One Piece", - "Dragon Ball", - "Dragon Ball Z", - "Naruto", - "Jujutsu Kaisen", - "Fairy Tail", - "Final Fantasy", - "Kingdom Hearts", - "Elder Scrolls", - "Skyrim", - "WoW", - "World of Warcraft", - "Marvel Comics", - "DC Comics", - "Pokemon", - "League of Legends", - "Runeterra", - "Arcane", - "Yu-Gi-Oh", - "Minecraft", - "Don't Starve", - "Witcher", - "Witcher 3", - "Cyberpunk", - "Cyberpunk 2077", - "Fallout", - "Divinity Original Sin 2", - "Fullmetal Alchemist", - "Fullmetal Alchemist Brotherhood", - "Lobotomy Corporation", - "Bloodborne", - "Dragonlance", - "Shackled City Adventure Path", - "Baldurs Gate 3", - "Library of Ruina", - "Radiant Citadel", - "Ravenloft", - "Forgotten Realms", - "Exandria", - "Critical Role", - "Star Wars", - "SW5e", - "Star Wars 5e", + 'One Piece', + 'Dragon Ball', + 'Dragon Ball Z', + 'Naruto', + 'Jujutsu Kaisen', + 'Fairy Tail', + 'Final Fantasy', + 'Kingdom Hearts', + 'Elder Scrolls', + 'Skyrim', + 'WoW', + 'World of Warcraft', + 'Marvel Comics', + 'DC Comics', + 'Pokemon', + 'League of Legends', + 'Runeterra', + 'Arcane', + 'Yu-Gi-Oh', + 'Minecraft', + 'Don\'t Starve', + 'Witcher', + 'Witcher 3', + 'Cyberpunk', + 'Cyberpunk 2077', + 'Fallout', + 'Divinity Original Sin 2', + 'Fullmetal Alchemist', + 'Fullmetal Alchemist Brotherhood', + 'Lobotomy Corporation', + 'Bloodborne', + 'Dragonlance', + 'Shackled City Adventure Path', + 'Baldurs Gate 3', + 'Library of Ruina', + 'Radiant Citadel', + 'Ravenloft', + 'Forgotten Realms', + 'Exandria', + 'Critical Role', + 'Star Wars', + 'SW5e', + 'Star Wars 5e', ]; + +// substrings to be normalized to the first value on the array +export const canonizationList = [ + ['5e 2024', '5.5e', '5e\'24', '5.24', '5e24', '5.5'], + ['5e', '5th Edition'], + ['Dungeons & Dragons', 'Dungeons and Dragons', 'Dungeons n dragons'], + ['D&D', 'DnD', 'dnd', 'Dnd', 'dnD', 'd&d', 'd&D', 'D&d'], + ['P2e', 'p2e', 'P2E', 'Pathfinder 2e'], +]; \ No newline at end of file diff --git a/client/homebrew/editor/tagInput/tagInput.jsx b/client/homebrew/editor/tagInput/tagInput.jsx index 7f4a3a77f..aee33e8f6 100644 --- a/client/homebrew/editor/tagInput/tagInput.jsx +++ b/client/homebrew/editor/tagInput/tagInput.jsx @@ -1,71 +1,62 @@ -import "./tagInput.less"; -import React, { useState, useEffect } from "react"; -import Combobox from "../../../components/combobox.jsx"; +import './tagInput.less'; +import React, { useState, useEffect } from 'react'; +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( - values.map((value) => ({ + values.map((value)=>({ value, - editing: false, - draft: "", + editing : false, + draft : '', })), ); - useEffect(() => { + useEffect(()=>{ 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( - incoming.map((value) => ({ + incoming.map((value)=>({ value, - editing: false, + editing : false, })), ); } }, [values]); - useEffect(() => { + useEffect(()=>{ onChange?.({ - target: { value: tagList.map((t) => t.value) }, + target : { value: tagList.map((t)=>t.value) }, }); }, [tagList]); - // substrings to be normalized to the first value on the array - const duplicateGroups = [ - ["5e 2024", "5.5e", "5e'24", "5.24", "5e24", "5.5"], - ["5e", "5th Edition"], - ["Dungeons & Dragons", "Dungeons and Dragons", "Dungeons n dragons"], - ["D&D", "DnD", "dnd", "Dnd", "dnD", "d&d", "d&D", "D&d"], - ["P2e", "p2e", "P2E", "Pathfinder 2e"], - ]; - - const normalizeValue = (input) => { + const normalizeValue = (input)=>{ const lowerInput = input.toLowerCase(); let normalizedTag = input; - for (const group of duplicateGroups) { + for (const group of canonizationList) { for (const tag of group) { - if (!tag) continue; + if(!tag) continue; const index = lowerInput.indexOf(tag.toLowerCase()); - if (index !== -1) { + if(index !== -1) { normalizedTag = input.slice(0, index) + group[0] + input.slice(index + tag.length); break; } } } - if (normalizedTag.includes(":")) { - const [rawType, rawValue = ""] = normalizedTag.split(":"); + if(normalizedTag.includes(':')) { + const [rawType, rawValue = ''] = normalizedTag.split(':'); const tagType = rawType.trim().toLowerCase(); const tagValue = rawValue.trim(); - if (tagValue.length > 0) { + if(tagValue.length > 0) { normalizedTag = `${tagType}:${tagValue[0].toUpperCase()}${tagValue.slice(1)}`; } //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; }; - const submitTag = (newValue, index = null) => { + const submitTag = (newValue, index = null)=>{ const trimmed = newValue?.trim(); - if (!trimmed) return; - if (!valuePatterns.test(trimmed)) return; + if(!trimmed) return; + if(!valuePatterns.test(trimmed)) return; const normalizedTag = normalizeValue(trimmed); - setTagList((prev) => { - const existsIndex = prev.findIndex((t) => t.value.toLowerCase() === normalizedTag.toLowerCase()); - if (unique && existsIndex !== -1) return prev; - if (index !== null) { - return prev.map((t, i) => (i === index ? { ...t, value: normalizedTag, editing: false } : t)); + setTagList((prev)=>{ + const existsIndex = prev.findIndex((t)=>t.value.toLowerCase() === normalizedTag.toLowerCase()); + if(unique && existsIndex !== -1) return prev; + if(index !== null) { + return prev.map((t, i)=>(i === index ? { ...t, value: normalizedTag, editing: false } : t)); } return [...prev, { value: normalizedTag, editing: false }]; }); }; - const removeTag = (index) => { - setTagList((prev) => prev.filter((_, i) => i !== index)); + const removeTag = (index)=>{ + setTagList((prev)=>prev.filter((_, i)=>i !== index)); }; - const editTag = (index) => { - setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: true, draft: t.value } : t))); + const editTag = (index)=>{ + setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: true, draft: t.value } : t))); }; - const stopEditing = (index) => { - setTagList((prev) => prev.map((t, i) => (i === index ? { ...t, editing: false, draft: "" } : t))); + const stopEditing = (index)=>{ + setTagList((prev)=>prev.map((t, i)=>(i === index ? { ...t, editing: false, draft: '' } : t))); }; - const suggestionOptions = tagSuggestionList.map((tag) => { - const tagType = tag.split(":"); + const suggestionOptions = tagSuggestionList.map((tag)=>{ + const tagType = tag.split(':'); - let classes = "item"; + let classes = 'item'; switch (tagType[0]) { - case "type": - classes = "item type"; - break; - case "group": - classes = "item group"; - break; - case "meta": - classes = "item meta"; - break; - case "system": - classes = "item system"; - break; - default: - classes = "item"; - break; + case 'type': + classes = 'item type'; + break; + case 'group': + classes = 'item group'; + break; + case 'meta': + classes = 'item meta'; + break; + case 'system': + classes = 'item system'; + break; + default: + classes = 'item'; + break; } return ( @@ -135,73 +126,69 @@ const TagInput = ({tooltip, label, valuePatterns, values = [], unique = true, pl }); return ( -
    +
    submitTag(value)} - onEntry={(e) => { - if (e.key === "Enter") { + onSelect={(value)=>submitTag(value)} + onEntry={(e)=>{ + if(e.key === 'Enter') { e.preventDefault(); submitTag(e.target.value); } }} /> -
      - {tagList.map((t, i) => - t.editing ? ( - - setTagList((prev) => - prev.map((tag, idx) => (idx === i ? { ...tag, draft: e.target.value } : tag)), - ) +
        + {tagList.map((t, i)=>t.editing ? ( + 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 === "Enter") { - e.preventDefault(); - submitTag(t.draft, i); // submit draft - setTagList((prev) => - prev.map((tag, idx) => (idx === i ? { ...tag, draft: "" } : tag)), - ); - } - if (e.key === "Escape") { - stopEditing(i); - e.target.blur(); - } - }} - autoFocus - /> - ) : ( -
      • editTag(i)}> - {t.value} - -
      • - ), + if(e.key === 'Escape') { + stopEditing(i); + e.target.blur(); + } + }} + autoFocus + /> + ) : ( +
      • editTag(i)}> + {t.value} + +
      • + ), )}
    diff --git a/client/homebrew/editor/tagInput/tagSuggestionList.js b/client/homebrew/editor/tagInput/tagSuggestionList.js index 6b8e4060e..eb4891616 100644 --- a/client/homebrew/editor/tagInput/tagSuggestionList.js +++ b/client/homebrew/editor/tagInput/tagSuggestionList.js @@ -1,1980 +1,1980 @@ export default [ - "meta:Theme", - "5e", - "Subclass", - "meta:theme", - "subclass", - "Class", - "Homebrew", - "Race", - "Dungeons and Dragons", - "theme", - "Daggerheart", - "2024", - "One Piece", - "One Piece DND", - "Luffy", - "Dungeons and Devil Fruits", - "Strawhats", - "Template", - "Campaign Frame", - "class", - "Players Handbook", - "dnd", - "osr", - "Dungeon Masters Guide", - "shadowdark", - "dragonbane", - "PHB", - "example", - "Devil Fruits", - "system:pf2e", - "DnD", - "DMG", - "system:dnd5.5", - "Monster", - "homebrew", - "race", - "template", - "Warlock", - "monster", - "Fighter", - "warlock", - "druid", - "sorcerer", - "D&D", - "Magic Item", - "Barbarian", - "Artificer", - "2014", - "system:descent into avernus", - "Sorcerer", - "Adventure", - "Paladin", - "Ranger", - "user help", - "fighter", - "5th Edition", - "Spells", - "Monk", - "Spell", - "NPC", - "Cleric", - "spell", - "Rogue", - "css", - "Item", - "artificer", - "magic item", - "Rules", - "barbarian", - "wizard", - "russian", - "DnD5e", - "Wizard", - "paladin", - "bastionland", - "spells", - "Devil Fruit", - "Bard", - "5.5e", - "rogue", - "Tabletop System", - "Haki", - "Druid", - "mystic bastionland", - "item", - "Lore", - "bard", - "monk", - "system:dnd5e", - "world", - "ranger", - "WIP", - "cleric", - "Dragon", - "Naruto", - "Creature", - "snippet", - "npc", - "DeS", - "Magic", - "guide", - "v3", - "Beast", - "Classe", - "onering", - "Monsters", - "Races", - "Weapon", - "adventure", - "Subclasses", - "stat block", - "weapon", - "Species", - "DONE", - "archetype", - "RPG", - "Hollow Knight", - "5e'24", - "Martial", - "DND", - "Classe Nova", - "Curse of Strahd", - "Boss", - "Hollowed Kingdoms", - "baldurs mouth", - "5.24", - "Homewbrew", - "Encyclopedia", - "Revised", - "OneWorldHD", - "knight", - "DPS", - "srd", - "Undead", - "items", - "DnD 5e", - "Guide", - "Compendium", - "Feat", - "newspaper", - "magic", - "TTRPG", - "descent into avernus", - "reference", - "system:D&D 5e24", - "feat", - "Magic Items", - "Campaign", - "resource", - "Feats", - "Anime", - "dd5", - "races", - "Monstrosity", - "DM Screen", - "2024 Rules", - "Rework", - "Character Build", - "Done", - "5e 2024", - "Construct", - "myth", - "magic items", - "creature", - "Legendary", - "Strahd", - "background", - "Player", - "style", - "Legacy", - "Player Handbook", - "martial", - "Character", - "Dungeons & Dragons", - "Table", - "reddit", - "monsters", - "OneDND", - "dragon", - "Suporte", - "Soulbound", - "Expanded Handbook", - "bestiary", - "Humanoid", - "system:dnd", - "Oredell", - "system:class", - "V3", - "system:5e", - "5e'14", - "Age of Sigmar", - "Items", - "Eberron", - "horror", - "dnd5e", - "star wars", - "Background", - "compendium", - "revision", - "Elemental", - "character", - "Marcial", - "SW5e", - "notes", - "DM", - "Fey", - "one-shot", - "resources", - "NEW", - "campaign", - "wondrous item", - "Setting", - "Archive", - "NIMRE", - "Tanque", - "Jogavel", - "Dnd 5e", - "LotM", - "setting", - "revised", - "Warhammer", - "rework", - "Conjurador", - "GMBinder", - "ItemSet", - "NPCs", - "classes", - "CR 2", - "AetherSail", - "undead", - "Artifact", - "cards", - "Example", - "Guides", - "Theme", - "Swamp", - "CR 1", - "patron", - "Extra", - "concept", - "final fantasy", - "Aberration", - "Elder Scrolls", - "rules", - "meta:gratis", - "Dice Pool", - "Cue", - "dark", - "type:Style", - "rpg", - "meta:free", - "summoner", - "OC", - "rare", - "Fleshing Out", - "francais", - "Dnd", - "Creatures", - "Sims 4", - "sous-classe", - "ffxiv", - "Underdark", - "add-on", - "weapons", - "Subrace", - "dungeons and dragons", - "Naruto 5e", - "Lafari", - "Very Rare", - "d6", - "red", - "Equipment", - "CR 3", - "Patron", - "5E", - "objet magique", - "spellcaster", - "feats", - "5.24e", - "system:d&d5e", - "Rare", - "Thudnfer", - "daggerheart", - "CR 1/2", - "Archetype", - "dnd 5e", - "Pathfinder", - "lineage", - "Celestial", - "Support", - "species", - "ARCHIVED", - "Horror", - "humanoid", - "Subclase", - "book:PHB E&E", - "final fantasy xiv", - "Jungle", - "Feywild", - "Fiend", - "subclasses", - "HB", - "Legacy Challenge", - "Project Horizon", - "vampire", - "WoW", - "DND 5e", - "Weapons", - "system:book clone", - "CoS", - "N5e", - "Summon", - "Spellcaster", - "Koretra", - "Voidborn", - "one shot", - "Templates", - "tables", - "Iphexar", - "Shattered Obelisk", - "Sword Coast", - "elemental", - "lore", - "character sheet", - "discord", - "BetterMonsters", - "players", - "group:simple skans", - "Coastal", - "Forest", - "Unearthed Arcana", - "Old", - "Collection", - "Ancestry", - "Caevash", - "Strixhaven", - "Limbus Company", - "Daydreams & Deviants", - "Statblocks", - "Urban", - "Classes", - "familiar", - "Blood", - "oneshot", - "n5e", - "wip", - "masks", - "Planeshifted", - "Carrioss", - "avernus", - "object", - "1", - "Ruins", - "Anime Character", - "wild shape", - "mask", - "dj9 game", - "Handbook", - "Curse", - "oath", - "Midralis", - "Appendix", - "5.5", - "tales of the valiant", - "player classes", - "Caster", - "Large", - "Fateforge", - "Cursed", - "beast", - "Pathfinder 2e", - "Design", - "Uncommon", - "Endeur", - "Elemental Water", - "SCC", - "sci-fi", - "Dragons", - "human", - "Gods", - "Medium", - "System", - "Help", - "Handout", - "Skyrim", - "BnB", - "draft", - "PC", - "Variant", - "syntax", - "OneShot", - "Clase", - "UESTRPG", - "Savannah", - "Reef", - "CR 5", - "Forgotten Realms", - "collection", - "Combat", - "Historia", - "artifact", - "Expansion", - "Attunement", - "Variant Rules", - "d&d", - "Party Build", - "Evocation", - "homerule", - "Forbidden West", - "how-to", - "tov", - "Mystical Item", - "CR 4", - "Necromancer", - "General Rules", - "Needs Update", - "stat blocks", - "DC Comics", - "Sword", - "Armor", - "blood hunter", - "Blood Hunter", - "Meio Conjurador", - "Mydia", - "3rd Party", - "N5E", - "Delvebound", - "Ocean", - "Subterranean", - "half-caster", - "Demon", - "Fire", - "meta:Template", - "Character Sheet", - "PF2e", - "png", - "fantasy", - "Setting Guide", - "Style", - "Cor", - "legacy", - "Minion", - "meta:khaoz age", - "Book", - "magical item", - "immersion", - "reminder cards", - "Regras", - "Durnovar", - "2025", - "Camp1", - "Abyss", - "armor", - "construct", - "firearms", - "Backgrounds", - "Companion", - "Melee", - "fey", - "Incomplete", - "Vampire", - "Bestiary", - "Worldbuilding", - "History", - "Conjuration", - "necromancy", - "dragons", - "ancestry", - "Mech", - "PbtA", - "COS", - "One Shot", - "Factions", - "Transmutation", - "Spellcasting", - "card", - "Ardh", - "redveil", - "conditions", - "elturel", - "rhye", - "group:James Haeck", - "CaelYuu", - "system:5.24e", - "system:GM Binder", - "Grassland", - "melee", - "Potions", - "anime", - "D&D 5e", - "Healer", - "Gambling", - "Lightning", - "fighting style", - "support", - "Style Template", - "mtg", - "NSFW", - "aventura", - "Manuals", - "plant", - "Incarnate", - "Crafting", - "DnDBeyond", - "Monster Girl", - "Monster Girl Encyclopedia", - "pet", - "npcs", - "Stat Block", - "Styleguide", - "mitologia", - "Objeto maravilloso", - "vaesen rpg", - "dnd-2024", - "Class Handbook", - "Space", - "Taiga", - "CR 6", - "Pact Boon", - "race/ancestry", - "Necromancy", - "cantrip", - "LoL", - "Raza", - "handout", - "Mechanic", - "Conversion", - "Wondrous Item", - "Familiar", - "necromancer", - "uncommon", - "curse", - "Campaign Setting", - "Tetra", - "1e", - "module", - "Evolving", - "boiling sea", - "deck", - "OSR", - "RU", - "VL", - "Underdeep", - "Deep Ocean", - "Giant", - "statblock", - "combat", - "Time", - "5E24", - "session zero", - "elf", - "tank", - "sorcerous origin", - "Drakkenheim", - "Tavern", - "Domain", - "healer", - "Valenor", - "blood", - "Oath", - "spooky", - "fire", - "meta:5e24 Style", - "Notes", - "City", - "Conjurador Completo", - "prop", - "Dotherys", - "Rietuma 3.0", - "5e24", - "Library of Ruina", - "español", - "Project Echo", - "battle of Japan", - "Plant", - "Badlands", - "Neverwinter", - "Fantasy", - "Beastfolk", - "Unarmed", - "Cold", - "Damage", - "attunement", - "Hurthud", - "3rd Level", - "spelljammer", - "mostro", - "Custom", - "PT-BR", - "Alternative Realms", - "The Foot", - "boss", - "demo", - "Supplement", - "FitD", - "classe", - "5.14", - "Copy", - "DnD5e24", - "X-Men", - "TNA", - "CR 8", - "Desert", - "CR 7", - "arme", - "random", - "spellcasting", - "Deprecated", - "Cards", - "finished", - "Ben 10", - "equipment", - "Geography", - "Games", - "For Players", - "Faerun", - "scroll", - "Faction", - "Alchemist", - "drow", - "Lineage", - "mix-blend-mode", - "columns", - "User Help", - "Reami Dimenticati", - "Класс", - "D100", - "nsfw", - "hucaen", - "v1.0", - "Cortex", - "Fallout", - "ww5e", - "MAGIC", - "DnD2024", - "ToV", - "D&D2024", - "The Backrooms", - "Freshwater", - "D20", - "Dragonborn", - "custom", - "sword", - "Dungeons and Dragons 5e", - "Water", - "legendary", - "Dungeon", - "Ravenloft", - "aberration", - "Longsword", - "transmutation", - "Fairy Tail", - "Character background", - "Exandria", - "Updated", - "pitch", - "Half-Caster", - "Complete", - "Money", - "player", - "forgotten-realms", - "Festival", - "Casino", - "SCAG", - "Currency", - "North", - "Toril", - "Scourged Land of Valenor", - "Oota", - "parchment", - "Literature", - "Serrith", - "PNJ", - "Divinity Original Sin 2", - "Wild", - "videogame", - "magic the gathering", - "sweetblossom", - "GMscreen", - "MandM", - "D&D 5.24", - "Camp2", - "Remaster", - "riassunti", - "type:Resource", - "system:D&D", - "tag:Class", - "Excelsior", - "Stat Blocks", - "Sci-Fi", - "Ooze", - "CR 1/8", - "sublclass", - "chart", - "Mountains", - "guns", - "Nature", - "Orc", - "Poison", - "Devil", - "fiend", - "DC", - "pt-br", - "ABnB", - "One-Shot", - "strahd", - "Ring", - "Theme song", - "orc", - "summon", - "Psion", - "Psionics", - "Dungeon Master", - "vehicle", - "DM only", - "Demigod", - "Antica Energia", - "Pirates", - "Sourcebook", - "devil", - "Cantrip", - "mystery", - "MtG", - "conversion", - "Festivals", - "Casinos", - "Taverns", - "Betting", - "Drinking", - "phandelver", - "Warhammer 40k", - "mutant", - "styling", - "FATE", - "Lone Wolf", - "icon", - "New Dawn", - "Magic Set", - "Paladin Subclass", - "Alter Class", - "difficulty classses", - "combat tables", - "phb", - "Project Moon", - "Undertomes", - "EGO", - "Campagne 1", - "Constelação", - "Arvore I", - "Fim da Jornada", - "greek god", - "dwarf", - "Firearms", - "3.5e", - "generator", - "Elf", - "meta: Scenario", - "enchantment", - "buff", - "ITW", - "Tank", - "Archived", - "Martial Archetype", - "caster", - "BR", - "Knight", - "Utility", - "SWADE", - "Star Wars", - "pc", - "Mystic", - "Useful", - "Netherdeep", - "crafting", - "Sapient Undead", - "Maverick", - "Revision", - "Resource", - "Humblewood", - "one piece", - "Bag of Holding", - "medium", - "lightning", - "backgrounds", - "4th Level", - "path", - "BREAK-RPG", - "dark fantasy", - "Players", - "poison", - "psionic", - "gazook89", - "homebrew subclass", - "wild-wasteland", - "CWD", - "Paid", - "Tales of the Valiant", - "Dreadhold", - "arma", - "system:Mutants and Masterminds", - "#Tiefschlaf", - "Brew", - "Myra", - "Swashbuckler", - "dead by daylight", - "Exceptional", - "COD Zombies", - "Hills", - "Tundra", - "type:Campaign", - "wild magic", - "Food", - "Death", - "homebrew rules", - "Remake", - "Witcher", - "water", - "Pet", - "book", - "AAH", - "pact", - "Ice", - "Character Creation", - "animal", - "Pokemon", - "clase", - "5e14", - "DBZ", - "CLONE", - "Evil", - "Tarsere", - "Mythology", - "pf2e", - "Magical", - "type:race", - "Sorcerous Origin", - "Information", - "styles", - "Module", - "gish", - "frames", - "DeltaGreen", - "Magic item", - "food", - "chef", - "basics", - "giant", - "Brew Creation", - "One-shot", - "ttrpg", - "Path", - "Don't Starve", - "MGE", - "firearm", - "DnDBehindTheScreen", - "store", - "The Artisan", - "timeline", - "college", - "dev", - "dungeon of the dead three", - "Cradle", - "Dnd5e", - "dungeon", - "Amaranthine", - "Regno di Oltremare", - "bestia", - "rewrite", - "WiP", - "Subclasse", - "mutants and masterminds", - "The Embrace", - "meta:documentation", - "Mutants And Masterminds", - "khedoria", - "Encounter Pack", - "giorni", - "Statblock", - "Enemies", - "Goblinoids", - "Heavens", - "system:2e", - "Vaalbara", - "Dwarf", - "airos", - "table", - "Artificer Specialization", - "Buff", - "Book 1", - "Ranged", - "cypher", - "utilities", - "40k", - "Psychic", - "Fear", - "steampunk", - "shadow", - "subclase", - "Barbarian Subclass", - "Elements", - "pact boon", - "Clan", - "Fly", - "solo", - "sourcebook", - "Marvel Comics", - "compilation", - "Firearm", - "sidekick", - "infusions", - "Mechanics", - "Summoner", - "Aasimar", - "Human", - "Vehicle", - "Shadow", - "Clone", - "custom css", - "ocean", - "sotdl", - "bandit", - "Wind", - "Printer Friendly", - "Obsolete", - "mechanics", - "illusion", - "5th edition", - "League of Legends", - "Vestige", - "dungeons", - "Dungeons", - "and", - "Elden Ring", - "L5R", - "d20", - "Poisons", - "d15", - "Dungeons And Dragons", - "MTG", - "divine", - "characters", - "witch", - "Anime Homebrew", - "Zombie", - "thunder", - "Jujutsu Kaisen", - "campagne", - "Deadlands", - "spell list", - "1 Person", - "Ritual", - "screen", - "nature", - "Divination", - "Compattare", - "dtrpg", - "quick ref", - "Mago", - "Illivia", - "Shonen", - "Core Deities", - "green ronin", - "Bless", - "D&D5e", - "version:0.1.0", - "curato", - "system:Ord", - "Images", - "Sealed Artifact", - "Giants", - "CR 9", - "CR 11", - "CR 0", - "CR 14", - "Shadowfell", - "Tier 1", - "d100", - "Elemental Air", - "artificiel", - "Cultist", - "Cyberpunk", - "Huge", - "Warrior", - "Gun", - "quest", - "LYRA", - "Music", - "Tiefling", - "Master", - "Witch", - "Linnorm", - "1st-level", - "Mount", - "Animal", - "Comics", - "Superhero", - "creatures", - "Hunter", - "Control", - "Dragon Ball", - "Dragon Ball Z", - "Dagger", - "questingforamonster", - "ICRPG", - "Booklet", - "f and t", - "common", - "Chaos", - "spellblade", - "Constitution", - "artisan", - "arcane", - "Released", - "ring", - "runes", - "gun", - "Supportive Material", - "The Witcher", - "Desarmado", - "Monster Monday", - "Bleach", - "Demon Slayer", - "mice", - "worldbuilding", - "Necrotic", - "ability score", - "demon", - "Armybook Shivatiano", - "warrior", - "Fighter Subclass", - "system", - "whisperveil", - "psychic", - "warhammer", - "Aventura", - "Culture", - "Material", - "meta:npc", - "shops", - "magic weapon", - "nhera", - "Dark Fantasy", - "Regles", - "Wonderous Item", - "Features", - "pokemon", - "Ghosts of Saltmarsh", - "monstrosity", - "DL TWW", - "companion", - "alternate layout", - "Tutorials", - "Kitsune", - "don", - "heroique", - "mini campaign", - "drago", - "Aquatic", - "tool", - "handmade", - "released", - "Spellblade", - "pregen", - "level 2", - "Baldurs gate 3", - "My Hero", - "Technically a subclass", - "5.24e Remastered Subclasses", - "dinosaurs", - "5E.2024", - "Razas", - "Horizon", - "Clothing", - "+2", - "castellano", - "pentacle prophecy", - "tag:Spells", - "gruppo A", - "Rpg", - "razze", - "type:Adventure", - "unfinished", - "3.5", - "gunslinger", - "BBEG", - "Arcane", - "component", - "Bow", - "backstory", - "phandalin", - "Skills", - "Pact", - "Elemental Earth", - "Joke", - "invocation", - "martial class", - "Super Villain", - "Eldritch", - "Elemental Fire", - "Homebrew Class", - "eldritch", - "cyberpunk", - "Player Race", - "Class Mod", - "Heatcoast", - "meta:Guide", - "Yemao", - "evil", - "Named NPC", - "CLASS", - "Angel", - "vecna", - "PT", - "PTBR", - "Ancient", - "Small", - "WotC Style", - "5e Homebrew", - "1st Level", - "dagger", - "Brancalonia", - "encounter", - "cat", - "primal path", - "Ambientazione", - "Magie", - "candlekeep", - "Ongoing", - "Oneshot", - "Wondrous", - "Janbrewery", - "Tattoos", - "5e (2014)", - "concentration", - "very rare", - "Set", - "Kobold", - "martial archetype", - "God", - "blog", - "New Gate", - "Healing", - "OneDnD", - "Incantesimi", - "Player Options", - "contest", - "pirate", - "Manuel", - "Alchimie", - "Herboristerie", - "Ingredients", - "starlost", - "Campaign 1", - "Abandoned", - "Previous Editions", - "Enchantment", - "Tools", - "Oblivion", - "domain", - "5th Level", - "DnD Beyond", - "Reference", - "Sorcerer Subclass", - "Dragon Magazine", - "feature", - "german", - "conjuration", - "strixhaven", - "Sentient", - "JJK", - "10 Generations", - "character creation", - "LevelUp", - "pallid grove", - "primer", - "Requires Attunement", - "College", - "Aesthetic", - "critter", - "home game", - "spanish", - "stats", - "Lairon", - "Hunters Guild", - "original setting", - "Bosses", - "Radiant Citadel", - "actions", - "Reworked", - "Elystera", - "Wyvern", - "vikings", - "thief", - "enemies", - "Obsession", - "Yi Sang", - "aberrazione", - "Limbus", - "animals", - "minecraft", - "mice of legend", - "osric", - "20 Minutes Till Dawn", - "campaign frame", - "latigo", - "DH", - "Eldritch Invocation", - "system:daggerheart", - "100ni", - "meta:Sheet", - "fa-solid fa-sheet-plastic:Ficha", - "tag:Berean", - "AD&D", - "B/X", - "The Codex Of Anomalous Entities", - "monster manual", - "Polar Waters", - "CR 12", - "CR 10", - "blood magic", - "Gunslinger", - "grimoire", - "Drakes", - "Japanese", - "subrace", - "ooze", - "Stats", - "Half Caster", - "Sea", - "time", - "Brawler", - "Session 0", - "Halloween", - "Runeterra", - "Divine", - "Random", - "Lifestar", - "arcane trickster", - "Paddy4530", - "evocation", - "light", - "Steampunk", - "shaman", - "Primal Path", - "monk subclass", - "Full Caster", - "World", - "Planning", - "spirit", - "Nova Era", - "abjuration", - "Christmas", - "Critical Role", - "Gish", - "Bandit", - "Monster Manual", - "party member", - "mgazt", - "Playable Race", - "Donjon.bin.sh", - "Final Fantasy", - "Roleplay", - "monstre", - "fairy", - "frame", - "Minecraft", - "Stealth", - "Manual", - "half caster", - "Storm", - "Sorcery", - "format work", - "Kingdom Hearts", - "hexblade", - "block", - "page layouts", - "Monk Subclass", - "FinyaFluKaiKolja", - "Radiant", - "group:playtest", - "Korrahir", - "noble", - "exorcist", - "xapien", - "Raven Queen", - "markdown", - "damage", - "Alchemy", - "morrigan", - "genasi", - "ZNH", - "folklore", - "Fate", - "hechicero", - "Air", - "Magic Weapon", - "Anime DND 5e", - "Dragon Ball Z TTRPG", - "Dragon Ball Z RPG", - "Dragon Ball Z DND", - "Dragon Ball Z 5e", - "samurai", - "Goblin", - "Base Sheet", - "Shackled City Adventure Path", - "Natureza", - "control", - "Normarch", - "Reddit", - "Genshin Impact", - "Abjuration", - "Myr", - "Flight", - "Vampyre", - "nightmare", - "Lycan", - "Occult", - "circle", - "Christmas Special", - "DoDD", - "Character Options", - "traduction", - "Characters", - "Gear", - "system:sf2e", - "drakkenheim", - "downtime", - "amulet", - "Feiticeiros e Maldicoes", - "Tecnica amaldicoada", - "prorpg", - "enemy", - "No Mercy", - "rain world", - "slugcat", - "fly", - "meta:User Guide", - "Fallout TTRPG", - "regles", - "Ill Tides", - "Light-hearted", - "Vastria", - "school", - "Fillible Online", - "Mezgarr", - "Berserk", - "invocations", - "Classe Refeita", - "Auroboros", - "bosses", - "fabula ultima", - "Shagya", - "wild", - "DnD 2024", - "KaiburrKathHound", - "Barbarian Path", - "fauna", - "5E.2014", - "system:curse of strahd", - "Unofficial", - "how to", - "Glaive", - "A5E", - "pt", - "Consumible", - "Realmers'", - "Versatile Lineage", - "Shichibukai", - "2024e", - "Rencontre", - "tag:Spell List", - "elementalist", - "noncaster", - "blasphemous", - "Mordhiem", - "Wildfrost", - "#Regelwerk", - "Rewrite", - "Maldición de Strahd", - "Scion", - "Entities", - "Hoarwyrm", - "Player utility", - "CR 1/4", - "Temperate Forest", - "Demons", - "Drow", - "type:rules", - "fay", - "2e", - "familier", - "supplement", - "Amberwar", - "slime", - "Lycanthropy", - "meta: Terres de Leyt", - "Strong", - "AAH Vol. 1", - "Force", - "Jump", - "Aboleths", - "lol", - "location", - "small", - "customizable", - "Modern", - "Sky", - "portugues", - "Hero", - "Villain", - "element", - "Tyranny of Dragons", - "Adventure Guide", - "New Class", - "Witchlight", - "Shardblade", - "Plateaux", - "WOTC", - "Snippet", - "Terra", - "Otherworldly Patron", - "ritual", - "hag", - "Cyberpunk 2077", - "tavern", - "Artificer Specialist", - "Werewolf", - "Boesia", - "vampiric", - "monastic tradition", - "Gothic", - "celestial", - "Unfinished", - "Core", - "Arcane Tradition", - "Troll", - "Origin", - "Draconic", - "dj9 member", - "test", - "Hag", - "gem", - "Invocations", - "Dark Sun", - "aarkhen", - "How to", - "ravenloft", - "faerie", - "Playtest", - "Shaman", - "dead", - "Tomba Aniquilacio", - "Pacto", - "fullcaster", - "Electric", - "Ability Score", - "4D", - "pathfinder", - "insect", - "hook", - "page layout", - "healing", - "Lineages", - "Flying", - "Martial Arts", - "journal", - "Aide de jeu", - "hunter", - "headers", - "Dark Souls", - "courtyard", - "crossroads", - "Quest", - "CotF", - "defense", - "Semryss", - "invoked class", - "Session Notes", - "goblin", - "infernal", - "fate", - "oni", - "spellbook", - "Summoning", - "slut", - "whore", - "Greyhawk", - "Mobility", - "Reddit Remake", - "Guild", - "Cosmic Mart", - "7th Level", - "dragonborn", - "curse of strahd", - "Ranger Subclass", - "dossier", - "dossie", - "de", - "pnpde", - "Plane Shift", - "halloween", - "group:aventura", - "9th Level", - "tome", - "cold", - "acid", - "deprecated", - "mind flayer", - "MECHA", - "EssentialsKit", - "2d6", - "ToD", - "Work In Progress", - "Bond", - "Versatile", - "Dead", - "SYWTBAGM", - "summoning", - "english", - "Eilistraee", - "Draft", - "DoD", - "map", - "Frightened", - "Psychic Damage", - "eberron", - "recompensa", - "wizard subclass", - "teiran", - "Saltmarsh", - "jp setting", - "Illithid", - "Longbow", - "hell", - "Monarch", - "type:feat", - "reglas", - "cooking", - "Abenteuer", - "reloaded", - "incompleto", - "mecanica", - "Location", - "Grimlores Grimoire", - "2024 Subclass", - "Chiesa di Toleno", - "finalfantasy", - "The Undertomes", - "Lobotomy Corporation", - "SDHTA", - "D&D 2024", - "other", - "ally", - "images", - "Player's Guide", - "Avalon Sword", - "Cael'Yuu", - "dnd-2014", - "Regelwerk", - "Español", - "br", - "dnd 5.0", - "monstro", - "grand cemetery", - "Phoenix", - "dnd 2024", - "Bloodhunter", - "Sintonizacion", - "dungeons & dragons", - "Fix", - "Rulebook", - "Shadowdark", - "heroic", - "HFW", - "Earthdawn", - "24e", - "cormyr", - "suzail", - "dc20", - "tag:Rules", - "The Griffon's Saddlebag", - "LOTM", - "tag:Adventure", - "drunken master", - "eldritch invocation", - "Персонаж", - "Orcs", - "Lizardfolk", - "Frostfell", - "CR 17", - "Shapechanger", - "Farmland", - "Mages", - "Any", - "CR 13", - "Earth", - "Mountain", - "Drake", - "transformation", - "GM", - "Lich", - "lovecraft", - "unique", - "Optional Rules", - "int", - "creator", - "Primal", - "simple", - "golem", - "Void", - "Armour", - "spellsword", - "General", - "Asian", - "Bringers of chaos", - "Optional Feature", - "subraces", - "Galanoth", - "barbarian subclass", - "felhearth", - "modular", - "Vampires", - "wysteria", - "adaptation", - "beasts", - "naruto", - "ninja", - "Psionic", - "Guns", - "Crystal", - "Guardian", - "NonProfit", - "Mimic", - "languages", - "Epic Boons", - "Primer", - "Icewind Dale", - "joke", - "lycan", - "CR3", - "Armors", - "ff7", - "materia", - "final fantasy 7 remake", - "esper", - "ff7 remake", - "gargantuan", - "Frog", - "CR5", - "blank", - "monster hunter", - "league of legends", - "french", - "Pokémon", - "kobold", - "soul", - "ffxi", - "d10", - "Roman", - "Cute", - "DD5", - "variant", - "tree", - "fr", - "Scenario", - "lycanthrope", - "druide", - "staff", - "eios", - "arkheneios", - "Runic", - "Work", - "Ukrainian", - "cover-page", - "mage", - "deities", - "gods", - "Boss Fight", - "Lair", - "WBTW", - "roguish archetype", - "Character Option", - "Shortsword", - "Illrigger", - "Bloodborne", - "cr6", - "Priest", - "Hamon", - "Toonkind", - "rol", - "Strength", - "forgotten realms", - "Spanish", - "Conclave", - "Electro", - "Magical Tattoos", - "Matt Mercer", - "Wildemount", - "Mighty Nien", - "Campaign 2", - "Resistances", - "Bug", - "impression", - "PF", - "Magnus Archives", - "ice", - "speed", - "Generic NPC", - "Titanic", - "Ink Friendly", - "bleed", - "elder scrolls", - "Immortal", - "LMOP", - "Travel", - "Olphus", - "3d6", - "heist", - "World History", - "ghost", - "genie", - "kids on bikes", - "Russia", - "conclave", - "overhaul", - "manual", - "Adventures In Eden", - "Downtime", - "hamon", - "cloak", - "shadowfell", - "Hellfire", - "Paladin Oath", - "Genshin", - "Nation", - "air", - "Magical Item", - "War", - "Original", - "Monstrous Compendium", - "Calamity", - "Warden", - "Apocalypse", - "shield", - "AC", - "expansion", - "Concentration", - "charm", - "Weave", - "lycanthropy", - "raza", - "far realm", - "fighter subclass", - "ita", - "Pirate", - "Laranja", - "Grapple", - "EastByForce", - "hobgoblin", - "oneshot-notes", - "Holy", - "optional", - "type:cenario", - "group:core", - "The Brewery", - "Alcance", - "Morrowind", - "Indigo", - "Divino", - "2nd Level", - "Sub-Class", - "cantrips", - "Cloak", - "battle master", - "Dark", - "Puzzle", - "Lucky", - "consumable", - "rebalance", - "Shove", - "Area Control", - "Vanguard", - "funny", - "e5", - "Dragonlance", - "psion", - "initiative", - "Tactician", - "Inspiration", - "artificier", - "way", - "inspired", - "historia", - "Medusa", - "2 part", - "holy", - "gift", - "Nimble", - "mostri", - "phoenix", - "travel", - "Class Template", - "Intimidation", - "constructs", - "P666", - "Formatting", - "Divinity", - "Rod", - "Language", - "yokai", - "rune", - "western", - "vampires", - "flying", - "cute", - "Enemy", - "boon", - "Tables", - "ShadowFight", - "meta: Theme", - "SCS", - "vanthampur villa", - "CoA", - "shop", - "destiny", - "magical weapon", - "Arcane Arcade", - "XP to Level 3", - "Dice Average RPG", - "Pip-Boy", - "Dragon Heist", - "session notes", - "tattoo", - "flick", - "P6:66", - "Comic Character", - "experiment", - "Minerva", - "type:Spellbook", - "Realmfall", - "Wand", - "halfling", - "sw5e", - "implementar AP", - "Mask", - "Gazook89", - "Weltenrauch-Chroniken", - "MiA", - "Made in Abyss", - "français", - "fae", - "Lemuria", - "Mork Borg", - "guerrier", - "prunus", - "condition", - "pf2", - "tr", - "costrutto", - "German", - "project moon", - "5r", - "galles", - "Project moon", - "Yisang", - "Spicebush", - "player-accessible", - "Especie", - "Westmarch", - "a", - "Cart", - "Magus", - "group:Mchael Galvis", - "tip", - "werewolf", - "mundane", - "garrett", - "unarmed", - "Arcane Odyssey", - "Tomb of Divinity", - "pets", - "Video Game", - "4 part", - "pbta", - "Druids", - "multiclass", - "manuale", - "mimic", - "plane shift", - "Dotes", - "Hechizos", - "Infernal", - "Enhanced", - "done", - "Mission report", - "Blanks", - "Masks", - "Ultimate Ability", - "shadow-slave", - "Advertising", - "transform", - "Fullmetal Alchemist", - "Fullmetal Alchemist Brotherhood", - "tag:TAoF&F", - "Dwarves", - "Humans", - "Nine Hells", - "Devils", - "Archons", - "CR 15", - "Troglodytes", - "Goliath", - "retired", - "boots", - "ranged", - "shields", - "Zhentarim", - "World of Warcraft", - "Frontline", - "Guildmaster's Guide to Ravnica", - "Dungeons & Dragons 5e", - "beholder", - "NEEDS FIXING", - "mechanic", - "Loot", - "champion", - "Runes", - "Shield", - "Punch", - "Sniper", - "Magical Girl", - "NotDND", - "story", - "Sleep", - "Bard College", - "Illusion", - "Thunder", - "Defender", - "Genasi", - "troll", - "Gehenna", - "Yugoloth", - "social", - "Player Class", - "homebrew class", - "CR 16", - "Ghost", - "Kobolds", - "Trolls", - "Yuan-Ti", - "Elder Scrolls Offline", - "armure", - "Mage", - "CR 18", - "Technology", - "Mystery", - "darkness", - "Airship", - "New Campaign", - "Warframe", - "Wizard Subclass", - "Gold", - "Candor", - "Overhaul", - "Dragon Knight", - "Enoreth", - "Artifacts", - "New", - "AMMO", - "Campagne", - "Valbise", - "Subclasseptember", - "Mecha", - "Yu-Gi-Oh", - "Goblinoid", - "underwater", - "SW5E", - "bardo" -] \ No newline at end of file + 'meta:Theme', + '5e', + 'Subclass', + 'meta:theme', + 'subclass', + 'Class', + 'Homebrew', + 'Race', + 'Dungeons and Dragons', + 'theme', + 'Daggerheart', + '2024', + 'One Piece', + 'One Piece DND', + 'Luffy', + 'Dungeons and Devil Fruits', + 'Strawhats', + 'Template', + 'Campaign Frame', + 'class', + 'Players Handbook', + 'dnd', + 'osr', + 'Dungeon Masters Guide', + 'shadowdark', + 'dragonbane', + 'PHB', + 'example', + 'Devil Fruits', + 'system:pf2e', + 'DnD', + 'DMG', + 'system:dnd5.5', + 'Monster', + 'homebrew', + 'race', + 'template', + 'Warlock', + 'monster', + 'Fighter', + 'warlock', + 'druid', + 'sorcerer', + 'D&D', + 'Magic Item', + 'Barbarian', + 'Artificer', + '2014', + 'system:descent into avernus', + 'Sorcerer', + 'Adventure', + 'Paladin', + 'Ranger', + 'user help', + 'fighter', + '5th Edition', + 'Spells', + 'Monk', + 'Spell', + 'NPC', + 'Cleric', + 'spell', + 'Rogue', + 'css', + 'Item', + 'artificer', + 'magic item', + 'Rules', + 'barbarian', + 'wizard', + 'russian', + 'DnD5e', + 'Wizard', + 'paladin', + 'bastionland', + 'spells', + 'Devil Fruit', + 'Bard', + '5.5e', + 'rogue', + 'Tabletop System', + 'Haki', + 'Druid', + 'mystic bastionland', + 'item', + 'Lore', + 'bard', + 'monk', + 'system:dnd5e', + 'world', + 'ranger', + 'WIP', + 'cleric', + 'Dragon', + 'Naruto', + 'Creature', + 'snippet', + 'npc', + 'DeS', + 'Magic', + 'guide', + 'v3', + 'Beast', + 'Classe', + 'onering', + 'Monsters', + 'Races', + 'Weapon', + 'adventure', + 'Subclasses', + 'stat block', + 'weapon', + 'Species', + 'DONE', + 'archetype', + 'RPG', + 'Hollow Knight', + '5e\'24', + 'Martial', + 'DND', + 'Classe Nova', + 'Curse of Strahd', + 'Boss', + 'Hollowed Kingdoms', + 'baldurs mouth', + '5.24', + 'Homewbrew', + 'Encyclopedia', + 'Revised', + 'OneWorldHD', + 'knight', + 'DPS', + 'srd', + 'Undead', + 'items', + 'DnD 5e', + 'Guide', + 'Compendium', + 'Feat', + 'newspaper', + 'magic', + 'TTRPG', + 'descent into avernus', + 'reference', + 'system:D&D 5e24', + 'feat', + 'Magic Items', + 'Campaign', + 'resource', + 'Feats', + 'Anime', + 'dd5', + 'races', + 'Monstrosity', + 'DM Screen', + '2024 Rules', + 'Rework', + 'Character Build', + 'Done', + '5e 2024', + 'Construct', + 'myth', + 'magic items', + 'creature', + 'Legendary', + 'Strahd', + 'background', + 'Player', + 'style', + 'Legacy', + 'Player Handbook', + 'martial', + 'Character', + 'Dungeons & Dragons', + 'Table', + 'reddit', + 'monsters', + 'OneDND', + 'dragon', + 'Suporte', + 'Soulbound', + 'Expanded Handbook', + 'bestiary', + 'Humanoid', + 'system:dnd', + 'Oredell', + 'system:class', + 'V3', + 'system:5e', + '5e\'14', + 'Age of Sigmar', + 'Items', + 'Eberron', + 'horror', + 'dnd5e', + 'star wars', + 'Background', + 'compendium', + 'revision', + 'Elemental', + 'character', + 'Marcial', + 'SW5e', + 'notes', + 'DM', + 'Fey', + 'one-shot', + 'resources', + 'NEW', + 'campaign', + 'wondrous item', + 'Setting', + 'Archive', + 'NIMRE', + 'Tanque', + 'Jogavel', + 'Dnd 5e', + 'LotM', + 'setting', + 'revised', + 'Warhammer', + 'rework', + 'Conjurador', + 'GMBinder', + 'ItemSet', + 'NPCs', + 'classes', + 'CR 2', + 'AetherSail', + 'undead', + 'Artifact', + 'cards', + 'Example', + 'Guides', + 'Theme', + 'Swamp', + 'CR 1', + 'patron', + 'Extra', + 'concept', + 'final fantasy', + 'Aberration', + 'Elder Scrolls', + 'rules', + 'meta:gratis', + 'Dice Pool', + 'Cue', + 'dark', + 'type:Style', + 'rpg', + 'meta:free', + 'summoner', + 'OC', + 'rare', + 'Fleshing Out', + 'francais', + 'Dnd', + 'Creatures', + 'Sims 4', + 'sous-classe', + 'ffxiv', + 'Underdark', + 'add-on', + 'weapons', + 'Subrace', + 'dungeons and dragons', + 'Naruto 5e', + 'Lafari', + 'Very Rare', + 'd6', + 'red', + 'Equipment', + 'CR 3', + 'Patron', + '5E', + 'objet magique', + 'spellcaster', + 'feats', + '5.24e', + 'system:d&d5e', + 'Rare', + 'Thudnfer', + 'daggerheart', + 'CR 1/2', + 'Archetype', + 'dnd 5e', + 'Pathfinder', + 'lineage', + 'Celestial', + 'Support', + 'species', + 'ARCHIVED', + 'Horror', + 'humanoid', + 'Subclase', + 'book:PHB E&E', + 'final fantasy xiv', + 'Jungle', + 'Feywild', + 'Fiend', + 'subclasses', + 'HB', + 'Legacy Challenge', + 'Project Horizon', + 'vampire', + 'WoW', + 'DND 5e', + 'Weapons', + 'system:book clone', + 'CoS', + 'N5e', + 'Summon', + 'Spellcaster', + 'Koretra', + 'Voidborn', + 'one shot', + 'Templates', + 'tables', + 'Iphexar', + 'Shattered Obelisk', + 'Sword Coast', + 'elemental', + 'lore', + 'character sheet', + 'discord', + 'BetterMonsters', + 'players', + 'group:simple skans', + 'Coastal', + 'Forest', + 'Unearthed Arcana', + 'Old', + 'Collection', + 'Ancestry', + 'Caevash', + 'Strixhaven', + 'Limbus Company', + 'Daydreams & Deviants', + 'Statblocks', + 'Urban', + 'Classes', + 'familiar', + 'Blood', + 'oneshot', + 'n5e', + 'wip', + 'masks', + 'Planeshifted', + 'Carrioss', + 'avernus', + 'object', + '1', + 'Ruins', + 'Anime Character', + 'wild shape', + 'mask', + 'dj9 game', + 'Handbook', + 'Curse', + 'oath', + 'Midralis', + 'Appendix', + '5.5', + 'tales of the valiant', + 'player classes', + 'Caster', + 'Large', + 'Fateforge', + 'Cursed', + 'beast', + 'Pathfinder 2e', + 'Design', + 'Uncommon', + 'Endeur', + 'Elemental Water', + 'SCC', + 'sci-fi', + 'Dragons', + 'human', + 'Gods', + 'Medium', + 'System', + 'Help', + 'Handout', + 'Skyrim', + 'BnB', + 'draft', + 'PC', + 'Variant', + 'syntax', + 'OneShot', + 'Clase', + 'UESTRPG', + 'Savannah', + 'Reef', + 'CR 5', + 'Forgotten Realms', + 'collection', + 'Combat', + 'Historia', + 'artifact', + 'Expansion', + 'Attunement', + 'Variant Rules', + 'd&d', + 'Party Build', + 'Evocation', + 'homerule', + 'Forbidden West', + 'how-to', + 'tov', + 'Mystical Item', + 'CR 4', + 'Necromancer', + 'General Rules', + 'Needs Update', + 'stat blocks', + 'DC Comics', + 'Sword', + 'Armor', + 'blood hunter', + 'Blood Hunter', + 'Meio Conjurador', + 'Mydia', + '3rd Party', + 'N5E', + 'Delvebound', + 'Ocean', + 'Subterranean', + 'half-caster', + 'Demon', + 'Fire', + 'meta:Template', + 'Character Sheet', + 'PF2e', + 'png', + 'fantasy', + 'Setting Guide', + 'Style', + 'Cor', + 'legacy', + 'Minion', + 'meta:khaoz age', + 'Book', + 'magical item', + 'immersion', + 'reminder cards', + 'Regras', + 'Durnovar', + '2025', + 'Camp1', + 'Abyss', + 'armor', + 'construct', + 'firearms', + 'Backgrounds', + 'Companion', + 'Melee', + 'fey', + 'Incomplete', + 'Vampire', + 'Bestiary', + 'Worldbuilding', + 'History', + 'Conjuration', + 'necromancy', + 'dragons', + 'ancestry', + 'Mech', + 'PbtA', + 'COS', + 'One Shot', + 'Factions', + 'Transmutation', + 'Spellcasting', + 'card', + 'Ardh', + 'redveil', + 'conditions', + 'elturel', + 'rhye', + 'group:James Haeck', + 'CaelYuu', + 'system:5.24e', + 'system:GM Binder', + 'Grassland', + 'melee', + 'Potions', + 'anime', + 'D&D 5e', + 'Healer', + 'Gambling', + 'Lightning', + 'fighting style', + 'support', + 'Style Template', + 'mtg', + 'NSFW', + 'aventura', + 'Manuals', + 'plant', + 'Incarnate', + 'Crafting', + 'DnDBeyond', + 'Monster Girl', + 'Monster Girl Encyclopedia', + 'pet', + 'npcs', + 'Stat Block', + 'Styleguide', + 'mitologia', + 'Objeto maravilloso', + 'vaesen rpg', + 'dnd-2024', + 'Class Handbook', + 'Space', + 'Taiga', + 'CR 6', + 'Pact Boon', + 'race/ancestry', + 'Necromancy', + 'cantrip', + 'LoL', + 'Raza', + 'handout', + 'Mechanic', + 'Conversion', + 'Wondrous Item', + 'Familiar', + 'necromancer', + 'uncommon', + 'curse', + 'Campaign Setting', + 'Tetra', + '1e', + 'module', + 'Evolving', + 'boiling sea', + 'deck', + 'OSR', + 'RU', + 'VL', + 'Underdeep', + 'Deep Ocean', + 'Giant', + 'statblock', + 'combat', + 'Time', + '5E24', + 'session zero', + 'elf', + 'tank', + 'sorcerous origin', + 'Drakkenheim', + 'Tavern', + 'Domain', + 'healer', + 'Valenor', + 'blood', + 'Oath', + 'spooky', + 'fire', + 'meta:5e24 Style', + 'Notes', + 'City', + 'Conjurador Completo', + 'prop', + 'Dotherys', + 'Rietuma 3.0', + '5e24', + 'Library of Ruina', + 'español', + 'Project Echo', + 'battle of Japan', + 'Plant', + 'Badlands', + 'Neverwinter', + 'Fantasy', + 'Beastfolk', + 'Unarmed', + 'Cold', + 'Damage', + 'attunement', + 'Hurthud', + '3rd Level', + 'spelljammer', + 'mostro', + 'Custom', + 'PT-BR', + 'Alternative Realms', + 'The Foot', + 'boss', + 'demo', + 'Supplement', + 'FitD', + 'classe', + '5.14', + 'Copy', + 'DnD5e24', + 'X-Men', + 'TNA', + 'CR 8', + 'Desert', + 'CR 7', + 'arme', + 'random', + 'spellcasting', + 'Deprecated', + 'Cards', + 'finished', + 'Ben 10', + 'equipment', + 'Geography', + 'Games', + 'For Players', + 'Faerun', + 'scroll', + 'Faction', + 'Alchemist', + 'drow', + 'Lineage', + 'mix-blend-mode', + 'columns', + 'User Help', + 'Reami Dimenticati', + 'Класс', + 'D100', + 'nsfw', + 'hucaen', + 'v1.0', + 'Cortex', + 'Fallout', + 'ww5e', + 'MAGIC', + 'DnD2024', + 'ToV', + 'D&D2024', + 'The Backrooms', + 'Freshwater', + 'D20', + 'Dragonborn', + 'custom', + 'sword', + 'Dungeons and Dragons 5e', + 'Water', + 'legendary', + 'Dungeon', + 'Ravenloft', + 'aberration', + 'Longsword', + 'transmutation', + 'Fairy Tail', + 'Character background', + 'Exandria', + 'Updated', + 'pitch', + 'Half-Caster', + 'Complete', + 'Money', + 'player', + 'forgotten-realms', + 'Festival', + 'Casino', + 'SCAG', + 'Currency', + 'North', + 'Toril', + 'Scourged Land of Valenor', + 'Oota', + 'parchment', + 'Literature', + 'Serrith', + 'PNJ', + 'Divinity Original Sin 2', + 'Wild', + 'videogame', + 'magic the gathering', + 'sweetblossom', + 'GMscreen', + 'MandM', + 'D&D 5.24', + 'Camp2', + 'Remaster', + 'riassunti', + 'type:Resource', + 'system:D&D', + 'tag:Class', + 'Excelsior', + 'Stat Blocks', + 'Sci-Fi', + 'Ooze', + 'CR 1/8', + 'sublclass', + 'chart', + 'Mountains', + 'guns', + 'Nature', + 'Orc', + 'Poison', + 'Devil', + 'fiend', + 'DC', + 'pt-br', + 'ABnB', + 'One-Shot', + 'strahd', + 'Ring', + 'Theme song', + 'orc', + 'summon', + 'Psion', + 'Psionics', + 'Dungeon Master', + 'vehicle', + 'DM only', + 'Demigod', + 'Antica Energia', + 'Pirates', + 'Sourcebook', + 'devil', + 'Cantrip', + 'mystery', + 'MtG', + 'conversion', + 'Festivals', + 'Casinos', + 'Taverns', + 'Betting', + 'Drinking', + 'phandelver', + 'Warhammer 40k', + 'mutant', + 'styling', + 'FATE', + 'Lone Wolf', + 'icon', + 'New Dawn', + 'Magic Set', + 'Paladin Subclass', + 'Alter Class', + 'difficulty classses', + 'combat tables', + 'phb', + 'Project Moon', + 'Undertomes', + 'EGO', + 'Campagne 1', + 'Constelação', + 'Arvore I', + 'Fim da Jornada', + 'greek god', + 'dwarf', + 'Firearms', + '3.5e', + 'generator', + 'Elf', + 'meta: Scenario', + 'enchantment', + 'buff', + 'ITW', + 'Tank', + 'Archived', + 'Martial Archetype', + 'caster', + 'BR', + 'Knight', + 'Utility', + 'SWADE', + 'Star Wars', + 'pc', + 'Mystic', + 'Useful', + 'Netherdeep', + 'crafting', + 'Sapient Undead', + 'Maverick', + 'Revision', + 'Resource', + 'Humblewood', + 'one piece', + 'Bag of Holding', + 'medium', + 'lightning', + 'backgrounds', + '4th Level', + 'path', + 'BREAK-RPG', + 'dark fantasy', + 'Players', + 'poison', + 'psionic', + 'gazook89', + 'homebrew subclass', + 'wild-wasteland', + 'CWD', + 'Paid', + 'Tales of the Valiant', + 'Dreadhold', + 'arma', + 'system:Mutants and Masterminds', + '#Tiefschlaf', + 'Brew', + 'Myra', + 'Swashbuckler', + 'dead by daylight', + 'Exceptional', + 'COD Zombies', + 'Hills', + 'Tundra', + 'type:Campaign', + 'wild magic', + 'Food', + 'Death', + 'homebrew rules', + 'Remake', + 'Witcher', + 'water', + 'Pet', + 'book', + 'AAH', + 'pact', + 'Ice', + 'Character Creation', + 'animal', + 'Pokemon', + 'clase', + '5e14', + 'DBZ', + 'CLONE', + 'Evil', + 'Tarsere', + 'Mythology', + 'pf2e', + 'Magical', + 'type:race', + 'Sorcerous Origin', + 'Information', + 'styles', + 'Module', + 'gish', + 'frames', + 'DeltaGreen', + 'Magic item', + 'food', + 'chef', + 'basics', + 'giant', + 'Brew Creation', + 'One-shot', + 'ttrpg', + 'Path', + 'Don\'t Starve', + 'MGE', + 'firearm', + 'DnDBehindTheScreen', + 'store', + 'The Artisan', + 'timeline', + 'college', + 'dev', + 'dungeon of the dead three', + 'Cradle', + 'Dnd5e', + 'dungeon', + 'Amaranthine', + 'Regno di Oltremare', + 'bestia', + 'rewrite', + 'WiP', + 'Subclasse', + 'mutants and masterminds', + 'The Embrace', + 'meta:documentation', + 'Mutants And Masterminds', + 'khedoria', + 'Encounter Pack', + 'giorni', + 'Statblock', + 'Enemies', + 'Goblinoids', + 'Heavens', + 'system:2e', + 'Vaalbara', + 'Dwarf', + 'airos', + 'table', + 'Artificer Specialization', + 'Buff', + 'Book 1', + 'Ranged', + 'cypher', + 'utilities', + '40k', + 'Psychic', + 'Fear', + 'steampunk', + 'shadow', + 'subclase', + 'Barbarian Subclass', + 'Elements', + 'pact boon', + 'Clan', + 'Fly', + 'solo', + 'sourcebook', + 'Marvel Comics', + 'compilation', + 'Firearm', + 'sidekick', + 'infusions', + 'Mechanics', + 'Summoner', + 'Aasimar', + 'Human', + 'Vehicle', + 'Shadow', + 'Clone', + 'custom css', + 'ocean', + 'sotdl', + 'bandit', + 'Wind', + 'Printer Friendly', + 'Obsolete', + 'mechanics', + 'illusion', + '5th edition', + 'League of Legends', + 'Vestige', + 'dungeons', + 'Dungeons', + 'and', + 'Elden Ring', + 'L5R', + 'd20', + 'Poisons', + 'd15', + 'Dungeons And Dragons', + 'MTG', + 'divine', + 'characters', + 'witch', + 'Anime Homebrew', + 'Zombie', + 'thunder', + 'Jujutsu Kaisen', + 'campagne', + 'Deadlands', + 'spell list', + '1 Person', + 'Ritual', + 'screen', + 'nature', + 'Divination', + 'Compattare', + 'dtrpg', + 'quick ref', + 'Mago', + 'Illivia', + 'Shonen', + 'Core Deities', + 'green ronin', + 'Bless', + 'D&D5e', + 'version:0.1.0', + 'curato', + 'system:Ord', + 'Images', + 'Sealed Artifact', + 'Giants', + 'CR 9', + 'CR 11', + 'CR 0', + 'CR 14', + 'Shadowfell', + 'Tier 1', + 'd100', + 'Elemental Air', + 'artificiel', + 'Cultist', + 'Cyberpunk', + 'Huge', + 'Warrior', + 'Gun', + 'quest', + 'LYRA', + 'Music', + 'Tiefling', + 'Master', + 'Witch', + 'Linnorm', + '1st-level', + 'Mount', + 'Animal', + 'Comics', + 'Superhero', + 'creatures', + 'Hunter', + 'Control', + 'Dragon Ball', + 'Dragon Ball Z', + 'Dagger', + 'questingforamonster', + 'ICRPG', + 'Booklet', + 'f and t', + 'common', + 'Chaos', + 'spellblade', + 'Constitution', + 'artisan', + 'arcane', + 'Released', + 'ring', + 'runes', + 'gun', + 'Supportive Material', + 'The Witcher', + 'Desarmado', + 'Monster Monday', + 'Bleach', + 'Demon Slayer', + 'mice', + 'worldbuilding', + 'Necrotic', + 'ability score', + 'demon', + 'Armybook Shivatiano', + 'warrior', + 'Fighter Subclass', + 'system', + 'whisperveil', + 'psychic', + 'warhammer', + 'Aventura', + 'Culture', + 'Material', + 'meta:npc', + 'shops', + 'magic weapon', + 'nhera', + 'Dark Fantasy', + 'Regles', + 'Wonderous Item', + 'Features', + 'pokemon', + 'Ghosts of Saltmarsh', + 'monstrosity', + 'DL TWW', + 'companion', + 'alternate layout', + 'Tutorials', + 'Kitsune', + 'don', + 'heroique', + 'mini campaign', + 'drago', + 'Aquatic', + 'tool', + 'handmade', + 'released', + 'Spellblade', + 'pregen', + 'level 2', + 'Baldurs gate 3', + 'My Hero', + 'Technically a subclass', + '5.24e Remastered Subclasses', + 'dinosaurs', + '5E.2024', + 'Razas', + 'Horizon', + 'Clothing', + '+2', + 'castellano', + 'pentacle prophecy', + 'tag:Spells', + 'gruppo A', + 'Rpg', + 'razze', + 'type:Adventure', + 'unfinished', + '3.5', + 'gunslinger', + 'BBEG', + 'Arcane', + 'component', + 'Bow', + 'backstory', + 'phandalin', + 'Skills', + 'Pact', + 'Elemental Earth', + 'Joke', + 'invocation', + 'martial class', + 'Super Villain', + 'Eldritch', + 'Elemental Fire', + 'Homebrew Class', + 'eldritch', + 'cyberpunk', + 'Player Race', + 'Class Mod', + 'Heatcoast', + 'meta:Guide', + 'Yemao', + 'evil', + 'Named NPC', + 'CLASS', + 'Angel', + 'vecna', + 'PT', + 'PTBR', + 'Ancient', + 'Small', + 'WotC Style', + '5e Homebrew', + '1st Level', + 'dagger', + 'Brancalonia', + 'encounter', + 'cat', + 'primal path', + 'Ambientazione', + 'Magie', + 'candlekeep', + 'Ongoing', + 'Oneshot', + 'Wondrous', + 'Janbrewery', + 'Tattoos', + '5e (2014)', + 'concentration', + 'very rare', + 'Set', + 'Kobold', + 'martial archetype', + 'God', + 'blog', + 'New Gate', + 'Healing', + 'OneDnD', + 'Incantesimi', + 'Player Options', + 'contest', + 'pirate', + 'Manuel', + 'Alchimie', + 'Herboristerie', + 'Ingredients', + 'starlost', + 'Campaign 1', + 'Abandoned', + 'Previous Editions', + 'Enchantment', + 'Tools', + 'Oblivion', + 'domain', + '5th Level', + 'DnD Beyond', + 'Reference', + 'Sorcerer Subclass', + 'Dragon Magazine', + 'feature', + 'german', + 'conjuration', + 'strixhaven', + 'Sentient', + 'JJK', + '10 Generations', + 'character creation', + 'LevelUp', + 'pallid grove', + 'primer', + 'Requires Attunement', + 'College', + 'Aesthetic', + 'critter', + 'home game', + 'spanish', + 'stats', + 'Lairon', + 'Hunters Guild', + 'original setting', + 'Bosses', + 'Radiant Citadel', + 'actions', + 'Reworked', + 'Elystera', + 'Wyvern', + 'vikings', + 'thief', + 'enemies', + 'Obsession', + 'Yi Sang', + 'aberrazione', + 'Limbus', + 'animals', + 'minecraft', + 'mice of legend', + 'osric', + '20 Minutes Till Dawn', + 'campaign frame', + 'latigo', + 'DH', + 'Eldritch Invocation', + 'system:daggerheart', + '100ni', + 'meta:Sheet', + 'fa-solid fa-sheet-plastic:Ficha', + 'tag:Berean', + 'AD&D', + 'B/X', + 'The Codex Of Anomalous Entities', + 'monster manual', + 'Polar Waters', + 'CR 12', + 'CR 10', + 'blood magic', + 'Gunslinger', + 'grimoire', + 'Drakes', + 'Japanese', + 'subrace', + 'ooze', + 'Stats', + 'Half Caster', + 'Sea', + 'time', + 'Brawler', + 'Session 0', + 'Halloween', + 'Runeterra', + 'Divine', + 'Random', + 'Lifestar', + 'arcane trickster', + 'Paddy4530', + 'evocation', + 'light', + 'Steampunk', + 'shaman', + 'Primal Path', + 'monk subclass', + 'Full Caster', + 'World', + 'Planning', + 'spirit', + 'Nova Era', + 'abjuration', + 'Christmas', + 'Critical Role', + 'Gish', + 'Bandit', + 'Monster Manual', + 'party member', + 'mgazt', + 'Playable Race', + 'Donjon.bin.sh', + 'Final Fantasy', + 'Roleplay', + 'monstre', + 'fairy', + 'frame', + 'Minecraft', + 'Stealth', + 'Manual', + 'half caster', + 'Storm', + 'Sorcery', + 'format work', + 'Kingdom Hearts', + 'hexblade', + 'block', + 'page layouts', + 'Monk Subclass', + 'FinyaFluKaiKolja', + 'Radiant', + 'group:playtest', + 'Korrahir', + 'noble', + 'exorcist', + 'xapien', + 'Raven Queen', + 'markdown', + 'damage', + 'Alchemy', + 'morrigan', + 'genasi', + 'ZNH', + 'folklore', + 'Fate', + 'hechicero', + 'Air', + 'Magic Weapon', + 'Anime DND 5e', + 'Dragon Ball Z TTRPG', + 'Dragon Ball Z RPG', + 'Dragon Ball Z DND', + 'Dragon Ball Z 5e', + 'samurai', + 'Goblin', + 'Base Sheet', + 'Shackled City Adventure Path', + 'Natureza', + 'control', + 'Normarch', + 'Reddit', + 'Genshin Impact', + 'Abjuration', + 'Myr', + 'Flight', + 'Vampyre', + 'nightmare', + 'Lycan', + 'Occult', + 'circle', + 'Christmas Special', + 'DoDD', + 'Character Options', + 'traduction', + 'Characters', + 'Gear', + 'system:sf2e', + 'drakkenheim', + 'downtime', + 'amulet', + 'Feiticeiros e Maldicoes', + 'Tecnica amaldicoada', + 'prorpg', + 'enemy', + 'No Mercy', + 'rain world', + 'slugcat', + 'fly', + 'meta:User Guide', + 'Fallout TTRPG', + 'regles', + 'Ill Tides', + 'Light-hearted', + 'Vastria', + 'school', + 'Fillible Online', + 'Mezgarr', + 'Berserk', + 'invocations', + 'Classe Refeita', + 'Auroboros', + 'bosses', + 'fabula ultima', + 'Shagya', + 'wild', + 'DnD 2024', + 'KaiburrKathHound', + 'Barbarian Path', + 'fauna', + '5E.2014', + 'system:curse of strahd', + 'Unofficial', + 'how to', + 'Glaive', + 'A5E', + 'pt', + 'Consumible', + 'Realmers\'', + 'Versatile Lineage', + 'Shichibukai', + '2024e', + 'Rencontre', + 'tag:Spell List', + 'elementalist', + 'noncaster', + 'blasphemous', + 'Mordhiem', + 'Wildfrost', + '#Regelwerk', + 'Rewrite', + 'Maldición de Strahd', + 'Scion', + 'Entities', + 'Hoarwyrm', + 'Player utility', + 'CR 1/4', + 'Temperate Forest', + 'Demons', + 'Drow', + 'type:rules', + 'fay', + '2e', + 'familier', + 'supplement', + 'Amberwar', + 'slime', + 'Lycanthropy', + 'meta: Terres de Leyt', + 'Strong', + 'AAH Vol. 1', + 'Force', + 'Jump', + 'Aboleths', + 'lol', + 'location', + 'small', + 'customizable', + 'Modern', + 'Sky', + 'portugues', + 'Hero', + 'Villain', + 'element', + 'Tyranny of Dragons', + 'Adventure Guide', + 'New Class', + 'Witchlight', + 'Shardblade', + 'Plateaux', + 'WOTC', + 'Snippet', + 'Terra', + 'Otherworldly Patron', + 'ritual', + 'hag', + 'Cyberpunk 2077', + 'tavern', + 'Artificer Specialist', + 'Werewolf', + 'Boesia', + 'vampiric', + 'monastic tradition', + 'Gothic', + 'celestial', + 'Unfinished', + 'Core', + 'Arcane Tradition', + 'Troll', + 'Origin', + 'Draconic', + 'dj9 member', + 'test', + 'Hag', + 'gem', + 'Invocations', + 'Dark Sun', + 'aarkhen', + 'How to', + 'ravenloft', + 'faerie', + 'Playtest', + 'Shaman', + 'dead', + 'Tomba Aniquilacio', + 'Pacto', + 'fullcaster', + 'Electric', + 'Ability Score', + '4D', + 'pathfinder', + 'insect', + 'hook', + 'page layout', + 'healing', + 'Lineages', + 'Flying', + 'Martial Arts', + 'journal', + 'Aide de jeu', + 'hunter', + 'headers', + 'Dark Souls', + 'courtyard', + 'crossroads', + 'Quest', + 'CotF', + 'defense', + 'Semryss', + 'invoked class', + 'Session Notes', + 'goblin', + 'infernal', + 'fate', + 'oni', + 'spellbook', + 'Summoning', + 'slut', + 'whore', + 'Greyhawk', + 'Mobility', + 'Reddit Remake', + 'Guild', + 'Cosmic Mart', + '7th Level', + 'dragonborn', + 'curse of strahd', + 'Ranger Subclass', + 'dossier', + 'dossie', + 'de', + 'pnpde', + 'Plane Shift', + 'halloween', + 'group:aventura', + '9th Level', + 'tome', + 'cold', + 'acid', + 'deprecated', + 'mind flayer', + 'MECHA', + 'EssentialsKit', + '2d6', + 'ToD', + 'Work In Progress', + 'Bond', + 'Versatile', + 'Dead', + 'SYWTBAGM', + 'summoning', + 'english', + 'Eilistraee', + 'Draft', + 'DoD', + 'map', + 'Frightened', + 'Psychic Damage', + 'eberron', + 'recompensa', + 'wizard subclass', + 'teiran', + 'Saltmarsh', + 'jp setting', + 'Illithid', + 'Longbow', + 'hell', + 'Monarch', + 'type:feat', + 'reglas', + 'cooking', + 'Abenteuer', + 'reloaded', + 'incompleto', + 'mecanica', + 'Location', + 'Grimlores Grimoire', + '2024 Subclass', + 'Chiesa di Toleno', + 'finalfantasy', + 'The Undertomes', + 'Lobotomy Corporation', + 'SDHTA', + 'D&D 2024', + 'other', + 'ally', + 'images', + 'Player\'s Guide', + 'Avalon Sword', + 'Cael\'Yuu', + 'dnd-2014', + 'Regelwerk', + 'Español', + 'br', + 'dnd 5.0', + 'monstro', + 'grand cemetery', + 'Phoenix', + 'dnd 2024', + 'Bloodhunter', + 'Sintonizacion', + 'dungeons & dragons', + 'Fix', + 'Rulebook', + 'Shadowdark', + 'heroic', + 'HFW', + 'Earthdawn', + '24e', + 'cormyr', + 'suzail', + 'dc20', + 'tag:Rules', + 'The Griffon\'s Saddlebag', + 'LOTM', + 'tag:Adventure', + 'drunken master', + 'eldritch invocation', + 'Персонаж', + 'Orcs', + 'Lizardfolk', + 'Frostfell', + 'CR 17', + 'Shapechanger', + 'Farmland', + 'Mages', + 'Any', + 'CR 13', + 'Earth', + 'Mountain', + 'Drake', + 'transformation', + 'GM', + 'Lich', + 'lovecraft', + 'unique', + 'Optional Rules', + 'int', + 'creator', + 'Primal', + 'simple', + 'golem', + 'Void', + 'Armour', + 'spellsword', + 'General', + 'Asian', + 'Bringers of chaos', + 'Optional Feature', + 'subraces', + 'Galanoth', + 'barbarian subclass', + 'felhearth', + 'modular', + 'Vampires', + 'wysteria', + 'adaptation', + 'beasts', + 'naruto', + 'ninja', + 'Psionic', + 'Guns', + 'Crystal', + 'Guardian', + 'NonProfit', + 'Mimic', + 'languages', + 'Epic Boons', + 'Primer', + 'Icewind Dale', + 'joke', + 'lycan', + 'CR3', + 'Armors', + 'ff7', + 'materia', + 'final fantasy 7 remake', + 'esper', + 'ff7 remake', + 'gargantuan', + 'Frog', + 'CR5', + 'blank', + 'monster hunter', + 'league of legends', + 'french', + 'Pokémon', + 'kobold', + 'soul', + 'ffxi', + 'd10', + 'Roman', + 'Cute', + 'DD5', + 'variant', + 'tree', + 'fr', + 'Scenario', + 'lycanthrope', + 'druide', + 'staff', + 'eios', + 'arkheneios', + 'Runic', + 'Work', + 'Ukrainian', + 'cover-page', + 'mage', + 'deities', + 'gods', + 'Boss Fight', + 'Lair', + 'WBTW', + 'roguish archetype', + 'Character Option', + 'Shortsword', + 'Illrigger', + 'Bloodborne', + 'cr6', + 'Priest', + 'Hamon', + 'Toonkind', + 'rol', + 'Strength', + 'forgotten realms', + 'Spanish', + 'Conclave', + 'Electro', + 'Magical Tattoos', + 'Matt Mercer', + 'Wildemount', + 'Mighty Nien', + 'Campaign 2', + 'Resistances', + 'Bug', + 'impression', + 'PF', + 'Magnus Archives', + 'ice', + 'speed', + 'Generic NPC', + 'Titanic', + 'Ink Friendly', + 'bleed', + 'elder scrolls', + 'Immortal', + 'LMOP', + 'Travel', + 'Olphus', + '3d6', + 'heist', + 'World History', + 'ghost', + 'genie', + 'kids on bikes', + 'Russia', + 'conclave', + 'overhaul', + 'manual', + 'Adventures In Eden', + 'Downtime', + 'hamon', + 'cloak', + 'shadowfell', + 'Hellfire', + 'Paladin Oath', + 'Genshin', + 'Nation', + 'air', + 'Magical Item', + 'War', + 'Original', + 'Monstrous Compendium', + 'Calamity', + 'Warden', + 'Apocalypse', + 'shield', + 'AC', + 'expansion', + 'Concentration', + 'charm', + 'Weave', + 'lycanthropy', + 'raza', + 'far realm', + 'fighter subclass', + 'ita', + 'Pirate', + 'Laranja', + 'Grapple', + 'EastByForce', + 'hobgoblin', + 'oneshot-notes', + 'Holy', + 'optional', + 'type:cenario', + 'group:core', + 'The Brewery', + 'Alcance', + 'Morrowind', + 'Indigo', + 'Divino', + '2nd Level', + 'Sub-Class', + 'cantrips', + 'Cloak', + 'battle master', + 'Dark', + 'Puzzle', + 'Lucky', + 'consumable', + 'rebalance', + 'Shove', + 'Area Control', + 'Vanguard', + 'funny', + 'e5', + 'Dragonlance', + 'psion', + 'initiative', + 'Tactician', + 'Inspiration', + 'artificier', + 'way', + 'inspired', + 'historia', + 'Medusa', + '2 part', + 'holy', + 'gift', + 'Nimble', + 'mostri', + 'phoenix', + 'travel', + 'Class Template', + 'Intimidation', + 'constructs', + 'P666', + 'Formatting', + 'Divinity', + 'Rod', + 'Language', + 'yokai', + 'rune', + 'western', + 'vampires', + 'flying', + 'cute', + 'Enemy', + 'boon', + 'Tables', + 'ShadowFight', + 'meta: Theme', + 'SCS', + 'vanthampur villa', + 'CoA', + 'shop', + 'destiny', + 'magical weapon', + 'Arcane Arcade', + 'XP to Level 3', + 'Dice Average RPG', + 'Pip-Boy', + 'Dragon Heist', + 'session notes', + 'tattoo', + 'flick', + 'P6:66', + 'Comic Character', + 'experiment', + 'Minerva', + 'type:Spellbook', + 'Realmfall', + 'Wand', + 'halfling', + 'sw5e', + 'implementar AP', + 'Mask', + 'Gazook89', + 'Weltenrauch-Chroniken', + 'MiA', + 'Made in Abyss', + 'français', + 'fae', + 'Lemuria', + 'Mork Borg', + 'guerrier', + 'prunus', + 'condition', + 'pf2', + 'tr', + 'costrutto', + 'German', + 'project moon', + '5r', + 'galles', + 'Project moon', + 'Yisang', + 'Spicebush', + 'player-accessible', + 'Especie', + 'Westmarch', + 'a', + 'Cart', + 'Magus', + 'group:Mchael Galvis', + 'tip', + 'werewolf', + 'mundane', + 'garrett', + 'unarmed', + 'Arcane Odyssey', + 'Tomb of Divinity', + 'pets', + 'Video Game', + '4 part', + 'pbta', + 'Druids', + 'multiclass', + 'manuale', + 'mimic', + 'plane shift', + 'Dotes', + 'Hechizos', + 'Infernal', + 'Enhanced', + 'done', + 'Mission report', + 'Blanks', + 'Masks', + 'Ultimate Ability', + 'shadow-slave', + 'Advertising', + 'transform', + 'Fullmetal Alchemist', + 'Fullmetal Alchemist Brotherhood', + 'tag:TAoF&F', + 'Dwarves', + 'Humans', + 'Nine Hells', + 'Devils', + 'Archons', + 'CR 15', + 'Troglodytes', + 'Goliath', + 'retired', + 'boots', + 'ranged', + 'shields', + 'Zhentarim', + 'World of Warcraft', + 'Frontline', + 'Guildmaster\'s Guide to Ravnica', + 'Dungeons & Dragons 5e', + 'beholder', + 'NEEDS FIXING', + 'mechanic', + 'Loot', + 'champion', + 'Runes', + 'Shield', + 'Punch', + 'Sniper', + 'Magical Girl', + 'NotDND', + 'story', + 'Sleep', + 'Bard College', + 'Illusion', + 'Thunder', + 'Defender', + 'Genasi', + 'troll', + 'Gehenna', + 'Yugoloth', + 'social', + 'Player Class', + 'homebrew class', + 'CR 16', + 'Ghost', + 'Kobolds', + 'Trolls', + 'Yuan-Ti', + 'Elder Scrolls Offline', + 'armure', + 'Mage', + 'CR 18', + 'Technology', + 'Mystery', + 'darkness', + 'Airship', + 'New Campaign', + 'Warframe', + 'Wizard Subclass', + 'Gold', + 'Candor', + 'Overhaul', + 'Dragon Knight', + 'Enoreth', + 'Artifacts', + 'New', + 'AMMO', + 'Campagne', + 'Valbise', + 'Subclasseptember', + 'Mecha', + 'Yu-Gi-Oh', + 'Goblinoid', + 'underwater', + 'SW5E', + 'bardo' +]; \ No newline at end of file diff --git a/client/homebrew/homebrew.jsx b/client/homebrew/homebrew.jsx index 9ab69074b..138b54f85 100644 --- a/client/homebrew/homebrew.jsx +++ b/client/homebrew/homebrew.jsx @@ -37,9 +37,15 @@ const Homebrew = (props)=>{ lang : '' }, userThemes, - brews + brews, + enablev4 } = props; + global.account = account; + global.version = version; + global.config = config; + global.enablev4 = enablev4; + const backgroundObject = ()=>{ if(config?.deployment || (config?.local && config?.development)) { const bgText = config?.deployment || 'Local'; @@ -52,6 +58,19 @@ const Homebrew = (props)=>{ updateLocalStorage(); + if(brew.pureError) { + return ( + +
    + + } /> + +
    +
    + ); + } + + return (
    diff --git a/client/homebrew/main.jsx b/client/homebrew/main.jsx index 77a88d30f..b580df54d 100644 --- a/client/homebrew/main.jsx +++ b/client/homebrew/main.jsx @@ -1,6 +1,8 @@ -import { createRoot } from "react-dom/client"; -import Homebrew from "./homebrew.jsx"; +import { createRoot } from 'react-dom/client'; +import Homebrew from './homebrew.jsx'; +import { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js'; const props = window.__INITIAL_PROPS__ || {}; -createRoot(document.getElementById("reactRoot")).render(); +createRoot(document.getElementById('reactRoot')).render(); +bootstrapAnchorPositioningPolyfill(); diff --git a/client/homebrew/navbar/metadata.navitem.jsx b/client/homebrew/navbar/metadata.navitem.jsx index bfea2e81a..8ee6b72c0 100644 --- a/client/homebrew/navbar/metadata.navitem.jsx +++ b/client/homebrew/navbar/metadata.navitem.jsx @@ -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(){ return
    @@ -65,10 +60,6 @@ const MetadataNav = createReactClass({

    Tags

    {this.getTags()}

    -
    -

    Systems

    -

    {this.getSystems()}

    -

    Updated

    {Moment(this.props.brew.updatedAt).fromNow()}

    diff --git a/client/homebrew/navbar/nav.jsx b/client/homebrew/navbar/nav.jsx index 9e065e0ee..7af6bcb79 100644 --- a/client/homebrew/navbar/nav.jsx +++ b/client/homebrew/navbar/nav.jsx @@ -4,7 +4,7 @@ import createReactClass from 'create-react-class'; import _ from 'lodash'; import cx from 'classnames'; -import NaturalCritIcon from '../../components/svg/naturalcrit-d20.svg.jsx'; +import NaturalCritIcon from '@components/svg/naturalcrit-d20.svg.jsx'; const Nav = { base : createReactClass({ diff --git a/client/homebrew/navbar/navbar.jsx b/client/homebrew/navbar/navbar.jsx index aa77dd2a0..db9a836c9 100644 --- a/client/homebrew/navbar/navbar.jsx +++ b/client/homebrew/navbar/navbar.jsx @@ -7,10 +7,10 @@ import PatreonNavItem from './patreon.navitem.jsx'; const Navbar = createReactClass({ displayName : 'Navbar', - getInitialState: function() { + getInitialState : function() { return { // showNonChromeWarning: false, // uncomment if needed - ver: global.version || '0.0.0' + ver : global.version || '0.0.0' }; }, diff --git a/client/homebrew/navbar/newbrew.navitem.jsx b/client/homebrew/navbar/newbrew.navitem.jsx index ac72121f1..5c91f8465 100644 --- a/client/homebrew/navbar/newbrew.navitem.jsx +++ b/client/homebrew/navbar/newbrew.navitem.jsx @@ -24,7 +24,7 @@ const NewBrew = ()=>{ localStorage.setItem(BREWKEY, newBrew.text); localStorage.setItem(STYLEKEY, newBrew.style); localStorage.setItem(METAKEY, JSON.stringify( - _.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang']) + _.pick(newBrew, ['title', 'description', 'tags', 'renderer', 'theme', 'lang']) )); window.location.href = '/new'; return; diff --git a/client/homebrew/navbar/print.navitem.jsx b/client/homebrew/navbar/print.navitem.jsx index ea262cf03..e669214b3 100644 --- a/client/homebrew/navbar/print.navitem.jsx +++ b/client/homebrew/navbar/print.navitem.jsx @@ -1,9 +1,25 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import Nav from './nav.jsx'; import { printCurrentBrew } from '@shared/helpers.js'; export default function(){ + const [printing, setPrinting] = useState(false); + + // listen for print cycle events to display "loading" message since it can take some time. + useEffect(()=>{ + document.addEventListener('print:startprep', handlePrintStartPrep); + document.addEventListener('print:finishedprep', handlePrintPrepFinished); + return ()=>{ + document.removeEventListener('print:startprep', handlePrintStartPrep); + document.removeEventListener('print:finishedprep', handlePrintPrepFinished); + } + }, []); + + const handlePrintStartPrep = ()=>{ setPrinting(true); }; + + const handlePrintPrepFinished = ()=>{ setPrinting(false); }; + return - get PDF + {printing ? 'loading' : 'get PDF'} ; }; diff --git a/client/homebrew/navbar/share.navitem.jsx b/client/homebrew/navbar/share.navitem.jsx index d0c659e2c..e329a4560 100644 --- a/client/homebrew/navbar/share.navitem.jsx +++ b/client/homebrew/navbar/share.navitem.jsx @@ -17,7 +17,7 @@ const getRedditLink = (brew)=>{ return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(brew.title.toWellFormed())}&text=${encodeURIComponent(text)}`; }; -export default ({ brew })=>( +export default ({ brew, currentPage })=>( share @@ -28,6 +28,12 @@ export default ({ brew })=>( {navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}`);}}> copy url + {currentPage > 1 && + {navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}#p${currentPage}`);}}> + copy url (page {currentPage}) + } post to reddit diff --git a/client/homebrew/pages/accountPage/accountPage.jsx b/client/homebrew/pages/accountPage/accountPage.jsx index ef1262034..8a443545c 100644 --- a/client/homebrew/pages/accountPage/accountPage.jsx +++ b/client/homebrew/pages/accountPage/accountPage.jsx @@ -1,7 +1,7 @@ import React from 'react'; import moment from 'moment'; import UIPage from '../basePages/uiPage/uiPage.jsx'; -import NaturalCritIcon from '../../../components/svg/naturalcrit-d20.svg.jsx'; +import NaturalCritIcon from '@components/svg/naturalcrit-d20.svg.jsx'; let SAVEKEY = ''; diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index d40058557..bc18bc5e5 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -8,9 +8,9 @@ import Markdown from '@shared/markdown.js'; import _ from 'lodash'; import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js'; -import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js'; +import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js'; -import SplitPane from '../../../components/splitPane/splitPane.jsx'; +import SplitPane from '@components/splitPane/splitPane.jsx'; import Editor from '../../editor/editor.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; @@ -57,22 +57,22 @@ const EditPage = (props)=>{ ...props }; - const [currentBrew , setCurrentBrew ] = useState(props.brew); - const [isSaving , setIsSaving ] = useState(false); - const [lastSavedTime , setLastSavedTime ] = useState(new Date()); - const [saveGoogle , setSaveGoogle ] = useState(!!props.brew.googleId); - const [error , setError ] = useState(null); - const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text)); - const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1); + const [currentBrew, setCurrentBrew] = useState(props.brew); + const [isSaving, setIsSaving] = useState(false); + const [lastSavedTime, setLastSavedTime] = useState(new Date()); + const [saveGoogle, setSaveGoogle] = useState(!!props.brew.googleId); + const [error, setError] = useState(null); + const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text)); + const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); - const [themeBundle , setThemeBundle ] = useState({}); - const [unsavedChanges , setUnsavedChanges ] = useState(false); - const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed); - const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false); - const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false); - const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true); - const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true); + const [themeBundle, setThemeBundle] = useState({}); + const [unsavedChanges, setUnsavedChanges] = useState(false); + const [alertTrashedGoogleBrew, setAlertTrashedGoogleBrew] = useState(props.brew.trashed); + const [alertLoginToTransfer, setAlertLoginToTransfer] = useState(false); + const [confirmGoogleTransfer, setConfirmGoogleTransfer] = useState(false); + const [autoSaveEnabled, setAutoSaveEnabled] = useState(true); + const [warnUnsavedChanges, setWarnUnsavedChanges] = useState(true); const editorRef = useRef(null); const lastSavedBrew = useRef(_.cloneDeep(props.brew)); @@ -90,7 +90,7 @@ const EditPage = (props)=>{ const handleControlKeys = (e)=>{ if(!(e.ctrlKey || e.metaKey)) return; - if(e.keyCode === 83) trySaveRef.current(true); + if(e.keyCode === 83) trySaveRef.current(true, true, saveGoogle); if(e.keyCode === 80) printCurrentBrew(); if([83, 80].includes(e.keyCode)) { e.stopPropagation(); @@ -118,13 +118,9 @@ const EditPage = (props)=>{ const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current); setUnsavedChanges(hasChange); - if(autoSaveEnabled) trySave(false, hasChange); + if(autoSaveEnabled) trySave(false, hasChange, saveGoogle); }, [currentBrew]); - useEffect(()=>{ - trySave(true); - }, [saveGoogle]); - const handleSplitMove = ()=>{ editorRef.current?.update(); }; @@ -183,11 +179,13 @@ const EditPage = (props)=>{ }; const toggleGoogleStorage = ()=>{ + const newSaveGoogle = !saveGoogle; setSaveGoogle((prev)=>!prev); setError(null); + trySave(true, true, newSaveGoogle); }; - const trySave = (immediate = false, hasChanges = true)=>{ + const trySave = (immediate = false, hasChanges = true, saveToGoogle = false)=>{ clearTimeout(saveTimeout.current); if(isSaving) return; if(!hasChanges && !immediate) return; @@ -196,7 +194,7 @@ const EditPage = (props)=>{ saveTimeout.current = setTimeout(async ()=>{ setIsSaving(true); setError(null); - await save(currentBrew, saveGoogle) + await save(currentBrew, saveToGoogle) .catch((err)=>{ setError(err); }); @@ -216,7 +214,7 @@ const EditPage = (props)=>{ const brewToSave = { ...brew, text : brew.text.normalize('NFC'), - pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1, + pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm)) || []).length + 1, patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))), hash : await md5(lastSavedBrew.current.text.normalize('NFC')), textBin : undefined, @@ -314,7 +312,7 @@ const EditPage = (props)=>{ // #3 - Unsaved changes exist, click to save, show SAVE NOW if(unsavedChanges) - return trySave(true)} color='blue' icon='fas fa-save'>save now; + return trySave(true, true, saveGoogle)} color='blue' icon='fas fa-save'>save now; // #4 - No unsaved changes, autosave is ON, show AUTO-SAVED if(autoSaveEnabled) @@ -365,7 +363,7 @@ const EditPage = (props)=>{ - + diff --git a/client/homebrew/pages/editPage/lockNotification/lockNotification.jsx b/client/homebrew/pages/editPage/lockNotification/lockNotification.jsx index c71c85891..326e13030 100644 --- a/client/homebrew/pages/editPage/lockNotification/lockNotification.jsx +++ b/client/homebrew/pages/editPage/lockNotification/lockNotification.jsx @@ -1,7 +1,7 @@ import './lockNotification.less'; import * as React from 'react'; import request from '../../../utils/request-middleware.js'; -import Dialog from '../../../../components/dialog.jsx'; +import Dialog from '@components/dialog.jsx'; function LockNotification(props) { props = { diff --git a/client/homebrew/pages/errorPage/errorPage.less b/client/homebrew/pages/errorPage/errorPage.less index 2d10301e0..df8dcf98d 100644 --- a/client/homebrew/pages/errorPage/errorPage.less +++ b/client/homebrew/pages/errorPage/errorPage.less @@ -1,7 +1,6 @@ .homebrew { - .uiPage.sitePage { + .uiPage.sitePage:has(.errorTitle) { .errorTitle { - //background-color: @orange; color : #D02727; text-align : center; } diff --git a/client/homebrew/pages/homePage/homePage.jsx b/client/homebrew/pages/homePage/homePage.jsx index 030e05a04..bb59c0665 100644 --- a/client/homebrew/pages/homePage/homePage.jsx +++ b/client/homebrew/pages/homePage/homePage.jsx @@ -1,4 +1,4 @@ -/* eslint-disable max-lines */ + import './homePage.less'; // Common imports @@ -8,9 +8,9 @@ import Markdown from '@shared/markdown.js'; import _ from 'lodash'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; -import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js'; +import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js'; -import SplitPane from '../../../components/splitPane/splitPane.jsx'; +import SplitPane from '@components/splitPane/splitPane.jsx'; import Editor from '../../editor/editor.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; @@ -45,16 +45,16 @@ const HomePage =(props)=>{ ...props }; - const [currentBrew , setCurrentBrew] = useState(props.brew); - const [error , setError] = useState(undefined); - const [HTMLErrors , setHTMLErrors] = useState(Markdown.validate(props.brew.text)); - const [currentEditorViewPageNum , setCurrentEditorViewPageNum] = useState(1); + const [currentBrew, setCurrentBrew] = useState(props.brew); + const [error, setError] = useState(undefined); + const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text)); + const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); - const [themeBundle , setThemeBundle] = useState({}); - const [unsavedChanges , setUnsavedChanges] = useState(false); - const [isSaving , setIsSaving] = useState(false); - const [autoSaveEnabled , setAutoSaveEnable] = useState(false); + const [themeBundle, setThemeBundle] = useState({}); + const [unsavedChanges, setUnsavedChanges] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [autoSaveEnabled, setAutoSaveEnable] = useState(false); const editorRef = useRef(null); const lastSavedBrew = useRef(_.cloneDeep(props.brew)); diff --git a/client/homebrew/pages/newPage/newPage.jsx b/client/homebrew/pages/newPage/newPage.jsx index 7f3247d04..2da3c8394 100644 --- a/client/homebrew/pages/newPage/newPage.jsx +++ b/client/homebrew/pages/newPage/newPage.jsx @@ -10,7 +10,7 @@ import _ from 'lodash'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.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 BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; @@ -42,17 +42,17 @@ const NewPage = (props)=>{ ...props }; - const [currentBrew , setCurrentBrew ] = useState(props.brew); - const [isSaving , setIsSaving ] = useState(false); - const [saveGoogle , setSaveGoogle ] = useState(global.account?.googleId ? true : false); - const [error , setError ] = useState(null); - const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text)); - const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1); + const [currentBrew, setCurrentBrew] = useState(props.brew); + const [isSaving, setIsSaving] = useState(false); + const [saveGoogle, setSaveGoogle] = useState(global.account?.googleId ? true : false); + const [error, setError] = useState(null); + const [HTMLErrors, setHTMLErrors] = useState(Markdown.validate(props.brew.text)); + const [currentEditorViewPageNum, setCurrentEditorViewPageNum] = useState(1); const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); - const [themeBundle , setThemeBundle ] = useState({}); - const [unsavedChanges , setUnsavedChanges ] = useState(false); - const [autoSaveEnabled , setAutoSaveEnabled ] = useState(false); + const [themeBundle, setThemeBundle] = useState({}); + const [unsavedChanges, setUnsavedChanges] = useState(false); + const [autoSaveEnabled, setAutoSaveEnabled] = useState(false); const editorRef = useRef(null); const lastSavedBrew = useRef(_.cloneDeep(props.brew)); @@ -156,7 +156,7 @@ const NewPage = (props)=>{ const updatedBrew = { ...currentBrew }; splitTextStyleAndMetadata(updatedBrew); - const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^\\page$/gm; + const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/gm; updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1; const res = await request diff --git a/client/homebrew/pages/sharePage/sharePage.jsx b/client/homebrew/pages/sharePage/sharePage.jsx index 093fc8965..8df241d7b 100644 --- a/client/homebrew/pages/sharePage/sharePage.jsx +++ b/client/homebrew/pages/sharePage/sharePage.jsx @@ -92,6 +92,19 @@ const SharePage = (props)=>{ clone to new + {navigator.clipboard.writeText(`${global.config.baseUrl}/share/${processShareId()}`);}}> + copy url + + {currentBrewRendererPageNum > 1 && + {navigator.clipboard.writeText(`${global.config.baseUrl}/share/${processShareId()}#p${currentBrewRendererPageNum}`);}}> + copy url (page {currentBrewRendererPageNum}) + } )} diff --git a/client/homebrew/pages/vaultPage/vaultPage.jsx b/client/homebrew/pages/vaultPage/vaultPage.jsx index e6a9768bb..682630b6f 100644 --- a/client/homebrew/pages/vaultPage/vaultPage.jsx +++ b/client/homebrew/pages/vaultPage/vaultPage.jsx @@ -11,7 +11,7 @@ import Account from '@navbar/account.navitem.jsx'; import NewBrew from '@navbar/newbrew.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx'; import BrewItem from '../basePages/listPage/brewItem/brewItem.jsx'; -import SplitPane from '../../../components/splitPane/splitPane.jsx'; +import SplitPane from '@components/splitPane/splitPane.jsx'; import ErrorIndex from '../errorPage/errors/errorIndex.js'; import request from '../../utils/request-middleware.js'; diff --git a/client/icons/broken-image.jpg b/client/icons/broken-image.jpg new file mode 100644 index 000000000..52fa33195 Binary files /dev/null and b/client/icons/broken-image.jpg differ diff --git a/config/default.json b/config/default.json index 6be4ce7ce..e55903405 100644 --- a/config/default.json +++ b/config/default.json @@ -7,5 +7,6 @@ "local_environments" : ["docker", "local"], "publicUrl" : "https://homebrewery.naturalcrit.com", "hb_images" : null, - "hb_fonts" : null + "hb_fonts" : null, + "enablev4" : true } diff --git a/index.html b/index.html index 5ee864b28..d6bd3157d 100644 --- a/index.html +++ b/index.html @@ -5,10 +5,6 @@ - @@ -19,7 +15,28 @@
    ` 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 `` 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 - req.account = undefined; + const brew = req.brew; - 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 */ -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 + splitTextStyleAndMetadata(brew); - 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 */ -router.put('/admin/compress/:id', (req, res)=>{ - HomebrewModel.findOne({ _id: req.params.id }) + /* Get list of a user's documents */ + router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{ + const username = req.params.user; + const fields = { _id: 0, text: 0, textBin: 0 }; // Remove unnecessary fields from document lists + + 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)=>{ if(!brew) return res.status(404).send('Brew not found'); @@ -152,250 +189,250 @@ router.put('/admin/compress/:id', (req, res)=>{ console.error(err); res.status(500).send('Error while saving'); }); -}); + }); -router.get('/admin/stats', mw.adminOnly, async (req, res)=>{ - try { - const totalBrewsCount = await HomebrewModel.countDocuments({}); - const publishedBrewsCount = await HomebrewModel.countDocuments({ published: true }); + router.get('/admin/stats', mw.adminOnly, async (req, res)=>{ + try { + const totalBrewsCount = await HomebrewModel.countDocuments({}); + const publishedBrewsCount = await HomebrewModel.countDocuments({ published: true }); - return res.json({ - totalBrews : totalBrewsCount, - totalPublishedBrews : publishedBrewsCount - }); - } catch (error) { - console.error(error); - return res.status(500).json({ error: 'Internal Server Error' }); - } -}); + return res.json({ + totalBrews : totalBrewsCount, + totalPublishedBrews : publishedBrewsCount + }); + } catch (error) { + console.error(error); + return res.status(500).json({ error: 'Internal Server Error' }); + } + }); -// ####################### LOCKS + // ####################### LOCKS -router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{ + router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{ - const countLocksQuery = { - lock : { $exists: true } - }; - const count = await HomebrewModel.countDocuments(countLocksQuery) + const countLocksQuery = { + lock : { $exists: true } + }; + const count = await HomebrewModel.countDocuments(countLocksQuery) .catch((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)=>{ - const countLocksPipeline = [ - { + router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{ + const countLocksPipeline = [ + { $match : { 'lock' : { '$exists': 1 } }, - }, - { - $project : { - shareId : 1, - editId : 1, - title : 1, - lock : 1 + }, + { + $project : { + shareId : 1, + editId : 1, + title : 1, + lock : 1 + } } - } - ]; - const lockedDocuments = await HomebrewModel.aggregate(countLocksPipeline) + ]; + const lockedDocuments = await HomebrewModel.aggregate(countLocksPipeline) .catch((error)=>{ throw { name: 'Can Not Get Locked Brews', message: 'Unable to get locked brew collection', status: 500, HBErrorCode: '68', error }; }); - return res.json({ - lockedDocuments - }); + return res.json({ + 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 = { - shareId : req.params.id - }; + const filter = { + 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) { - throw { name: 'Already Locked', message: 'Lock already exists on brew', shareId: req.params.id, title: brew.title, status: 500, HBErrorCode: '64' }; - } + 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' }; + } - lock.overwrite = undefined; + lock.overwrite = undefined; - brew.lock = lock; - brew.markModified('lock'); + brew.lock = lock; + brew.markModified('lock'); - await brew.save() + await brew.save() .catch((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 = { - shareId : req.params.id - }; + const filter = { + 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.markModified('lock'); + brew.lock = undefined; + brew.markModified('lock'); - await brew.save() + await brew.save() .catch((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)=>{ - const countReviewsPipeline = [ - { + router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{ + const countReviewsPipeline = [ + { $match : { 'lock.reviewRequested' : { '$exists': 1 } }, - }, - { - $project : { - shareId : 1, - editId : 1, - title : 1, - lock : 1 + }, + { + $project : { + shareId : 1, + editId : 1, + title : 1, + lock : 1 + } } - } - ]; - const reviewDocuments = await HomebrewModel.aggregate(countReviewsPipeline) + ]; + const reviewDocuments = await HomebrewModel.aggregate(countReviewsPipeline) .catch((error)=>{ throw { name: 'Can Not Get Reviews', message: 'Unable to get review collection', status: 500, HBErrorCode: '68', error }; }); - return res.json({ - reviewDocuments - }); + return res.json({ + reviewDocuments + }); -})); + })); -router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{ + router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{ // === This route is NOT Admin only === // Any user can request a review of their document - const filter = { - shareId : req.params.id, - lock : { $exists: 1 } - }; + const filter = { + shareId : req.params.id, + lock : { $exists: 1 } + }; - 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' }; }; + 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.lock.reviewRequested){ - throw { name: 'Review Already Requested', message: `Review already requested for brew ${brew.shareId} - ${brew.title}`, code: 500, HBErrorCode: '71' }; - }; + if(brew.lock.reviewRequested){ + 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.markModified('lock'); + brew.lock.reviewRequested = new Date(); + brew.markModified('lock'); - await brew.save() + await brew.save() .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 }; }); - 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 = { - shareId : req.params.id, - 'lock.reviewRequested' : { $exists: 1 } - }; + const filter = { + shareId : req.params.id, + 'lock.reviewRequested' : { $exists: 1 } + }; - 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' }; }; + 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' }; }; - brew.lock.reviewRequested = undefined; - brew.markModified('lock'); + brew.lock.reviewRequested = undefined; + brew.markModified('lock'); - await brew.save() + await brew.save() .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 }; }); - 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)=>{ - try { - const notifications = await NotificationModel.getAll(); - return res.json(notifications); + router.get('/admin/notification/all', async (req, res, next)=>{ + try { + const notifications = await NotificationModel.getAll(); + return res.json(notifications); - } catch (error) { - console.log('Error getting all notifications: ', error.message); - return res.status(500).json({ message: error.message }); - } -}); + } catch (error) { + console.log('Error getting all notifications: ', error.message); + return res.status(500).json({ message: error.message }); + } + }); -router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{ - 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.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.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) => { + router.get('/admin', mw.adminOnly, asyncHandler(async (req, res)=>{ const props = { - url : req.originalUrl - }; + url : req.originalUrl + }; - const htmlPath = isProd - ? path.resolve('build', 'index.html') - : path.resolve('index.html'); + const htmlPath = isProd + ? path.resolve('build', 'index.html') + : path.resolve('index.html'); - let html = fs.readFileSync(htmlPath, 'utf-8'); + let html = fs.readFileSync(htmlPath, 'utf-8'); - if (!isProd && vite?.transformIndexHtml) { - html = await vite.transformIndexHtml(req.originalUrl, html); - } + if(!isProd && vite?.transformIndexHtml) { + html = await vite.transformIndexHtml(req.originalUrl, html); + } - res.send(html.replace( - '', - `\n` - )); -})); + res.send(html.replace( + '', + `\n` + )); + })); return router; diff --git a/server/app.js b/server/app.js index 07fc3ba9c..b1e0e1c25 100644 --- a/server/app.js +++ b/server/app.js @@ -55,7 +55,7 @@ export default async function createApp(vite) { app.set('trust proxy', 1 /* number of proxies between user and server */); - if (vite) { + if(vite) { app.use(vite.middlewares); } @@ -593,7 +593,7 @@ export default async function createApp(vite) { html = html.replace( '', - `\n\n${ogMetaTags}` + ()=>{ return `\n\n${ogMetaTags}`; } ); return html; diff --git a/server/brewDefaults.js b/server/brewDefaults.js index 11a84b9e9..501914735 100644 --- a/server/brewDefaults.js +++ b/server/brewDefaults.js @@ -14,7 +14,6 @@ const DEFAULT_BREW = { theme : '5ePHB', authors : [], tags : [], - systems : [], lang : 'en', thumbnail : '', views : 0, diff --git a/server/googleActions.js b/server/googleActions.js index b13ca11b9..d50549051 100644 --- a/server/googleActions.js +++ b/server/googleActions.js @@ -151,7 +151,6 @@ const GoogleActions = { description : file.description, views : parseInt(file.properties.views), published : file.properties.published ? file.properties.published == 'true' : false, - systems : [], lang : file.properties.lang, thumbnail : file.properties.thumbnail, webViewLink : file.webViewLink @@ -298,7 +297,6 @@ const GoogleActions = { text : file.data, description : obj.data.description, - systems : obj.data.properties.systems ? obj.data.properties.systems.split(',') : [], authors : [], lang : obj.data.properties.lang, published : obj.data.properties.published ? obj.data.properties.published == 'true' : false, diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 6c7a9774b..d619977e0 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -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 api = { @@ -167,7 +188,10 @@ const api = { stub.renderer = stub.renderer || undefined; // Clear empty strings stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields - req.brew = stub; + + + const fixedStub = migrateSystemsToTags(stub); + req.brew = fixedStub; next(); }; }, @@ -191,7 +215,7 @@ const api = { `\`\`\`\n\n` + `${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; metadata.snippets = snippetsArray.length > 0 ? snippetsArray : undefined; text = `\`\`\`metadata\n` + @@ -365,22 +389,29 @@ const api = { if(brewFromServer?.hash !== brewFromClient?.hash) { console.log(`Hash mismatch on brew ${brewFromClient.editId}`); - //debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`); + debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`); res.setHeader('Content-Type', 'application/json'); return res.status(409).send(JSON.stringify({ message: `The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` })); } + let result = []; try { const patches = parsePatch(brewFromClient.patches); // Patch to a throwaway variable while parallelizing - we're more concerned with error/no error. - const patchedResult = decodeURI(applyPatches(patches, encodeURI(brewFromServer.text))[0]); - if(patchedResult != brewFromClient.text) + result = applyPatches(patches, encodeURI(brewFromServer.text)); + const failedPatches = patches.map((patch, index)=>{if(!result[1][index]){ return patch; }}); + if(failedPatches > 0){ + throw (`Patch failure: ${failedPatches}/${result[1].length} did not apply`); + } + if(decodeURI(result[0]) != brewFromClient.text){ throw ('Patches did not apply cleanly, text mismatch detected'); + } // brew.text = applyPatches(patches, brewFromServer.text)[0]; } catch (err) { - //debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`); + debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`); console.error('Failed to apply patches:', { - //patches : brewFromClient.patches, + // patches : brewFromClient.patches, + // result : result, brewId : brewFromClient.editId || 'unknown', error : err }); @@ -389,6 +420,9 @@ const api = { } let brew = _.assign(brewFromServer, brewFromClient); + + migrateSystemsToTags(brew); + brew.title = brew.title.trim(); brew.description = brew.description.trim() || ''; brew.text = api.mergeBrewText(brew); @@ -478,7 +512,7 @@ const api = { await HomebrewModel.deleteOne({ editId: id }); return next(); } - throw(err); + throw (err); } let brew = req.brew; diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js index f467c35dc..a8500d8f4 100644 --- a/server/homebrew.api.spec.js +++ b/server/homebrew.api.spec.js @@ -63,7 +63,6 @@ describe('Tests for api', ()=>{ title : 'some title', description : 'this is a description', tags : ['something', 'fun'], - systems : ['D&D 5e'], lang : 'en', renderer : 'v3', theme : 'phb', @@ -350,7 +349,6 @@ describe('Tests for api', ()=>{ renderer : 'legacy', lang : 'en', shareId : undefined, - systems : [], tags : [], theme : '5ePHB', thumbnail : '', @@ -450,7 +448,6 @@ describe('Tests for api', ()=>{ title : 'some title', description : 'this is a description', tags : ['something', 'fun'], - systems : ['D&D 5e'], renderer : 'v3', theme : 'phb', googleId : '12345' @@ -462,8 +459,6 @@ description: this is a description tags: - something - fun -systems: - - D&D 5e renderer: v3 theme: phb @@ -479,7 +474,6 @@ brew`); title : 'some title', description : 'this is a description', tags : ['something', 'fun'], - systems : ['D&D 5e'], renderer : 'v3', theme : 'phb', googleId : '12345' @@ -491,8 +485,6 @@ description: this is a description tags: - something - fun -systems: - - D&D 5e renderer: v3 theme: phb @@ -522,7 +514,6 @@ brew`); expect(sent).toEqual(googleBrew); expect(result.tags).toBeUndefined(); - expect(result.systems).toBeUndefined(); expect(result.published).toBeUndefined(); expect(result.authors).toBeUndefined(); expect(result.owner).toBeUndefined(); @@ -614,7 +605,6 @@ brew`); lang : 'en', shareId : expect.any(String), style : undefined, - systems : [], tags : [], text : undefined, textBin : expect.objectContaining({}), @@ -674,7 +664,6 @@ brew`); shareId : expect.any(String), googleId : expect.any(String), style : undefined, - systems : [], tags : [], text : undefined, textBin : undefined, @@ -1147,7 +1136,6 @@ brew`); 'title: title\n' + 'description: description\n' + 'tags: [ \'tag a\' , \'tag b\' ]\n' + - 'systems: [ test system ]\n' + 'renderer: legacy\n' + 'theme: 5ePHB\n' + 'lang: en\n' + @@ -1168,8 +1156,6 @@ brew`); // Metadata expect(testBrew.title).toEqual('title'); 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.theme).toEqual('5ePHB'); expect(testBrew.lang).toEqual('en'); @@ -1178,19 +1164,6 @@ brew`); // Text 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']); - }); }); }); diff --git a/server/homebrew.model.js b/server/homebrew.model.js index ff371ee42..e923ac928 100644 --- a/server/homebrew.model.js +++ b/server/homebrew.model.js @@ -15,7 +15,7 @@ const HomebrewSchema = mongoose.Schema({ description : { type: String, default: '' }, tags : { type: [String], index: true }, - systems : [String], + systems : { type: [String], default: undefined }, lang : { type: String, default: 'en', index: true }, renderer : { type: String, default: '', index: true }, authors : { type: [String], index: true }, diff --git a/shared/helpers.js b/shared/helpers.js index 8177aa7a9..3610ccea4 100644 --- a/shared/helpers.js +++ b/shared/helpers.js @@ -91,7 +91,7 @@ const splitTextStyleAndMetadata = (brew)=>{ const index = brew.text.indexOf('\n```\n\n'); const metadataSection = brew.text.slice(11, index + 1); 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.text = brew.text.slice(index + 6); } @@ -105,14 +105,35 @@ const splitTextStyleAndMetadata = (brew)=>{ if(typeof brew.tags === 'string') brew.tags = brew.tags ? [brew.tags] : []; }; -const printCurrentBrew = ()=>{ +const printCurrentBrew = async ()=>{ if(window.typeof !== 'undefined') { - window.frames['BrewRenderer'].contentWindow.print(); - //Force DOM reflow; Print dialog causes a repaint, and @media print CSS somehow makes out-of-view pages disappear - const node = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer').item(0); - node.style.display='none'; - node.offsetHeight; // accessing this is enough to trigger a reflow - node.style.display=''; + // fire a custom event for the print cycle + document.dispatchEvent(new CustomEvent('print:startprep')); + try { + const iframeDoc = window.frames['BrewRenderer'].contentDocument; + + // get all img elements with lazy loading (currently only elements generated through MarkedJS) + const lazyImages = [...iframeDoc.querySelectorAll('img[loading="lazy"]')]; + lazyImages.forEach((img)=>{ img.loading = 'eager'; }); + + // waits for images to load before resolving promise and opening print dialog + await Promise.all( + lazyImages + .filter((img)=>!img.complete) + .map((img)=>new Promise((resolve)=>{ img.onload = resolve; img.onerror = resolve; })) + ); + + window.frames['BrewRenderer'].contentWindow.print(); + + //Force DOM reflow; Print dialog causes a repaint, and @media print CSS somehow makes out-of-view pages disappear + const node = iframeDoc.getElementsByClassName('brewRenderer').item(0); + node.style.display='none'; + node.offsetHeight; // accessing this is enough to trigger a reflow + node.style.display=''; + } finally { + // when lazy load images have all been loaded, and the doc re-rendered for print preview, emit 'finished' event. + document.dispatchEvent(new CustomEvent('print:finishedprep')); + } } }; @@ -160,9 +181,35 @@ const debugTextMismatch = (clientTextRaw, serverTextRaw, label)=>{ // Char-level diff for (let i = 0; i < Math.min(clientText.length, serverText.length); i++) { if(clientText[i] !== serverText[i]) { + const getMismatchContext = (text, index, name, size = 10)=>{ + const lower = Math.max(index - size, 0); + const upper = Math.min(index + size, text.length); + const slice = `${JSON.stringify(text.slice(lower, index)).slice(1, -1)}\u001B[31m${JSON.stringify(text[i]).slice(1, -1)}\u001B[0m${JSON.stringify(text.slice(index+1, upper)).slice(1, -1)}`; + const lineNo = text.slice(0, index).split('\n').length; + const code = `U+${text.charCodeAt(i).toString(16).toUpperCase()}`; + + return { + name, + lineNo, + code, + lower, + upper, + slice + }; + }; + + const boundSize = 10; + + const clientContext = getMismatchContext(clientText, i, 'Client', boundSize); + const serverContext = getMismatchContext(serverText, i, 'Server', boundSize); + + const logContext = (context)=>{ + console.log(` ${context.name} - line ${context.lineNo} : (${context.code})\t${context.slice}`); + }; + console.log(`Char mismatch at index ${i}:`); - console.log(` Client: '${clientText[i]}' (U+${clientText.charCodeAt(i).toString(16).toUpperCase()})`); - console.log(` Server: '${serverText[i]}' (U+${serverText.charCodeAt(i).toString(16).toUpperCase()})`); + logContext(clientContext); + logContext(serverContext); break; } } diff --git a/shared/markdown.js b/shared/markdown.js index adb058042..05a564254 100644 --- a/shared/markdown.js +++ b/shared/markdown.js @@ -1,4 +1,4 @@ -/* eslint-disable max-depth */ + /* eslint-disable max-lines */ import _ from 'lodash'; import { marked as Marked } from 'marked'; @@ -70,9 +70,9 @@ renderer.link = function (token) { if(title) { out += ` title="${escape(title)}"`; } - if(self) { - out += ' target="_self"'; - } + // if(self) { + // out += ' target="_self"'; + // } out += `>${text}`; return out; }; @@ -83,7 +83,7 @@ renderer.image = function (token) { if(href === null) return text; - let out = `${text}${text}`; return out; }; diff --git a/shared/naturalcrit/styles/core.less b/shared/naturalcrit/styles/core.less index 3ef75144d..3ff3e37fe 100644 --- a/shared/naturalcrit/styles/core.less +++ b/shared/naturalcrit/styles/core.less @@ -4,14 +4,7 @@ @import './animations.less'; @import './colors.less'; @import './tooltip.less'; -@font-face { - font-family : 'CodeLight'; - src : url('./CODE Light.otf') format('opentype'); -} -@font-face { - font-family : 'CodeBold'; - src : url('./CODE Bold.otf') format('opentype'); -} +@import './fonts/fonts.css'; html,body, #reactRoot { height : 100vh; min-height : 100vh; diff --git a/shared/naturalcrit/styles/CODE Bold.otf b/shared/naturalcrit/styles/fonts/CODE Bold.otf similarity index 100% rename from shared/naturalcrit/styles/CODE Bold.otf rename to shared/naturalcrit/styles/fonts/CODE Bold.otf diff --git a/shared/naturalcrit/styles/CODE Light.otf b/shared/naturalcrit/styles/fonts/CODE Light.otf similarity index 100% rename from shared/naturalcrit/styles/CODE Light.otf rename to shared/naturalcrit/styles/fonts/CODE Light.otf diff --git a/shared/naturalcrit/styles/fonts/fonts.css b/shared/naturalcrit/styles/fonts/fonts.css new file mode 100644 index 000000000..f7c0c4371 --- /dev/null +++ b/shared/naturalcrit/styles/fonts/fonts.css @@ -0,0 +1,38 @@ +/* open-sans-latin-wght-normal */ + +@font-face { + font-family : 'Open Sans'; + font-style : normal; + font-weight : normal; + src : url('open-sans-latin-400-normal.woff2') format('woff2'); + font-display : swap; + unicode-range : U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* +* Nowhere is font-weight: 700 actually used with Open Sans, everything is set to 800. +* But, 800 *is* too bold. And since we don't have an 800 font file, it's just using the +* 700 font file and it looks fine. Not sure it's worth changing everything to 700? +*/ +@font-face { + font-family : 'Open Sans'; + font-style : normal; + font-weight : bold; + src : url('open-sans-latin-700-normal.woff2') format('woff2'); + font-display : swap; + unicode-range : U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family : 'CodeLight'; + font-style : normal; + src : url('./CODE Light.otf') format('opentype'); + font-display : block; +} + +@font-face { + font-family : 'CodeBold'; + font-style : normal; + src : url('./CODE Bold.otf') format('opentype'); + font-display : block; +} \ No newline at end of file diff --git a/shared/naturalcrit/styles/fonts/open-sans-latin-400-normal.woff2 b/shared/naturalcrit/styles/fonts/open-sans-latin-400-normal.woff2 new file mode 100644 index 000000000..e2d3fa4ea Binary files /dev/null and b/shared/naturalcrit/styles/fonts/open-sans-latin-400-normal.woff2 differ diff --git a/shared/naturalcrit/styles/fonts/open-sans-latin-700-normal.woff2 b/shared/naturalcrit/styles/fonts/open-sans-latin-700-normal.woff2 new file mode 100644 index 000000000..f5977a43a Binary files /dev/null and b/shared/naturalcrit/styles/fonts/open-sans-latin-700-normal.woff2 differ diff --git a/shared/naturalcrit/styles/reset.less b/shared/naturalcrit/styles/reset.less index 21e07a1c0..0fb5f52a0 100644 --- a/shared/naturalcrit/styles/reset.less +++ b/shared/naturalcrit/styles/reset.less @@ -21,3 +21,5 @@ text-transform : unset; background-color : unset; } + +:where(i){ text-box-trim: trim-both } diff --git a/tests/markdown/basic.test.js b/tests/markdown/basic.test.js index f2405d0d8..6a131eb91 100644 --- a/tests/markdown/basic.test.js +++ b/tests/markdown/basic.test.js @@ -8,8 +8,10 @@ test('Processes the markdown within an HTML block if its just a class wrapper', expect(rendered).toBe('

    Bold text

    \n
    '); }); -test('Check markdown is using the custom renderer; specifically that it adds target=_self attribute to internal links in HTML blocks', function() { - const source = '
    [Has _self Attribute?](#p1)
    '; - const rendered = Markdown.render(source); - expect(rendered).toBe(''); -}); +// TEST REMOVED AS IT IS NO LONGER REQUIRED +// +// test('Check markdown is using the custom renderer; specifically that it adds target=_self attribute to internal links in HTML blocks', function() { +// const source = '
    [Has _self Attribute?](#p1)
    '; +// const rendered = Markdown.render(source); +// expect(rendered).toBe(''); +// }); diff --git a/tests/markdown/mustache-syntax.test.js b/tests/markdown/mustache-syntax.test.js index 95ca2f58d..378263e58 100644 --- a/tests/markdown/mustache-syntax.test.js +++ b/tests/markdown/mustache-syntax.test.js @@ -324,7 +324,7 @@ describe('Injection: When an injection tag follows an element', ()=>{ it('Renders an image element with injected style', function() { const source = '![alt text](https://i.imgur.com/hMna6G0.png){position:absolute}'; const rendered = Markdown.render(source).trimReturns(); - expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('

    alt text

    '); + expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe('

    alt text

    '); }); it('Renders an element modified by only the first of two consecutive injections', function() { @@ -343,19 +343,19 @@ describe('Injection: When an injection tag follows an element', ()=>{ it('Renders an image with added attributes', function() { const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`; const rendered = Markdown.render(source).trimReturns(); - expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`

    homebrew mug

    `); + expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`

    homebrew mug

    `); }); it('Renders an image with "=" in the url, and added attributes', function() { const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png?auth=12345&height=1024) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e}`; const rendered = Markdown.render(source).trimReturns(); - expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`

    homebrew mug

    `); + expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`

    homebrew mug

    `); }); it('Renders an image and added attributes with "=" in the value, ', function() { const source = `![homebrew mug](https://i.imgur.com/hMna6G0.png) {position:absolute,bottom:20px,left:130px,width:220px,a="b and c",d=e,otherUrl="url?auth=12345"}`; const rendered = Markdown.render(source).trimReturns(); - expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`

    homebrew mug

    `); + expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`

    homebrew mug

    `); }); }); diff --git a/tests/markdown/variables.test.js b/tests/markdown/variables.test.js index 884553703..ad23c87c1 100644 --- a/tests/markdown/variables.test.js +++ b/tests/markdown/variables.test.js @@ -315,21 +315,21 @@ describe('Normal Links and Images', ()=>{ const source = `![alt text](url)`; const rendered = Markdown.render(source).trimReturns(); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent` -

    alt text

    `.trimReturns()); +

    alt text

    `.trimReturns()); }); it('Renders normal images with a title', function() { const source = 'An image ![alt text](url "and title")!'; const rendered = Markdown.render(source).trimReturns(); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent` -

    An image alt text!

    `.trimReturns()); +

    An image alt text!

    `.trimReturns()); }); it('Applies curly injectors to images', function() { const source = `![alt text](url){width:100px}`; const rendered = Markdown.render(source).trimReturns(); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(dedent` -

    alt text

    `.trimReturns()); +

    alt text

    `.trimReturns()); }); it('Renders normal links', function() { @@ -438,25 +438,25 @@ describe('Regression Tests', ()=>{ it('Handle Extra spaces in image alt-text 1', function(){ const source='![ where is my image??](http://i.imgur.com/hMna6G0.png)'; const rendered = Markdown.render(source).trimReturns(); - expect(rendered).toBe('

    \"where

    '); + expect(rendered).toBe('

    \"where

    '); }); it('Handle Extra spaces in image alt-text 2', function(){ const source='![where is my image??](http://i.imgur.com/hMna6G0.png)'; const rendered = Markdown.render(source).trimReturns(); - expect(rendered).toBe('

    \"where

    '); + expect(rendered).toBe('

    \"where

    '); }); it('Handle Extra spaces in image alt-text 3', function(){ const source='![where is my image?? ](http://i.imgur.com/hMna6G0.png)'; const rendered = Markdown.render(source).trimReturns(); - expect(rendered).toBe('

    \"where

    '); + expect(rendered).toBe('

    \"where

    '); }); it('Handle Extra spaces in image alt-text 4', function(){ const source='![where is my image??](http://i.imgur.com/hMna6G0.png){height=20%,width=20%}'; const rendered = Markdown.render(source).trimReturns(); - expect(rendered).toBe('

    \"where

    '); + expect(rendered).toBe('

    \"where

    '); }); }); diff --git a/themes/Legacy/5ePHB/snippets/coverpage.gen.js b/themes/Legacy/5ePHB/snippets/coverpage.gen.js index c134930cf..9c2de2943 100644 --- a/themes/Legacy/5ePHB/snippets/coverpage.gen.js +++ b/themes/Legacy/5ePHB/snippets/coverpage.gen.js @@ -99,7 +99,7 @@ const subtitles = [ function coverPageGen() { -return ` diff --git a/themes/V3/5ePHB/snippets.js b/themes/V3/5ePHB/snippets.js index 3fcbdd564..fee12e1f6 100644 --- a/themes/V3/5ePHB/snippets.js +++ b/themes/V3/5ePHB/snippets.js @@ -109,28 +109,24 @@ export default [ gen : MonsterBlockGen.monster('monster,frame,wide', 4), }, { - name : 'Front Cover Page', - icon : 'fac book-front-cover', - gen : CoverPageGen.front, - experimental : true + name : 'Front Cover Page', + icon : 'fac book-front-cover', + gen : CoverPageGen.front, }, { - name : 'Inside Cover Page', - icon : 'fac book-inside-cover', - gen : CoverPageGen.inside, - experimental : true + name : 'Inside Cover Page', + icon : 'fac book-inside-cover', + gen : CoverPageGen.inside, }, { - name : 'Part Cover Page', - icon : 'fac book-part-cover', - gen : CoverPageGen.part, - experimental : true + name : 'Part Cover Page', + icon : 'fac book-part-cover', + gen : CoverPageGen.part, }, { - name : 'Back Cover Page', - icon : 'fac book-back-cover', - gen : CoverPageGen.back, - experimental : true + name : 'Back Cover Page', + icon : 'fac book-back-cover', + gen : CoverPageGen.back, }, { name : 'Magic Item', @@ -143,8 +139,8 @@ export default [ gen : function(){ return dedent` {{artist,top:90px,right:30px - ##### Starry Night - [Van Gogh](https://www.vangoghmuseum.nl/en) + ##### Bird with autumn foliage + [by L. Prang & Co.](https://www.loc.gov/resource/pga.14148) }} \n`; }, @@ -209,11 +205,10 @@ export default [ ] }, { - name : 'Rune Table', - icon : 'fas fa-language', - gen : scriptGen.dwarvish, - experimental : true, - subsnippets : [ + name : 'Rune Table', + icon : 'fas fa-language', + gen : scriptGen.dwarvish, + subsnippets : [ { name : 'Dwarvish', icon : 'fac davek', diff --git a/themes/V3/5ePHB/snippets/coverpage.gen.js b/themes/V3/5ePHB/snippets/coverpage.gen.js index 96fc91cbd..c2824bcb2 100644 --- a/themes/V3/5ePHB/snippets/coverpage.gen.js +++ b/themes/V3/5ePHB/snippets/coverpage.gen.js @@ -1,5 +1,6 @@ import _ from 'lodash'; import dedent from 'dedent'; +const domain = window.location.origin; const titles = [ 'The Burning Gallows', 'The Ring of Nenlast', @@ -84,7 +85,7 @@ export default { return dedent` {{frontCover}} - {{logo ![](https://homebrewery.naturalcrit.com/assets/naturalCritLogoRed.svg)}} + {{logo ![](${domain}/assets/naturalCritLogoRed.svg)}} # ${_.sample(titles)} ## ${_.sample(subtitles)} @@ -96,7 +97,7 @@ export default { ${_.sample(footnote)} }} - ![background image](https://homebrewery.naturalcrit.com/assets/demontemple.jpg){position:absolute,bottom:0,left:0,height:100%} + ![The Departure, 1837, by Thomas Cole](${domain}/assets/the_departure.webp){position:absolute,bottom:0,right:-400px,height:100%} \page`; }, @@ -110,10 +111,10 @@ export default { ___ {{imageMaskCenter${_.random(1, 16)},--offsetX:0%,--offsetY:0%,--rotation:0 - ![background image](https://homebrewery.naturalcrit.com/assets/mountaincottage.jpg){position:absolute,bottom:0,left:0,height:100%} + ![The Spirit of War, 1851, by Jasper Francis Cropsey](${domain}/assets/the_spirit_of_war.webp){position:absolute,bottom:100px,right:70px,height:70%} }} - {{logo ![](https://homebrewery.naturalcrit.com/assets/naturalCritLogoRed.svg)}} + {{logo ![](${domain}/assets/naturalCritLogoRed.svg)}} \page`; }, @@ -126,7 +127,7 @@ export default { ## ${_.sample(subtitles)} {{imageMaskEdge${_.random(1, 8)},--offset:10cm,--rotation:180 - ![Background image](https://homebrewery.naturalcrit.com/assets/nightchapel.jpg){position:absolute,bottom:0,left:0,height:100%} + ![The United States Frigate "President" Engaging the British Squadron, 1815, 1850, by Fitz Henry Lane](${domain}/assets/frigate.webp){position:absolute,bottom:0,right:0,height:100%} }} \page`; @@ -143,10 +144,10 @@ export default { For use with any fantasy roleplaying ruleset. Play the best game of your life! - ![background image](https://homebrewery.naturalcrit.com/assets/shopvials.jpg){position:absolute,bottom:0,left:0,height:100%} + ![Italian Coast Scene with Ruined Tower, 1838, by Thomas Cole](${domain}/assets/ruined_tower.webp){position:absolute,bottom:0,right:-250px,height:100%} {{logo - ![](https://homebrewery.naturalcrit.com/assets/naturalCritLogoWhite.svg) + ![](${domain}/assets/naturalCritLogoWhite.svg) Homebrewery.Naturalcrit.com }}`; diff --git a/themes/V3/Blank/snippets.js b/themes/V3/Blank/snippets.js index 0b61c0fd6..738bbda67 100644 --- a/themes/V3/Blank/snippets.js +++ b/themes/V3/Blank/snippets.js @@ -11,6 +11,7 @@ import LicenseDTTRPGGCC from './snippets/licenseDTRPGCC.gen.js'; import LicenseMongoosePublishing from './snippets/licenseMongoose.gen.js'; import TableOfContentsGen from './snippets/tableOfContents.gen.js'; import indexGen from './snippets/index.gen.js'; +const domain = window.location.origin; export default [ @@ -194,10 +195,9 @@ export default [ ] }, { - name : 'Index', - icon : 'fas fa-bars', - gen : indexGen, - experimental : true + name : 'Index', + icon : 'fas fa-bars', + gen : indexGen, }, ] @@ -329,7 +329,7 @@ export default [ }, { name : 'DTRPG Community Content', - incon : 'fab fa-dtrpg', + icon : null, subsnippets : [ { name : 'Chronicle System Guild Colophon', @@ -519,13 +519,13 @@ export default [ { name : 'MIT License', - icon : 'fas fa-mit', + icon : null, gen : LicenseGen.mit, }, { name : 'Mongoose Publishing Fair Use', - icon : 'fas fa-mongoosepub', + icon : null, subsnippets : [ { name : 'Long Form Fair Use', @@ -553,14 +553,14 @@ export default [ { name : 'ORC Notice', - icon : 'fas fa-Paizo', + icon : null, gen : LicenseGen.orc1, }, { name : 'Shadowdark', - icon : 'fab fa-shadowdark', + icon : null, subsnippets : [ { name : 'Logos', @@ -645,25 +645,25 @@ export default [ name : 'Image', icon : 'fas fa-image', gen : dedent` - ![cat warrior](https://homebrewery.naturalcrit.com/assets/catwarrior.jpg) {width:325px,mix-blend-mode:multiply}` + ![Bird with autumn foliage by L. Prang & Co.](${domain}/assets/bird.webp) {width:325px}` }, { name : 'Image Wrap Left', icon : 'fac image-wrap-left', gen : dedent` - ![homebrewery_mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {width:280px,margin-right:-3cm,wrapLeft}` + ![homebrewery_mug](${domain}/assets/homebrewerymug.png) {width:280px,margin-right:-3cm,wrapLeft}` }, { name : 'Image Wrap Right', icon : 'fac image-wrap-right', gen : dedent` - ![homebrewery_mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {width:280px,margin-left:-3cm,wrapRight}` + ![homebrewery_mug](${domain}/assets/homebrewerymug.png) {width:280px,margin-left:-3cm,wrapRight}` }, { name : 'Background Image', icon : 'fas fa-tree', gen : dedent` - ![homebrew mug](https://homebrewery.naturalcrit.com/assets/homebrewerymug.png) {position:absolute,top:50px,right:30px,width:280px}` + ![homebrew mug](${domain}/assets/homebrewerymug.png) {position:absolute,top:50px,right:30px,width:280px}` }, { name : 'Watercolor Splatter', diff --git a/themes/V3/Blank/snippets/imageMask.gen.js b/themes/V3/Blank/snippets/imageMask.gen.js index 670d2de98..833225b36 100644 --- a/themes/V3/Blank/snippets/imageMask.gen.js +++ b/themes/V3/Blank/snippets/imageMask.gen.js @@ -1,11 +1,12 @@ import _ from 'lodash'; import dedent from 'dedent'; +const domain = window.location.origin; export default { center : ()=>{ return dedent` {{imageMaskCenter${_.random(1, 16)},--offsetX:0%,--offsetY:0%,--rotation:0 - ![](https://homebrewery.naturalcrit.com/assets/dragoninflight.jpg){height:100%} + ![The Roman Theater at Taormina, 1828, by Louise-Joséphine sarazin de Belmont](${domain}/assets/roman_theatre.webp){height:100%} }} \n\n`; @@ -32,7 +47,7 @@ export default { const offsetY = (y == 'top' ? '50%' : '-50%'); return dedent` {{imageMaskCorner${_.random(1, 37)},--offsetX:${offsetX},--offsetY:${offsetY},--rotation:0 - ![](https://homebrewery.naturalcrit.com/assets/dragoninflight.jpg){height:100%} + ![The Roman Theater at Taormina, 1828, by Louise-Joséphine sarazin de Belmont](${domain}/assets/roman_theatre.webp){height:100%} }}