0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2025-12-27 09:22:44 +00:00

Merge branch 'master' into refactor-share-page-as-functional-comp

This commit is contained in:
Trevor Buckner
2024-12-26 18:50:48 -05:00
committed by GitHub
33 changed files with 1494 additions and 1836 deletions

View File

@@ -70,9 +70,15 @@ jobs:
- run:
name: Test - Hard Breaks
command: npm run test:hard-breaks
- run:
name: Test - Non-Breaking Spaces
command: npm run test:non-breaking-spaces
- run:
name: Test - Variables
command: npm run test:variables
- run:
name: Test - Emojis
command: npm run test:emojis
- run:
name: Test - Routes
command: npm run test:route
@@ -82,6 +88,9 @@ jobs:
- run:
name: Test - Coverage
command: npm run test:coverage
- run:
name: Test - Content Negotiation
command: npm run test:content-negotiation
workflows:
build_and_test:

View File

@@ -1,26 +1,29 @@
<!--
Before submitting a Pull Request, please consider the following to speed up reviews:
- 👷‍♀️ Create small PRs. Large PRs can usually be broken down into incremental PRs.
- 🚩 Do you already have several open PRs? Consider finishing or asking for help with existing PRs first.
- 🔧 Does your PR reference a discussed and approved issue, especially for personal or edge-case requests?
- 💡 Is the solution agreed upon? Save rework time by discussing strategy before coding.
-->
> [!TIP]
> Before submitting a Pull Request, please consider the following to speed up reviews:
> - 👷‍♀️ Create small PRs. Large PRs can usually be broken down into incremental PRs.
> - 🚩 Do you already have several open PRs? Consider finishing or asking for help with existing PRs first.
> - 🔧 Does your PR reference a discussed and approved issue, especially for personal or edge-case requests?
> - 💡 Is the solution agreed upon? Save rework time by discussing strategy before coding.
## Description
_Describe what your PR accomplishes. Consider walking through the main changes to aid reviewers in following your code, especially if it covers multiple files._
## Related Issues or Discussions
> [!CAUTION]
> If no issue exists yet, create it, and get agreement on the approach (or paste in a previous agreement from chat, etc.) before moving forward. (Experimental PRs are OK without prior discussion, but do not expect to get merged.)
- Closes #
## QA Instructions, Screenshots, Recordings
_Please replace this line with instructions on how to test or view your changes, as well as any before/after
images for UI changes._
_Replace this line with instructions on how to test or view your changes, as well as any before/after
screenshots or recordings for UI changes._
### Reviewer Checklist
_Please replace the list below with specific features you want reviewers to look at._
_Replace the list below with specific features you want reviewers to look at._
*Reviewers, refer to this list when testing features, or suggest new items *
- [ ] Verify new features are functional
@@ -32,5 +35,3 @@ _Please replace the list below with specific features you want reviewers to look
- [ ] Feature A handles negative numbers
- [ ] Identify opportunities for simplification and refactoring
- [ ] Check for code legibility and appropriate comments
<details><summary>Copy this list</summary>

View File

