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 a5b7c84ad..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 { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js'; const props = window.__INITIAL_PROPS__ || {}; 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/safeHTML.js b/client/homebrew/brewRenderer/safeHTML.js index d9438b663..ca2e59d5f 100644 --- a/client/homebrew/brewRenderer/safeHTML.js +++ b/client/homebrew/brewRenderer/safeHTML.js @@ -32,12 +32,12 @@ function safeHTML(htmlString) { return; } // Check remaining elements for blacklisted attributes - for (const attribute of element.attributes){ + [...element.attributes].forEach((attribute)=>{ if(blacklistAttrs.some((test)=>{return test(attribute);})) { - element.removeAttribute(attribute.localName); - break; + element.removeAttribute(attribute.name); + return; }; - }; + }); }); return div.innerHTML; diff --git a/client/homebrew/brewRenderer/toolBar/toolBar.jsx b/client/homebrew/brewRenderer/toolBar/toolBar.jsx index 97d996633..0f158e0e6 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; @@ -99,11 +99,16 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa return (