@@ -7,6 +7,11 @@ import './Anchored.less';
// **The Anchor Positioning API is not available in Firefox yet**
// So in Firefox the positioning isn't perfect but is likely sufficient, and FF team seems to be working on the API quickly.
// When Anchor Positioning is added to Firefox, this can also be rewritten using the Popover API-- add the `popover` attribute
// to the container div, which will render the container in the *top level* and give it better interactions like
// click outside to dismiss. **Do not** add without Anchor, though, because positioning is very limited with the `popover`
// attribute.
const Anchored = ({ children })=>{
const [visible, setVisible] = useState(false);

View File

@@ -1,7 +1,7 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./brewRenderer.less');
const React = require('react');
const { useState, useRef, useCallback, useMemo } = React;
const { useState, useRef, useMemo, useEffect } = React;
const _ = require('lodash');
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
@@ -36,8 +36,46 @@ const BrewPage = (props)=>{
index : 0,
...props
};
const pageRef = useRef(null);
const cleanText = safeHTML(props.contents);
return <div className={props.className} id={`p${props.index + 1}`} style={props.style}>
useEffect(()=>{
if(!pageRef.current) return;
// Observer for tracking pages within the `.pages` div
const visibleObserver = new IntersectionObserver(
(entries)=>{
entries.forEach((entry)=>{
if(entry.isIntersecting)
props.onVisibilityChange(props.index + 1, true, false); // add page to array of visible pages.
else
props.onVisibilityChange(props.index + 1, false, false);
}
)},
{ threshold: .3, rootMargin: '0px 0px 0px 0px' } // detect when >30% of page is within bounds.
);
// Observer for tracking the page at the center of the iframe.
const centerObserver = new IntersectionObserver(
(entries)=>{
entries.forEach((entry)=>{
if(entry.isIntersecting)
props.onVisibilityChange(props.index + 1, true, true); // Set this page as the center page
}
)},
{ threshold: 0, rootMargin: '-50% 0px -50% 0px' } // Detect when the page is at the center
);
// attach observers to each `.page`
visibleObserver.observe(pageRef.current);
centerObserver.observe(pageRef.current);
return ()=>{
visibleObserver.disconnect();
centerObserver.disconnect();
};
}, []);
return <div className={props.className} id={`p${props.index + 1}`} data-index={props.index} ref={pageRef} style={props.style}>
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
</div>;
};
@@ -64,8 +102,10 @@ const BrewRenderer = (props)=>{
};
const [state, setState] = useState({
isMounted : false,
visibility : 'hidden'
isMounted : false,
visibility : 'hidden',
visiblePages : [],
centerPage : 1
});
const [displayOptions, setDisplayOptions] = useState({
@@ -83,34 +123,23 @@ const BrewRenderer = (props)=>{
rawPages = props.text.split(/^\\page$/gm);
}
const scrollToHash = (hash)=>{
if(!hash) return;
const handlePageVisibilityChange = (pageNum, isVisible, isCenter)=>{
setState((prevState)=>{
const updatedVisiblePages = new Set(prevState.visiblePages);
if(!isCenter)
isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum);
const iframeDoc = document.getElementById('BrewRenderer').contentDocument;
let anchor = iframeDoc.querySelector(hash);
return {
...prevState,
visiblePages : [...updatedVisiblePages].sort((a, b)=>a - b),
centerPage : isCenter ? pageNum : prevState.centerPage
};
});
if(anchor) {
anchor.scrollIntoView({ behavior: 'smooth' });
} else {
// Use MutationObserver to wait for the element if it's not immediately available
new MutationObserver((mutations, obs)=>{
anchor = iframeDoc.querySelector(hash);
if(anchor) {
anchor.scrollIntoView({ behavior: 'smooth' });
obs.disconnect();
}
}).observe(iframeDoc, { childList: true, subtree: true });
}
if(isCenter)
props.onPageChange(pageNum);
};
const updateCurrentPage = useCallback(_.throttle((e)=>{
const { scrollTop, clientHeight, scrollHeight } = e.target;
const totalScrollableHeight = scrollHeight - clientHeight;
const currentPageNumber = Math.max(Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length), 1);
props.onPageChange(currentPageNumber);
}, 200), []);
const isInView = (index)=>{
if(!state.isMounted)
return false;
@@ -137,19 +166,21 @@ const BrewRenderer = (props)=>{
};
const renderPage = (pageText, index)=>{
const styles = {
...(!displayOptions.pageShadows ? { boxShadow: 'none' } : {})
// Add more conditions as needed
};
if(props.renderer == 'legacy') {
const html = MarkdownLegacy.render(pageText);
return <BrewPage className='page phb' index={index} key={index} contents={html} />;
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
} else {
pageText += `\n\n&nbsp;\n\\column\n&nbsp;`; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
const html = Markdown.render(pageText, index);
const styles = {
...(!displayOptions.pageShadows ? { boxShadow: 'none' } : {})
// Add more conditions as needed
};
return <BrewPage className='page' index={index} key={index} contents={html} style={styles} />;
return <BrewPage className='page' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
}
};
@@ -182,6 +213,26 @@ const BrewRenderer = (props)=>{
}
};
const scrollToHash = (hash)=>{
if(!hash) return;
const iframeDoc = document.getElementById('BrewRenderer').contentDocument;
let anchor = iframeDoc.querySelector(hash);
if(anchor) {
anchor.scrollIntoView({ behavior: 'smooth' });
} else {
// Use MutationObserver to wait for the element if it's not immediately available
new MutationObserver((mutations, obs)=>{
anchor = iframeDoc.querySelector(hash);
if(anchor) {
anchor.scrollIntoView({ behavior: 'smooth' });
obs.disconnect();
}
}).observe(iframeDoc, { childList: true, subtree: true });
}
};
const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount"
scrollToHash(window.location.hash);
@@ -217,13 +268,13 @@ const BrewRenderer = (props)=>{
}
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
renderedPages = useMemo(()=>renderPages(), [props.text]);
renderedPages = useMemo(()=>renderPages(), [props.text, displayOptions]);
return (
<>
{/*render dummy page while iFrame is mounting.*/}
{!state.isMounted
? <div className='brewRenderer' onScroll={updateCurrentPage}>
? <div className='brewRenderer'>
<div className='pages'>
{renderDummyPage(1)}
</div>
@@ -236,7 +287,7 @@ const BrewRenderer = (props)=>{
<NotificationPopup />
</div>
<ToolBar displayOptions={displayOptions} currentPage={props.currentBrewRendererPageNum} totalPages={rawPages.length} onDisplayOptionsChange={handleDisplayOptionsChange} />
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length}/>
{/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
@@ -245,18 +296,17 @@ const BrewRenderer = (props)=>{
onClick={()=>{emitClick();}}
>
<div className={`brewRenderer ${global.config.deployment && 'deployment'}`}
onScroll={updateCurrentPage}
onKeyDown={handleControlKeys}
tabIndex={-1}
style={ styleObject }>
style={ styleObject }
>
{/* Apply CSS from Style tab and render pages from Markdown tab */}
{state.isMounted
&&
<>
{renderedStyle}
<div lang={`${props.lang || 'en'}`} style={pagesStyle} className={
`pages ${displayOptions.startOnRight ? 'recto' : 'verso'} ${displayOptions.spread}` } >
<div className={`pages ${displayOptions.startOnRight ? 'recto' : 'verso'} ${displayOptions.spread}`} lang={`${props.lang || 'en'}`} style={pagesStyle}>
{renderedPages}
</div>
</>

View File

@@ -17,7 +17,7 @@
grid-template-columns: repeat(2, auto);
grid-template-rows: repeat(3, auto);
gap: 10px 10px;
justify-content: center;
justify-content: safe center;
&.recto .page:first-child {
// sets first page on 'right' ('recto') of the preview, as if for a Cover page.
// todo: add a checkbox to toggle this setting
@@ -33,7 +33,7 @@
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-start;
justify-content: safe center;
& :where(.page) {
flex: 0 0 auto;
margin-left: unset !important;

View File

@@ -1,29 +1,30 @@
/* eslint-disable max-lines */
require('./toolBar.less');
const React = require('react');
const { useState, useEffect } = React;
const _ = require('lodash');
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
// import * as ZoomIcons from '../../../icons/icon-components/zoomIcons.jsx';
const MAX_ZOOM = 300;
const MIN_ZOOM = 10;
const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChange })=>{
const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages })=>{
const [pageNum, setPageNum] = useState(currentPage);
const [pageNum, setPageNum] = useState(1);
const [toolsVisible, setToolsVisible] = useState(true);
useEffect(()=>{
setPageNum(currentPage);
}, [currentPage]);
// format multiple visible pages as a range (e.g. "150-153")
const pageRange = visiblePages.length === 1 ? `${visiblePages[0]}` : `${visiblePages[0]} - ${visiblePages.at(-1)}`;
setPageNum(pageRange);
}, [visiblePages]);
const handleZoomButton = (zoom)=>{
handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
};
const handleOptionChange = (optionKey, newValue)=>{
//setDisplayOptions(prevOptions => ({ ...prevOptions, [optionKey]: newValue }));
onDisplayOptionsChange({ ...displayOptions, [optionKey]: newValue });
};
@@ -32,16 +33,16 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
setPageNum(parseInt(pageInput)); // input type is 'text', so `page` comes in as a string, not number.
};
// scroll to a page, used in the Prev/Next Page buttons.
const scrollToPage = (pageNumber)=>{
if(typeof pageNumber !== 'number') return;
pageNumber = _.clamp(pageNumber, 1, totalPages);
const iframe = document.getElementById('BrewRenderer');
const brewRenderer = iframe?.contentWindow?.document.querySelector('.brewRenderer');
const page = brewRenderer?.querySelector(`#p${pageNumber}`);
page?.scrollIntoView({ block: 'start' });
setPageNum(pageNumber);
};
const calculateChange = (mode)=>{
const iframe = document.getElementById('BrewRenderer');
const iframeWidth = iframe.getBoundingClientRect().width;
@@ -57,8 +58,12 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
desiredZoom = (iframeWidth / widestPage) * 100;
} else if(mode == 'fit'){
let minDimRatio;
// find the page with the largest single dim (height or width) so that zoom can be adapted to fit it.
const minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity);
if(displayOptions.spread === 'facing')
minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth / 2), Infinity); // if 'facing' spread, fit two pages in view
else
minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity);
desiredZoom = minDimRatio * 100;
}
@@ -185,8 +190,8 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
className='previousPage tool'
type='button'
title='Previous Page(s)'
onClick={()=>scrollToPage(pageNum - 1)}
disabled={pageNum <= 1}
onClick={()=>scrollToPage(_.min(visiblePages) - visiblePages.length)}
disabled={visiblePages.includes(1)}
>
<i className='fas fa-arrow-left'></i>
</button>
@@ -205,6 +210,7 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
onChange={(e)=>handlePageInput(e.target.value)}
onBlur={()=>scrollToPage(pageNum)}
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
style={{ width: `${pageNum.length}ch` }}
/>
<span id='page-count' title='Total Page Count'>/ {totalPages}</span>
</div>
@@ -214,8 +220,8 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
className='tool'
type='button'
title='Next Page(s)'
onClick={()=>scrollToPage(pageNum + 1)}
disabled={pageNum >= totalPages}
onClick={()=>scrollToPage(_.max(visiblePages) + 1)}
disabled={visiblePages.includes(totalPages)}
>
<i className='fas fa-arrow-right'></i>
</button>

View File

@@ -104,9 +104,9 @@
height : 1.5em;
padding : 2px 5px;
font-family : 'Open Sans', sans-serif;
color : #000000;
background : #EEEEEE;
border : 1px solid gray;
color : inherit;
background : #3B3B3B;
border : none;
&:focus { outline : 1px solid #D3D3D3; }
// `.range-input` if generic to all range inputs, or `#zoom-slider` if only for zoom slider
@@ -115,10 +115,10 @@
color : #D3D3D3;
accent-color : #D3D3D3;
&::-webkit-slider-thumb, &::-moz-slider-thumb {
&::-webkit-slider-thumb, &::-moz-range-thumb {
width : 5px;
height : 5px;
cursor : pointer;
cursor : ew-resize;
outline : none;
}
@@ -141,7 +141,7 @@
// `.text-input` if generic to all range inputs, or `#page-input` if only for current page input
&#page-input {
width : 4ch;
min-width : 5ch;
margin-right : 1ch;
text-align : center;
}

View File

@@ -207,19 +207,11 @@ const Snippetbar = createClass({
renderEditorButtons : function(){
if(!this.props.showEditButtons) return;
const foldButtons = <>
<div className={`editorTool foldAll ${this.props.view !== 'meta' && this.props.foldCode ? 'active' : ''}`}
onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.view !== 'meta' && this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
</>;
return <div className='editors'>
<div className='historyTools'>
return (
<div className='editors'>
{this.props.view !== 'meta' && <><div className='historyTools'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={this.toggleHistoryMenu} >
<i className='fas fa-clock-rotate-left' />
@@ -235,14 +227,21 @@ const Snippetbar = createClass({
</div>
</div>
<div className='codeTools'>
{foldButtons}
<div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`}
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()}
</div>
</div>
</div></>}
<div className='tabs'>
<div className={cx('text', { selected: this.props.view === 'text' })}
@@ -259,7 +258,8 @@ const Snippetbar = createClass({
</div>
</div>
</div>;
</div>
)
},
render : function(){

View File

@@ -22,7 +22,7 @@
justify-content : flex-end;
min-width : 225px;
&:only-child { margin-left : auto; }
&:only-child { margin-left : auto;min-width:unset;}
>div {
display : flex;
@@ -38,6 +38,11 @@
line-height : @menuHeight;
text-align : center;
cursor : pointer;
&.editorTool:not(.active) {
cursor:not-allowed;
}
&:hover,&.selected { background-color : #999999; }
&.text {
.tooltipLeft('Brew Editor');

View File

@@ -1,8 +1,12 @@
//╔===--------------- Polyfills --------------===╗//
import 'core-js/es/string/to-well-formed.js';
//╚===--------------- ---------------===╝//
require('./homebrew.less');
const React = require('react');
const createClass = require('create-react-class');
const { StaticRouter:Router } = require('react-router-dom/server');
const { Route, Routes, useParams, useSearchParams } = require('react-router-dom');
const { StaticRouter:Router } = require('react-router');
const { Route, Routes, useParams, useSearchParams } = require('react-router');
const HomePage = require('./pages/homePage/homePage.jsx');
const EditPage = require('./pages/editPage/editPage.jsx');

View File

@@ -1,6 +1,6 @@
require('./brewItem.less');
const React = require('react');
const createClass = require('create-react-class');
const { useCallback } = React;
const moment = require('moment');
import request from '../../../../utils/request-middleware.js';
@@ -8,176 +8,172 @@ const googleDriveIcon = require('../../../../googleDrive.svg');
const homebreweryIcon = require('../../../../thumbnail.png');
const dedent = require('dedent-tabs').default;
const BrewItem = createClass({
displayName : 'BrewItem',
getDefaultProps : function() {
return {
brew : {
title : '',
description : '',
authors : [],
stubbed : true
},
updateListFilter : ()=>{},
reportError : ()=>{},
renderStorage : true
};
const BrewItem = ({
brew = {
title : '',
description : '',
authors : [],
stubbed : true,
},
updateListFilter = ()=>{},
reportError = ()=>{},
renderStorage = true,
})=>{
deleteBrew : function(){
if(this.props.brew.authors.length <= 1){
if(!confirm('Are you sure you want to delete this brew? Because you are the only owner of this brew, the document will be deleted permanently.')) return;
if(!confirm('Are you REALLY sure? You will not be able to recover the document.')) return;
const deleteBrew = useCallback(()=>{
if(brew.authors.length <= 1) {
if(!window.confirm('Are you sure you want to delete this brew? Because you are the only owner of this brew, the document will be deleted permanently.')) return;
if(!window.confirm('Are you REALLY sure? You will not be able to recover the document.')) return;
} else {
if(!confirm('Are you sure you want to remove this brew from your collection? This will remove you as an editor, but other owners will still be able to access the document.')) return;
if(!confirm('Are you REALLY sure? You will lose editor access to this document.')) return;
if(!window.confirm('Are you sure you want to remove this brew from your collection? This will remove you as an editor, but other owners will still be able to access the document.')) return;
if(!window.confirm('Are you REALLY sure? You will lose editor access to this document.')) return;
}
request.delete(`/api/${this.props.brew.googleId ?? ''}${this.props.brew.editId}`)
.send()
.end((err, res)=>{
if(err) {
this.props.reportError(err);
} else {
location.reload();
}
});
},
request.delete(`/api/${brew.googleId ?? ''}${brew.editId}`).send().end((err, res)=>{
if (err) reportError(err); else window.location.reload();
});
}, [brew, reportError]);
updateFilter : function(type, term){
this.props.updateListFilter(type, term);
},
const updateFilter = useCallback((type, term)=> updateListFilter(type, term), [updateListFilter]);
renderDeleteBrewLink : function(){
if(!this.props.brew.editId) return;
const renderDeleteBrewLink = ()=>{
if(!brew.editId) return null;
return <a className='deleteLink' onClick={this.deleteBrew}>
<i className='fas fa-trash-alt' title='Delete' />
</a>;
},
return (
<a className='deleteLink' onClick={deleteBrew}>
<i className='fas fa-trash-alt' title='Delete' />
</a>
);
};
renderEditLink : function(){
if(!this.props.brew.editId) return;
const renderEditLink = ()=>{
if(!brew.editId) return null;
let editLink = this.props.brew.editId;
if(this.props.brew.googleId && !this.props.brew.stubbed) {
editLink = this.props.brew.googleId + editLink;
let editLink = brew.editId;
if(brew.googleId && !brew.stubbed) editLink = brew.googleId + editLink;
return (
<a className='editLink' href={`/edit/${editLink}`} target='_blank' rel='noopener noreferrer'>
<i className='fas fa-pencil-alt' title='Edit' />
</a>
);
};
const renderShareLink = ()=>{
if(!brew.shareId) return null;
let shareLink = brew.shareId;
if(brew.googleId && !brew.stubbed) {
shareLink = brew.googleId + shareLink;
}
return <a className='editLink' href={`/edit/${editLink}`} target='_blank' rel='noopener noreferrer'>
<i className='fas fa-pencil-alt' title='Edit' />
</a>;
},
return (
<a className='shareLink' href={`/share/${shareLink}`} target='_blank' rel='noopener noreferrer'>
<i className='fas fa-share-alt' title='Share' />
</a>
);
};
renderShareLink : function(){
if(!this.props.brew.shareId) return;
const renderDownloadLink = ()=>{
if(!brew.shareId) return null;
let shareLink = this.props.brew.shareId;
if(this.props.brew.googleId && !this.props.brew.stubbed) {
shareLink = this.props.brew.googleId + shareLink;
let shareLink = brew.shareId;
if(brew.googleId && !brew.stubbed) {
shareLink = brew.googleId + shareLink;
}
return <a className='shareLink' href={`/share/${shareLink}`} target='_blank' rel='noopener noreferrer'>
<i className='fas fa-share-alt' title='Share' />
</a>;
},
return (
<a className='downloadLink' href={`/download/${shareLink}`}>
<i className='fas fa-download' title='Download' />
</a>
);
};
renderDownloadLink : function(){
if(!this.props.brew.shareId) return;
let shareLink = this.props.brew.shareId;
if(this.props.brew.googleId && !this.props.brew.stubbed) {
shareLink = this.props.brew.googleId + shareLink;
const renderStorageIcon = ()=>{
if(!renderStorage) return null;
if(brew.googleId) {
return (
<span title={brew.webViewLink ? 'Your Google Drive Storage' : 'Another User\'s Google Drive Storage'}>
<a href={brew.webViewLink} target='_blank'>
<img className='googleDriveIcon' src={googleDriveIcon} alt='googleDriveIcon' />
</a>
</span>
);
}
return <a className='downloadLink' href={`/download/${shareLink}`}>
<i className='fas fa-download' title='Download' />
</a>;
},
return (
<span title='Homebrewery Storage'>
<img className='homebreweryIcon' src={homebreweryIcon} alt='homebreweryIcon' />
</span>
);
};
renderStorageIcon : function(){
if(!this.props.renderStorage) return;
if(this.props.brew.googleId) {
return <span title={this.props.brew.webViewLink ? 'Your Google Drive Storage': 'Another User\'s Google Drive Storage'}>
<a href={this.props.brew.webViewLink} target='_blank'>
<img className='googleDriveIcon' src={googleDriveIcon} alt='googleDriveIcon' />
</a>
</span>;
}
if(Array.isArray(brew.tags)) {
brew.tags = brew.tags?.filter((tag)=>tag); // remove tags that are empty strings
brew.tags.sort((a, b)=>{
return a.indexOf(':') - b.indexOf(':') !== 0 ? a.indexOf(':') - b.indexOf(':') : a.toLowerCase().localeCompare(b.toLowerCase());
});
}
return <span title='Homebrewery Storage'>
<img className='homebreweryIcon' src={homebreweryIcon} alt='homebreweryIcon' />
</span>;
},
const dateFormatString = 'YYYY-MM-DD HH:mm:ss';
render : function(){
const brew = this.props.brew;
if(Array.isArray(brew.tags)) { // temporary fix until dud tags are cleaned
brew.tags = brew.tags?.filter((tag)=>tag); //remove tags that are empty strings
brew.tags.sort((a, b)=>{
return a.indexOf(':') - b.indexOf(':') != 0 ? a.indexOf(':') - b.indexOf(':') : a.toLowerCase().localeCompare(b.toLowerCase());
});
}
const dateFormatString = 'YYYY-MM-DD HH:mm:ss';
return <div className='brewItem'>
{brew.thumbnail &&
<div className='thumbnail' style={{ backgroundImage: `url(${brew.thumbnail})` }} >
</div>
}
return (
<div className='brewItem'>
{brew.thumbnail && <div className='thumbnail' style={{ backgroundImage: `url(${brew.thumbnail})` }}></div>}
<div className='text'>
<h2>{brew.title}</h2>
<p className='description'>{brew.description}</p>
</div>
<hr />
<div className='info'>
{brew.tags?.length ? <>
{brew.tags?.length ? (
<div className='brewTags' title={`${brew.tags.length} tags:\n${brew.tags.join('\n')}`}>
<i className='fas fa-tags'/>
<i className='fas fa-tags' />
{brew.tags.map((tag, idx)=>{
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
return <span key={idx} className={matches[1]} onClick={()=>{this.updateFilter(tag);}}>{matches[2]}</span>;
return <span key={idx} className={matches[1]} onClick={()=>updateFilter(tag)}>{matches[2]}</span>;
})}
</div>
</> : <></>
}
) : null}
<span title={`Authors:\n${brew.authors?.join('\n')}`}>
<i className='fas fa-user'/> {brew.authors?.map((author, index)=>(
<i className='fas fa-user' />{' '}
{brew.authors?.map((author, index)=>(
<React.Fragment key={index}>
{author === 'hidden'
? <span title="Username contained an email address; hidden to protect user's privacy">{author}</span>
: <a href={`/user/${author}`}>{author}</a>
}
{author === 'hidden' ? (
<span title="Username contained an email address; hidden to protect user's privacy">
{author}
</span>
) : (<a href={`/user/${author}`}>{author}</a>)}
{index < brew.authors.length - 1 && ', '}
</React.Fragment>
))}
</span>
<br />
<span title={`Last viewed: ${moment(brew.lastViewed).local().format(dateFormatString)}`}>
<i className='fas fa-eye'/> {brew.views}
<i className='fas fa-eye' /> {brew.views}
</span>
{brew.pageCount &&
{brew.pageCount && (
<span title={`Page count: ${brew.pageCount}`}>
<i className='far fa-file' /> {brew.pageCount}
</span>
}
<span title={dedent`
Created: ${moment(brew.createdAt).local().format(dateFormatString)}
Last updated: ${moment(brew.updatedAt).local().format(dateFormatString)}`}>
)}
<span
title={dedent` Created: ${moment(brew.createdAt).local().format(dateFormatString)}
Last updated: ${moment(brew.updatedAt).local().format(dateFormatString)}`}
>
<i className='fas fa-sync-alt' /> {moment(brew.updatedAt).fromNow()}
</span>
{this.renderStorageIcon()}
{renderStorageIcon()}
</div>
<div className='links'>
{this.renderShareLink()}
{this.renderEditLink()}
{this.renderDownloadLink()}
{this.renderDeleteBrewLink()}
{renderShareLink()}
{renderEditLink()}
{renderDownloadLink()}
{renderDeleteBrewLink()}
</div>
</div>;
}
});
</div>
);
};
module.exports = BrewItem;

View File

@@ -381,7 +381,7 @@ const EditPage = createClass({
**[Homebrewery Link](${global.config.publicUrl}/share/${shareLink})**`;
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title)}&text=${encodeURIComponent(text)}`;
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title.toWellFormed())}&text=${encodeURIComponent(text)}`;
},
renderNavbar : function(){

View File

@@ -18,7 +18,18 @@ const errorIndex = (props)=>{
'01' : dedent`
## An error occurred while retrieving this brew from Google Drive!
Google reported an error while attempting to retrieve a brew from this link.`,
Google is able to see the brew at this link, but reported an error while attempting to retrieve it.
### Refreshing your Google Credentials
This issue is likely caused by an issue with your Google credentials; if you are the owner of this file, the following steps may resolve the issue:
- Go to https://www.naturalcrit.com/login and click logout if present (in small text at the bottom of the page).
- Click "Sign In with Google", which will refresh your Google credentials.
- After completing the sign in process, return to Homebrewery and refresh/reload the page so that it can pick up the updated credentials.
- If this was the source of the issue, it should now be resolved.
If following these steps does not resolve the issue, please let us know!`,
// Google Drive - 404 : brew deleted or access denied
'02' : dedent`
@@ -50,7 +61,7 @@ const errorIndex = (props)=>{
- **The Google Account may be closed.** Google may have removed the account
due to inactivity or violating a Google policy. Make sure the owner can
still access Google Drive normally and upload/download files to it.
:
If the file isn't found, Google Drive usually puts your file in your Trash folder for
30 days. Assuming the trash hasn't been emptied yet, it might be worth checking.
You can also find the Activity tab on the right side of the Google Drive page, which
@@ -172,8 +183,8 @@ const errorIndex = (props)=>{
**Brew Title:** ${props.brew.brewTitle}`,
// ####### Admin page error #######
'52': dedent`
// ####### Admin page error #######
'52' : dedent`
## Access Denied
You need to provide correct administrator credentials to access this page.`,

View File

@@ -92,49 +92,11 @@
&:invalid { background : rgb(255, 188, 181); }
&[type='checkbox'] {
position : relative;
display : inline-block;
width : 50px;
height : 30px;
font-family : 'WalterTurncoat';
font-size : 20px;
font-weight : 800;
color : white;
letter-spacing : 2px;
appearance : none;
background : red;
isolation : isolate;
border-radius : 5px;
&::before,&::after {
position : absolute;
inset : 0;
z-index : 5;
padding-top : 2px;
text-align : center;
}
&::before {
display : block;
content : 'No';
}
&::after {
display : none;
content : 'Yes';
}
&:checked {
background : green;
&::before { display : none; }
&::after { display : block; }
}
}
}
#searchButton {
.colorButton(@green);
position : absolute;
right : 20px;
bottom : 0;
@@ -152,7 +114,6 @@
flex-direction : column;
height : 100%;
overflow-y : auto;
font-family : 'BookInsanityRemake';
font-size : 0.34cm;
h3 {
@@ -356,6 +317,7 @@
}
button {
.colorButton(@green);
width : max-content;
&.previousPage { grid-area : previousPage; }

19
faq.md
View File

@@ -69,7 +69,6 @@ pre {
You can check the site status here: [Everyone or Just Me](https://downforeveryoneorjustme.com/homebrewery.naturalcrit.com)
### Why am I getting an error when trying to save, and my account is linked to Google?
A sign-in with Google only lasts a year until the authentication expires. You must go [here](https://www.naturalcrit.com/login), click the *Log-out* button, and then sign back in using your Google account.
@@ -82,12 +81,17 @@ If you have linked your account with a Google account, you would change your pas
### Is there a way to restore a previous version of my brew?
Currently, there is no way to do this through the site yourself. This would take too much of a toll on the amount of storage the homebrewery requires. However, we do have daily backups of our database that we keep for 8 days, and you can contact the moderators on [the subreddit](https://www.reddit.com/r/homebrewery) with your Homebrewery username, the name of the lost brew, and the last known time it was working properly. We can manually look through our backups and restore it if it exists.
In your brew, there is an icon, :fas_clock_rotate_left:, that button opens up a menu with versions of your brew, stored in order from newer to older, up to a week old. Because of the amount of duplicates this function creates, this information is stored in **your browser**, so if you were to uninstall it or clear your cookies and site data, or change computers, the info will not be there.
Also, we do have daily backups of our database that we keep for 8 days, and you can contact the moderators on [the subreddit](https://www.reddit.com/r/homebrewery) with your Homebrewery username, the name of the lost brew, and the last known time it was working properly. We can manually look through our backups and restore it if it exists.
### I worked on a brew for X hours, and suddenly all the text disappeared!
This usually happens if you accidentally drag-select all of your text and then start typing which overwrites the selection. Do not panic, and do not refresh the page or reload your brew quite yet as it is probably auto-saved in this state already. Simply press CTRL+Z as many times as needed to undo your last few changes and you will be back to where you were, then make sure to save your brew in the "good" state.
You can also load a history version old enough to have all the text, using the :fas_clock_rotate_left: history versions button.
\column
### Why is only Chrome supported?
@@ -112,10 +116,7 @@ Once you have an image you would like to use, it is recommended to host it somew
\page
### A particular font does not work for my language, what do I do?
The fonts used were originally created for use with the English language, though revisions since then have added more support for other languages. They are still not complete sets and may be missing a glyph/character you need. Unfortunately, the volunteer group as it stands at the time of this writing does not have a font guru, so it would be difficult to add more glyphs (especially complicated glyphs). Let us know which glyph is missing on the subreddit, but you may need to search [Google Fonts](https://fonts.google.com) for an alternative font if you need something fast.
### Whenever I click on the "Get PDF" button, instead of getting a download, it opens Print Preview in another tab.
Yes, this is by design. In the print preview, select "Save as PDF" as the Destination, and then click "Save". There will be a normal download dialog where you can save your brew as a PDF.
The fonts used were originally created for use with the English language, though revisions since then have added more support for other languages. They are still not complete sets and may be missing a glyph/character you need. Unfortunately, the volunteer group as it stands at the time of this writing does not have a font guru, so it would be difficult to add more glyphs (especially complicated glyphs). Let us know which glyph is missing on the subreddit, but you may need to search [Google Fonts](https://fonts.google.com) for an alternative font if you need something fast.
### I have white borders on the bottom/sides of the print preview.
@@ -126,4 +127,8 @@ The Homebrewery defaults to creating US Letter page sizes. If you are printing
### Typing `#### Adhesion` in the text editor doesn't show the header at all in the completed page?
Your ad-blocking software is mistakenly assuming your text to be an ad. Whitelist homebrewery.naturalcrit.com in your ad-blocking software.
Your ad-blocking software is mistakenly assuming your text to be an ad. We recommend whitelisting homebrewery.naturalcrit.com in your ad-blocking software, as we have no ads.
### My username appears as _hidden_ when checking my brews in the Vault, why is that?
Your username is most likely your e-mail adress, and our code is picking that up and protecting your identity. This will remain as is, but you can ask for a name change by contacting the moderators on [the subreddit](https://www.reddit.com/r/homebrewery) with your Homebrewery username, and your desired new name. You will also be asked to provide details about some of your unpublished brews, to verify your identity. No information will be leaked or shared.

2173
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@
"test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose",
"test:api-unit:css": "jest \"server/.*.spec.js\" -t \"Get CSS\" --verbose",
"test:api-unit:notifications": "jest \"server/.*.spec.js\" -t \"Notifications\" --verbose",
"test:content-negotiation": "jest \"server/middleware/.*.spec.js\" --verbose",
"test:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose",
@@ -37,6 +38,7 @@
"test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace",
"test:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace",
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
"test:non-breaking-spaces": "jest tests/markdown/non-breaking-spaces.test.js --verbose --noStackTrace",
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
"test:route": "jest tests/routes/static-pages.test.js --verbose",
"test:safehtml": "jest tests/html/safeHTML.test.js --verbose",
@@ -85,17 +87,19 @@
"@babel/core": "^7.26.0",
"@babel/plugin-transform-runtime": "^7.25.9",
"@babel/preset-env": "^7.26.0",
"@babel/preset-react": "^7.25.9",
"@babel/preset-react": "^7.26.3",
"@googleapis/drive": "^8.14.0",
"body-parser": "^1.20.2",
"classnames": "^2.5.1",
"codemirror": "^5.65.6",
"cookie-parser": "^1.4.7",
"core-js": "^3.39.0",
"cors": "^2.8.5",
"create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3",
"dompurify": "^3.2.1",
"dompurify": "^3.2.3",
"expr-eval": "^2.0.2",
"express": "^4.21.1",
"express": "^4.21.2",
"express-async-handler": "^1.2.0",
"express-static-gzip": "2.2.0",
"fs-extra": "11.2.0",
@@ -111,13 +115,13 @@
"marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
"mongoose": "^8.8.2",
"nanoid": "5.0.8",
"mongoose": "^8.9.2",
"nanoid": "5.0.9",
"nconf": "^0.12.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-frame-component": "^4.1.3",
"react-router-dom": "6.28.0",
"react-router": "^7.1.1",
"sanitize-filename": "1.6.3",
"superagent": "^10.1.1",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
@@ -125,15 +129,15 @@
"devDependencies": {
"@stylistic/stylelint-plugin": "^3.1.1",
"babel-plugin-transform-import-meta": "^2.2.1",
"eslint": "^9.15.0",
"eslint-plugin-jest": "^28.9.0",
"eslint-plugin-react": "^7.37.2",
"globals": "^15.12.0",
"eslint": "^9.17.0",
"eslint-plugin-jest": "^28.10.0",
"eslint-plugin-react": "^7.37.3",
"globals": "^15.14.0",
"jest": "^29.7.0",
"jest-expect-message": "^1.1.3",
"jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0",
"stylelint": "^16.10.0",
"stylelint": "^16.12.0",
"stylelint-config-recess-order": "^5.1.1",
"stylelint-config-recommended": "^14.0.1",
"supertest": "^7.0.0"

View File

@@ -7,11 +7,12 @@ const { pack, watchFile, livereload } = vitreum;
import lessTransform from 'vitreum/transforms/less.js';
import assetTransform from 'vitreum/transforms/asset.js';
import babel from '@babel/core';
import babelConfig from '../babel.config.json' with { type : 'json' };
import less from 'less';
const isDev = !!process.argv.find((arg) => arg === '--dev');
const babelify = async (code)=>(await babel.transformAsync(code, { presets: [['@babel/preset-env', { 'exclude': ['proposal-dynamic-import'] }], '@babel/preset-react'], plugins: ['@babel/plugin-transform-runtime'] })).code;
const babelify = async (code)=>(await babel.transformAsync(code, babelConfig)).code;
const transforms = {
'.js' : (code, filename, opts)=>babelify(code),

View File

@@ -1,5 +1,5 @@
import {model as HomebrewModel } from './homebrew.model.js';
import {model as NotificationModel } from './notifications.model.js';
import { model as HomebrewModel } from './homebrew.model.js';
import { model as NotificationModel } from './notifications.model.js';
import express from 'express';
import Moment from 'moment';
import zlib from 'zlib';
@@ -108,6 +108,9 @@ router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin',
req.body = brew;
// Remove Account from request to prevent Admin user from being added to brew as an Author
req.account = undefined;
return await HomebrewAPI.updateBrew(req, res);
});

View File

@@ -2,7 +2,7 @@
// Set working directory to project root
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import packageJSON from './../package.json' with { type: "json" };
import packageJSON from './../package.json' with { type: 'json' };
const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(`${__dirname}/..`);
@@ -26,7 +26,7 @@ import serveCompressedStaticAssets from './static-assets.mv.js';
import sanitizeFilename from 'sanitize-filename';
import asyncHandler from 'express-async-handler';
import templateFn from '../client/template.js';
import {model as HomebrewModel } from './homebrew.model.js';
import { model as HomebrewModel } from './homebrew.model.js';
import { DEFAULT_BREW } from './brewDefaults.js';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
@@ -47,7 +47,7 @@ const sanitizeBrew = (brew, accessType)=>{
return brew;
};
app.set('trust proxy', 1 /* number of proxies between user and server */)
app.set('trust proxy', 1 /* number of proxies between user and server */);
app.use('/', serveCompressedStaticAssets(`build`));
app.use(contentNegotiation);
@@ -55,6 +55,40 @@ app.use(bodyParser.json({ limit: '25mb' }));
app.use(cookieParser());
app.use(forceSSL);
import cors from 'cors';
const nodeEnv = config.get('node_env');
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
const corsOptions = {
origin : (origin, callback)=>{
const allowedOrigins = [
'https://homebrewery.naturalcrit.com',
'https://www.naturalcrit.com',
'https://naturalcrit-stage.herokuapp.com',
'https://homebrewery-stage.herokuapp.com',
];
if(isLocalEnvironment) {
allowedOrigins.push('http://localhost:8000', 'http://localhost:8010');
}
const herokuRegex = /^https:\/\/(?:homebrewery-pr-\d+\.herokuapp\.com|naturalcrit-pr-\d+\.herokuapp\.com)$/; // Matches any Heroku app
if(!origin || allowedOrigins.includes(origin) || herokuRegex.test(origin)) {
callback(null, true);
} else {
console.log(origin, 'not allowed');
callback(new Error('Not allowed by CORS, if you think this is an error, please contact us'));
}
},
methods : ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials : true,
};
app.use(cors(corsOptions));
//Account Middleware
app.use((req, res, next)=>{
if(req.cookies && req.cookies.nc_session){
@@ -62,7 +96,9 @@ app.use((req, res, next)=>{
req.account = jwt.decode(req.cookies.nc_session, config.get('secret'));
//console.log("Just loaded up JWT from cookie:");
//console.log(req.account);
} catch (e){}
} catch (e){
console.log(e);
}
}
req.config = {
@@ -273,7 +309,7 @@ app.get('/user/:username', async (req, res, next)=>{
console.log(err);
});
brews.forEach(brew => brew.stubbed = true); //All brews from MongoDB are "stubbed"
brews.forEach((brew)=>brew.stubbed = true); //All brews from MongoDB are "stubbed"
if(ownAccount && req?.account?.googleId){
const auth = await GoogleActions.authCheck(req.account, res);
@@ -312,6 +348,34 @@ app.get('/user/:username', async (req, res, next)=>{
return next();
});
//Change author name on brews
app.put('/api/user/rename', async (req, res)=>{
const { username, newUsername } = req.body;
const ownAccount = req.account && (req.account.username == newUsername);
if(!username || !newUsername)
return res.status(400).json({ error: 'Username and newUsername are required.' });
if(!ownAccount)
return res.status(403).json({ error: 'Must be logged in to change your username' });
try {
const brews = await HomebrewModel.getByUser(username, true, ['authors']);
const renamePromises = brews.map(async (brew)=>{
const updatedAuthors = brew.authors.map((author)=>author === username ? newUsername : author
);
return HomebrewModel.updateOne(
{ _id: brew._id },
{ $set: { authors: updatedAuthors } }
);
});
await Promise.all(renamePromises);
return res.json({ success: true, message: `Brews for ${username} renamed to ${newUsername}.` });
} catch (error) {
console.error('Error renaming brews:', error);
return res.status(500).json({ error: 'Failed to rename brews.' });
}
});
//Edit Page
app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
@@ -399,7 +463,7 @@ app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, r
app.get('/account', asyncHandler(async (req, res, next)=>{
const data = {};
data.title = 'Account Information Page';
if(!req.account) {
res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"');
const error = new Error('No valid account');
@@ -413,7 +477,7 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
let googleCount = [];
if(req.account) {
if(req.account.googleId) {
auth = await GoogleActions.authCheck(req.account, res, false)
auth = await GoogleActions.authCheck(req.account, res, false);
googleCount = await GoogleActions.listGoogleBrews(auth)
.catch((err)=>{
@@ -448,8 +512,6 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
return next();
}));
const nodeEnv = config.get('node_env');
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
// Local only
if(isLocalEnvironment){
// Login
@@ -477,8 +539,8 @@ app.get('/vault', asyncHandler(async(req, res, next)=>{
//Send rendered page
app.use(asyncHandler(async (req, res, next)=>{
if (!req.route) return res.redirect('/'); // Catch-all for invalid routes
if(!req.route) return res.redirect('/'); // Catch-all for invalid routes
const page = await renderPage(req, res);
if(!page) return;
res.send(page);

View File

@@ -241,8 +241,8 @@ const GoogleActions = {
return obj.data.id;
},
getGoogleBrew : async (id, accessId, accessType)=>{
const drive = googleDrive.drive({ version: 'v3', auth: defaultAuth });
getGoogleBrew : async (auth = defaultAuth, id, accessId, accessType)=>{
const drive = googleDrive.drive({ version: 'v3', auth: auth });
const obj = await drive.files.get({
fileId : id,

View File

@@ -87,76 +87,68 @@ const api = {
// Create middleware with the accessType passed in as part of the scope
return async (req, res, next)=>{
// Get relevant IDs for the brew
const { id, googleId } = api.getId(req);
let { id, googleId } = api.getId(req);
const accessMap = {
edit : { editId: id },
share : { shareId: id },
admin : {
$or : [
{ editId: id },
{ shareId: id },
] }
admin : { $or : [{ editId: id }, { shareId: id }] }
};
// Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine.
let stub = await HomebrewModel.get(accessMap[accessType])
.catch((err)=>{
if(googleId) {
if(googleId)
console.warn(`Unable to find document stub for ${accessType}Id ${id}`);
} else {
else
console.warn(err);
}
});
stub = stub?.toObject();
googleId ??= stub?.googleId;
if(stub?.lock?.locked && accessType != 'edit') {
throw { HBErrorCode: '51', code: stub.lock.code, message: stub.lock.shareMessage, brewId: stub.shareId, brewTitle: stub.title };
const isOwner = (accessType == 'edit' && (!stub || stub?.authors?.length === 0)) || stub?.authors?.[0] === req.account?.username;
const isAuthor = stub?.authors?.includes(req.account?.username);
const isInvited = stub?.invitedAuthors?.includes(req.account?.username);
if(accessType === 'edit' && !(isOwner || isAuthor || isInvited)) {
const accessError = { name: 'Access Error', status: 401, authors: stub?.authors, brewTitle: stub?.title, shareId: stub?.shareId };
if(req.account)
throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03' };
else
throw { ...accessError, message: 'User is not logged in', HBErrorCode: '04' };
}
// If there is a google id, try to find the google brew
if(!stubOnly && (googleId || stub?.googleId)) {
let googleError;
const googleBrew = await GoogleActions.getGoogleBrew(googleId || stub?.googleId, id, accessType)
.catch((err)=>{
googleError = err;
if(stub?.lock?.locked && accessType != 'edit') {
throw { HBErrorCode: '51', code: stub?.lock.code, message: stub?.lock.shareMessage, brewId: stub?.shareId, brewTitle: stub?.title };
}
// If there's a google id, get it if requesting the full brew or if no stub found yet
if(googleId && (!stubOnly || !stub)) {
const oAuth2Client = isOwner ? GoogleActions.authCheck(req.account, res) : undefined;
const googleBrew = await GoogleActions.getGoogleBrew(oAuth2Client, googleId, id, accessType)
.catch((googleError)=>{
const reason = googleError.errors?.[0].reason;
if(reason == 'notFound')
throw { ...googleError, HBErrorCode: '02', authors: stub?.authors, account: req.account?.username };
else
throw { ...googleError, HBErrorCode: '01' };
});
// Throw any error caught while attempting to retrieve Google brew.
if(googleError) {
const reason = googleError.errors?.[0].reason;
if(reason == 'notFound') {
throw { ...googleError, HBErrorCode: '02', authors: stub?.authors, account: req.account?.username };
} else {
throw { ...googleError, HBErrorCode: '01' };
}
}
// Combine the Homebrewery stub with the google brew, or if the stub doesn't exist just use the google brew
stub = stub ? _.assign({ ...api.excludeStubProps(stub), stubbed: true }, api.excludeGoogleProps(googleBrew)) : googleBrew;
}
const authorsExist = stub?.authors?.length > 0;
const isAuthor = stub?.authors?.includes(req.account?.username);
const isInvited = stub?.invitedAuthors?.includes(req.account?.username);
if(accessType === 'edit' && (authorsExist && !(isAuthor || isInvited))) {
const accessError = { name: 'Access Error', status: 401 };
if(req.account){
throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03', authors: stub.authors, brewTitle: stub.title, shareId: stub.shareId };
}
throw { ...accessError, message: 'User is not logged in', HBErrorCode: '04', authors: stub.authors, brewTitle: stub.title, shareId: stub.shareId };
}
// If after all of that we still don't have a brew, throw an exception
if(!stub && !stubOnly) {
if(!stub)
throw { name: 'BrewLoad Error', message: 'Brew not found', status: 404, HBErrorCode: '05', accessType: accessType, brewId: id };
}
// Clean up brew: fill in missing fields with defaults / fix old invalid values
if(stub) {
stub.tags = stub.tags || undefined; // Clear empty strings
stub.renderer = stub.renderer || undefined; // Clear empty strings
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
}
stub.tags = stub.tags || undefined; // Clear empty strings
stub.renderer = stub.renderer || undefined; // Clear empty strings
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
req.brew = stub ?? {};
req.brew = stub;
next();
};
},
@@ -475,12 +467,11 @@ const api = {
}
};
router.use('/api', checkClientVersion);
router.post('/api', asyncHandler(api.newBrew));
router.put('/api/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.put('/api/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.delete('/api/:id', asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
router.post('/api', checkClientVersion, asyncHandler(api.newBrew));
router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
export default api;

View File

@@ -1,5 +1,7 @@
/* eslint-disable max-lines */
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
describe('Tests for api', ()=>{
let api;
let google;
@@ -296,7 +298,7 @@ describe('Tests for api', ()=>{
expect(next).toHaveBeenCalled();
expect(api.getId).toHaveBeenCalledWith(req);
expect(model.get).toHaveBeenCalledWith({ shareId: '1' });
expect(google.getGoogleBrew).toHaveBeenCalledWith('2', '1', 'share');
expect(google.getGoogleBrew).toHaveBeenCalledWith(undefined, '2', '1', 'share');
});
it('access is denied to a locked brew', async()=>{
@@ -968,4 +970,57 @@ brew`);
expect(res.send).toHaveBeenCalledWith('');
});
});
describe('Split Text, Style, and Metadata', ()=>{
it('basic splitting', async ()=>{
const testBrew = {
text : '```metadata\n' +
'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' +
'\n' +
'```\n' +
'\n' +
'```css\n' +
'style\n' +
'style\n' +
'style\n' +
'```\n' +
'\n' +
'text\n'
};
splitTextStyleAndMetadata(testBrew);
// 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');
// Style
expect(testBrew.style).toEqual('style\nstyle\nstyle');
// 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']);
});
});
});

View File

@@ -1,10 +1,10 @@
import packageJSON from '../../package.json' with { type: "json" };
const version = packageJSON.version;
import packageJSON from '../../package.json' with { type: 'json' };
export default (req, res, next)=>{
const userVersion = req.get('Homebrewery-Version');
const version = packageJSON.version;
if(userVersion != version) {
if(userVersion !== version) {
return res.status(412).send({
message : `Client version ${userVersion} is out of date. Please save your changes elsewhere and refresh to pick up client version ${version}.`
});
@@ -12,3 +12,4 @@ export default (req, res, next)=>{
next();
};

View File

@@ -6,7 +6,7 @@ export default (req, res, next)=>{
const isImageRequest = req.get('Accept')?.split(',')
?.filter((h)=>!h.includes('q='))
?.every((h)=>/image\/.*/.test(h));
if(isImageRequest && !isLocalEnvironment && !req.url?.startsWith('/staticImages')) {
if(isImageRequest && !(isLocalEnvironment && req.url?.startsWith('/staticImages'))) {
return res.status(406).send({
message : 'Request for image at this URL is not supported'
});

View File

@@ -0,0 +1,41 @@
import contentNegotiationMiddleware from './content-negotiation.js';
describe('content-negotiation-middleware', ()=>{
let request;
let response;
let next;
beforeEach(()=>{
request = {
get : function(key) {
return this[key];
}
};
response = {
status : jest.fn(()=>response),
send : jest.fn(()=>{})
};
next = jest.fn();
});
it('should return 406 on image request', ()=>{
contentNegotiationMiddleware({
Accept : 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
...request
}, response);
expect(response.status).toHaveBeenLastCalledWith(406);
expect(response.send).toHaveBeenCalledWith({
message : 'Request for image at this URL is not supported'
});
});
it('should call next on non-image request', ()=>{
contentNegotiationMiddleware({
Accept : 'text,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
...request
}, response, next);
expect(next).toHaveBeenCalled();
});
});

View File

@@ -21,6 +21,9 @@ const splitTextStyleAndMetadata = (brew)=>{
brew.snippets = brew.text.slice(11, index - 1);
brew.text = brew.text.slice(index + 5);
}
// Handle old brews that still have empty strings in the tags metadata
if(typeof brew.tags === 'string') brew.tags = brew.tags ? [brew.tags] : [];
};
const printCurrentBrew = ()=>{

View File

@@ -11,49 +11,54 @@
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
@keyframes sourceMoveAnimation {
50% {background-color: red; color: white;}
100% {background-color: unset; color: unset;}
50% { color : white;background-color : red;}
100% { color : unset;background-color : unset;}
}
.codeEditor{
@media screen and (pointer : coarse) {
font-size : 16px;
}
.CodeMirror-foldmarker {
font-family: inherit;
text-shadow: none;
font-weight: 600;
color: grey;
}
.codeEditor {
@media screen and (pointer : coarse) {
font-size : 16px;
}
.CodeMirror-foldmarker {
font-family : inherit;
font-weight : 600;
color : grey;
text-shadow : none;
}
.sourceMoveFlash .CodeMirror-line{
animation-name: sourceMoveAnimation;
animation-duration: 0.4s;
}
.CodeMirror-foldgutter {
cursor : pointer;
border-left : 1px solid #EEEEEE;
transition : background 0.1s;
&:hover { background : #DDDDDD; }
}
.CodeMirror-vscrollbar {
&::-webkit-scrollbar {
width: 20px;
}
&::-webkit-scrollbar-thumb {
width: 20px;
background: linear-gradient(90deg, #858585 15px, #808080 15px);
}
}
.sourceMoveFlash .CodeMirror-line {
animation-name : sourceMoveAnimation;
animation-duration : 0.4s;
}
.CodeMirror-vscrollbar {
&::-webkit-scrollbar { width : 20px; }
&::-webkit-scrollbar-thumb {
width : 20px;
background : linear-gradient(90deg, #858585 15px, #808080 15px);
}
}
//.cm-tab {
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
//}
//.cm-tab {
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
//}
//.cm-trailingspace {
// .cm-space {
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
// }
//}
//.cm-trailingspace {
// .cm-space {
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
// }
//}
}
.emojiPreview {
font-size: 1.5em;
line-height: 1.2em;
font-size : 1.5em;
line-height : 1.2em;
}

View File

@@ -391,10 +391,31 @@ const forcedParagraphBreaks = {
}
};
const nonbreakingSpaces = {
name : 'nonbreakingSpaces',
level : 'inline',
start(src) { return src.match(/:>+/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /:(>+)/ym;
const match = regex.exec(src);
if(match?.length) {
return {
type : 'nonbreakingSpaces', // Should match "name" above
raw : match[0], // Text to consume from the source
length : match[1].length,
text : ''
};
}
},
renderer(token) {
return `&nbsp;`.repeat(token.length).concat('');
}
};
const definitionListsSingleLine = {
name : 'definitionListsSingleLine',
level : 'block',
start(src) { return src.match(/\n[^\n]*?::[^\n]*/m)?.index; }, // Hint to Marked.js to stop and check for a match
start(src) { return src.match(/\n[^\n]*?::[^\n]*/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym;
let match;
@@ -748,11 +769,12 @@ const tableTerminators = [
];
Marked.use(MarkedVariables());
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
mustacheSpans, mustacheDivs, mustacheInjectInline] });
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks,
nonbreakingSpaces, superSubScripts, mustacheSpans, mustacheDivs, mustacheInjectInline] });
Marked.use(mustacheInjectBlock);
Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }),
MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
function cleanUrl(href) {
try {

View File

@@ -43,5 +43,6 @@ html,body, #reactRoot{
}
&:disabled{
background-color : @silver !important;
cursor:not-allowed;
}
}

View File

@@ -0,0 +1,72 @@
/* eslint-disable max-lines */
import Markdown from 'naturalcrit/markdown.js';
describe('Non-Breaking Spaces', ()=>{
test('Single Space', function() {
const source = ':>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;</p>`);
});
test('Double Space', function() {
const source = ':>>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;</p>`);
});
test('Triple Space', function() {
const source = ':>>>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;</p>`);
});
test('Many Space', function() {
const source = ':>>>>>>>>>>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</p>`);
});
test('Multiple sets of Spaces', function() {
const source = ':>>>\n:>>>\n:>>>';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;\n&nbsp;&nbsp;&nbsp;\n&nbsp;&nbsp;&nbsp;</p>`);
});
test('Pair of inline Spaces', function() {
const source = ':>>:>>';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;&nbsp;</p>`);
});
test('Space directly between two paragraphs', function() {
const source = 'Line 1\n:>>\nLine 2';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Line 1\n&nbsp;&nbsp;\nLine 2</p>`);
});
test('Ignored inside a code block', function() {
const source = '```\n\n:>\n\n```\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<pre><code>\n:&gt;\n</code></pre>`);
});
test('I am actually a single-line definition list!', function() {
const source = 'Term ::> Definition 1\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt><dd>> Definition 1</dd>\n</dl>`);
});
test('I am actually a definition list!', function() {
const source = 'Term\n::> Definition 1\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>> Definition 1</dd></dl>`);
});
test('I am actually a two-term definition list!', function() {
const source = 'Term\n::> Definition 1\n::>> Definition 2';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>> Definition 1</dd>\n<dd>>> Definition 2</dd></dl>`);
});
});

View File

@@ -42,23 +42,25 @@ body {
}
.phb, .page{
.useColumns();
counter-increment : phb-page-numbers;
position : relative;
z-index : 15;
box-sizing : border-box;
overflow : hidden;
height : 279.4mm;
width : 215.9mm;
padding : 1.0cm 1.7cm;
padding-bottom : 1.5cm;
background-color : @background;
background-image : @backgroundImage;
font-family : BookSanity;
font-size : 0.317cm;
text-rendering : optimizeLegibility;
page-break-before : always;
page-break-after : always;
contain : size;
counter-increment : phb-page-numbers;
position : relative;
z-index : 15;
box-sizing : border-box;
overflow : hidden;
height : 279.4mm;
width : 215.9mm;
padding : 1.0cm 1.7cm;
padding-bottom : 1.5cm;
background-color : @background;
background-image : @backgroundImage;
font-family : BookSanity;
font-size : 0.317cm;
text-rendering : optimizeLegibility;
page-break-before : always;
page-break-after : always;
contain : strict;
content-visibility : auto;
contain-intrinsic-size : auto none;
}
.phb{

View File

@@ -45,19 +45,21 @@ body { counter-reset : page-numbers 0; }
}
.page {
.useColumns();
position : relative;
z-index : 15;
box-sizing : border-box;
width : 215.9mm;
height : 279.4mm;
padding : 1.4cm 1.9cm 1.7cm;
overflow : hidden;
background-color : var(--HB_Color_Background);
text-rendering : optimizeLegibility;
contain : size;
position : relative;
z-index : 15;
box-sizing : border-box;
width : 215.9mm;
height : 279.4mm;
padding : 1.4cm 1.9cm 1.7cm;
overflow : hidden;
background-color : var(--HB_Color_Background);
text-rendering : optimizeLegibility;
contain : strict;
content-visibility : auto;
contain-intrinsic-size : auto none;
}
//*****************************
// * BASE
//*****************************
// * BASE
// *****************************/
.page {
p {