0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-07 07:42:39 +00:00

Merge branch 'master' into erase-unnecessary-divs

This commit is contained in:
Víctor Losada Hernández
2024-10-26 18:18:49 +02:00
committed by GitHub
22 changed files with 1085 additions and 1048 deletions

View File

@@ -81,9 +81,85 @@ pre {
} }
``` ```
## changelog ## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Saturday 10/12/2024 - v3.16.0
{{taskList
##### 5e-Cleric
* [x] Added a new API endpoint `/metadata/:shareId` to fetch metadata about individual brews
Fixes issue [#2638](https://github.com/naturalcrit/homebrewery/issues/2638)
* [x] Added A3, A5, and Card page size snippets under {{openSans **:fas_paintbrush: STYLE TAB :fas_arrow_right: :fas_print: PRINT**}}
* [x] Adjust navbar styling for very long titles
Fixes issue [#2071](https://github.com/naturalcrit/homebrewery/issues/2071)
* [x] Added some sorting options to the {{openSans **VAULT** {{fas,fa-dungeon}}}} page
* [x] Fix `language` property not working in share page
Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776)
##### abquintic
* [x] New {{openSans **:fas_pencil: TEXT EDITOR :fas_arrow_right: :fas_bookmark: PAGE NUMBER :fas_arrow_right:**}}
{{openSans **:fas_xmark: SKIP PAGE NUMBER**}} and {{openSans **:fas_arrow_rotate_left: RESTART PAGE NUMBER**}} snippets for more control over automatic page numbering.
Fixes issue [#513](https://github.com/naturalcrit/homebrewery/issues/513)
* [x] New Table of Contents control options via {{openSans **:fas_pencil: TEXT EDITOR :fas_arrow_right: :fas_book: TABLE OF CONTENTS**}} submenus. By default, H1-H3 is included in the ToC generation, but the new options allow marking `{{blocks}}` to include or exclude specific or ranges of contained headers. Also, a global option to increase the default range of H1-H3 to H1-H4/5/6. After applying these markers, you must regenerate the Table of Contents to see the changes.
* [x] Added a ":fas_lock: SYNC VIEWS" button onto the divider bar. When locked, scrolling on either panel will sync the other panel to the same page.
Fixes issue [#241](https://github.com/naturalcrit/homebrewery/issues/241)
##### Gazook89
* [x] Added a :fas_glasses: HIDE button to the page navigation bar
##### G-Ambatte
* [x] Automatic local backups of your files, in case of accidental data loss. Stores up to 5 snapshots of each brew edited in your browser, incrementing from a few minutes old to a maximum of several days. Restore a backup by clicking an entry in the new {{openSans **:fas_clock_rotate_left: HISTORY**}} button in the snippet bar.
Fixes issue [#3070](https://github.com/naturalcrit/homebrewery/issues/3070)
* [x] Fix issue with legacy brews breaking on Share page
Fixes issue [#3764](https://github.com/naturalcrit/homebrewery/issues/3764)
* [x] Fix print size when printing a zoomed document
Fixes issue [#3744](https://github.com/naturalcrit/homebrewery/issues/3744)
##### All
* [x] Background code cleanup, security fixes, dev tool improvements, dependency updates, prep for upcoming features, etc.
}}
### Wednesday 9/25/2024 - v3.15.1
{{taskList
##### calculuschild
* [x] Background fixes to handle Google Drive issues
* [x] Remove duplicate error logging
##### calculuschild, 5e-Cleric
* [x] Fix links in {{openSans **RECENT BREWS :fas_clock_rotate_left:**}} and user {{openSans **BREWS :fas_beer_mug_empty:**}} pointing to trashed Google Drive files after transferring from Google to Homebrewery storage
Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776)
}}
\page
### Wednesday 9/04/2024 - v3.15.0 ### Wednesday 9/04/2024 - v3.15.0
{{taskList {{taskList

View File

@@ -66,7 +66,7 @@ const NotificationAdd = ()=>{
<label className='field'> <label className='field'>
Dismiss Key: Dismiss Key:
<input className='fieldInput' type='text' ref={dismissKeyRef} required <input className='fieldInput' type='text' ref={dismissKeyRef} required
placeholder='GOOGLEDRIVENOTIF' placeholder='dismiss_notif_drive'
/> />
</label> </label>

View File

@@ -14,9 +14,6 @@ const NotificationDetail = ({ notification, onDelete })=>(
<dt>Title</dt> <dt>Title</dt>
<dd>{notification.title || 'No Title'}</dd> <dd>{notification.title || 'No Title'}</dd>
<dt>Text</dt>
<dd>{notification.text || 'No Text'}</dd>
<dt>Created</dt> <dt>Created</dt>
<dd>{Moment(notification.createdAt).format('LLLL')}</dd> <dd>{Moment(notification.createdAt).format('LLLL')}</dd>
@@ -25,6 +22,9 @@ const NotificationDetail = ({ notification, onDelete })=>(
<dt>Stop</dt> <dt>Stop</dt>
<dd>{Moment(notification.stopAt).format('LLLL') || 'No End Time'}</dd> <dd>{Moment(notification.stopAt).format('LLLL') || 'No End Time'}</dd>
<dt>Text</dt>
<dd>{notification.text || 'No Text'}</dd>
</dl> </dl>
<button onClick={()=>onDelete(notification.dismissKey)}>DELETE</button> <button onClick={()=>onDelete(notification.dismissKey)}>DELETE</button>
</> </>

View File

@@ -1,22 +1,26 @@
// Dialog box, for popups and modal blocking messages // Dialog box, for popups and modal blocking messages
const React = require('react'); import React from "react";
const { useRef, useEffect } = React; const { useRef, useEffect } = React;
function Dialog({ dismissKey, closeText = 'Close', blocking = false, ...rest }) { function Dialog({ dismisskeys, closeText = 'Close', blocking = false, ...rest }) {
const dialogRef = useRef(null); const dialogRef = useRef(null);
useEffect(()=>{ useEffect(()=>{
if(!dismissKey || !localStorage.getItem(dismissKey)) { if (dismisskeys.length !== 0) {
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show(); blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
} }
}, []); }, [dialogRef.current, dismisskeys]);
const dismiss = ()=>{ const dismiss = () => {
dismissKey && localStorage.setItem(dismissKey, true); dismisskeys.forEach(key => {
if (key) {
localStorage.setItem(key, 'true');
}
});
dialogRef.current?.close(); dialogRef.current?.close();
}; };
return ( return (
<dialog ref={dialogRef} onCancel={dismiss} {...rest}> <dialog ref={dialogRef} onCancel={dismiss} {...rest}>
{rest.children} {rest.children}
<button className='dismiss' onClick={dismiss}> <button className='dismiss' onClick={dismiss}>

View File

@@ -1,7 +1,7 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/ /*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./brewRenderer.less'); require('./brewRenderer.less');
const React = require('react'); const React = require('react');
const { useState, useRef, useEffect, useCallback } = React; const { useState, useRef, useCallback, useMemo } = React;
const _ = require('lodash'); const _ = require('lodash');
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js'); const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
@@ -44,7 +44,7 @@ const BrewPage = (props)=>{
//v=====--------------------< Brew Renderer Component >-------------------=====v// //v=====--------------------< Brew Renderer Component >-------------------=====v//
const renderedPages = []; let renderedPages = [];
let rawPages = []; let rawPages = [];
const BrewRenderer = (props)=>{ const BrewRenderer = (props)=>{
@@ -64,7 +64,6 @@ const BrewRenderer = (props)=>{
}; };
const [state, setState] = useState({ const [state, setState] = useState({
height : PAGE_HEIGHT,
isMounted : false, isMounted : false,
visibility : 'hidden', visibility : 'hidden',
zoom : 100 zoom : 100
@@ -78,15 +77,24 @@ const BrewRenderer = (props)=>{
rawPages = props.text.split(/^\\page$/gm); rawPages = props.text.split(/^\\page$/gm);
} }
useEffect(()=>{ // Unmounting steps const scrollToHash = (hash) => {
return ()=>{window.removeEventListener('resize', updateSize);}; if (!hash) return;
}, []);
const updateSize = ()=>{ const iframeDoc = document.getElementById('BrewRenderer').contentDocument;
setState((prevState)=>({ let anchor = iframeDoc.querySelector(hash);
...prevState,
height : mainRef.current.parentNode.clientHeight, 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 updateCurrentPage = useCallback(_.throttle((e)=>{ const updateCurrentPage = useCallback(_.throttle((e)=>{
@@ -162,9 +170,9 @@ const BrewRenderer = (props)=>{
}; };
const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount" const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount"
scrollToHash(window.location.hash);
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
updateSize();
window.addEventListener('resize', updateSize);
renderPages(); //Make sure page is renderable before showing renderPages(); //Make sure page is renderable before showing
setState((prevState)=>({ setState((prevState)=>({
...prevState, ...prevState,
@@ -187,6 +195,15 @@ const BrewRenderer = (props)=>{
})); }));
}; };
const styleObject = {};
if(global.config.deployment) {
styleObject.backgroundImage = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='40px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${global.config.deployment}</text></svg>")`;
}
const renderedStyle = useMemo(()=> renderStyle(), [props.style, props.themeBundle]);
renderedPages = useMemo(() => renderPages(), [props.text]);
return ( return (
<> <>
{/*render dummy page while iFrame is mounting.*/} {/*render dummy page while iFrame is mounting.*/}
@@ -212,19 +229,19 @@ const BrewRenderer = (props)=>{
contentDidMount={frameDidMount} contentDidMount={frameDidMount}
onClick={()=>{emitClick();}} onClick={()=>{emitClick();}}
> >
<div className={'brewRenderer'} <div className={`brewRenderer ${global.config.deployment && 'deployment'}`}
onScroll={updateCurrentPage} onScroll={updateCurrentPage}
onKeyDown={handleControlKeys} onKeyDown={handleControlKeys}
tabIndex={-1} tabIndex={-1}
style={{ height: state.height }}> style={ styleObject }>
{/* Apply CSS from Style tab and render pages from Markdown tab */} {/* Apply CSS from Style tab and render pages from Markdown tab */}
{state.isMounted {state.isMounted
&& &&
<> <>
{renderStyle()} {renderedStyle}
<div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}> <div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}>
{renderPages()} {renderedPages}
</div> </div>
</> </>
} }

View File

@@ -4,6 +4,10 @@
overflow-y : scroll; overflow-y : scroll;
will-change : transform; will-change : transform;
padding-top : 30px; padding-top : 30px;
height : 100vh;
&.deployment {
background-color: darkred;
}
:where(.pages) { :where(.pages) {
margin : 30px 0px; margin : 30px 0px;
& > :where(.page) { & > :where(.page) {
@@ -14,6 +18,9 @@
margin-left : auto; margin-left : auto;
box-shadow : 1px 4px 14px #000000; box-shadow : 1px 4px 14px #000000;
} }
*[id] {
scroll-margin-top:100px;
}
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {
width : 20px; width : 20px;

View File

@@ -1,44 +1,62 @@
require('./notificationPopup.less'); require('./notificationPopup.less');
const React = require('react'); import React, { useEffect, useState } from 'react';
const _ = require('lodash'); const request = require('../../utils/request-middleware.js');
import Dialog from '../../../components/dialog.jsx'; import Dialog from '../../../components/dialog.jsx';
const DISMISS_KEY = 'dismiss_notification04-09-24';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />; const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
const NotificationPopup = ()=>{ const NotificationPopup = ()=>{
return <Dialog className='notificationPopup' dismissKey={DISMISS_KEY} closeText={DISMISS_BUTTON} > const [notifications, setNotifications] = useState([]);
const [dissmissKeyList, setDismissKeyList] = useState([]);
const [error, setError] = useState(null);
useEffect(()=>{
getNotifications();
}, []);
const getNotifications = async ()=>{
setError(null);
try {
const res = await request.get('/admin/notification/all');
pickActiveNotifications(res.body || []);
} catch (err) {
console.log(err);
setError(`Error looking up notifications: ${err?.response?.body?.message || err.message}`);
}
};
const pickActiveNotifications = (notifs)=>{
const now = new Date();
const filteredNotifications = notifs.filter((notification)=>{
const startDate = new Date(notification.startAt);
const stopDate = new Date(notification.stopAt);
const dismissed = localStorage.getItem(notification.dismissKey) ? true : false;
return now >= startDate && now <= stopDate && !dismissed;
});
setNotifications(filteredNotifications);
setDismissKeyList(filteredNotifications.map((notif)=>notif.dismissKey));
};
const renderNotificationsList = ()=>{
if(error) return <div className='error'>{error}</div>;
return notifications.map((notification)=>(
<li key={notification.dismissKey} >
<em>{notification.title}</em><br />
<p dangerouslySetInnerHTML={{ __html: notification.text }}></p>
</li>
));
};
return <Dialog className='notificationPopup' dismisskeys={dissmissKeyList} closeText={DISMISS_BUTTON} >
<div className='header'> <div className='header'>
<i className='fas fa-info-circle info'></i> <i className='fas fa-info-circle info'></i>
<h3>Notice</h3> <h3>Notice</h3>
<small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small> <small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small>
</div> </div>
<ul> <ul>
<li key='Vault'> {renderNotificationsList()}
<em>Search brews with our new page!</em><br />
We have been working very hard in making this possible, now you can share your work and look at it in the new <a href='/vault'>Vault</a> page!
All PUBLISHED brews will be available to anyone searching there, by title or author, and filtering by renderer.
More features will be coming.
</li>
<li key='googleDriveFolder'>
<em>Don't delete your Homebrewery folder on Google Drive!</em> <br />
We have had several reports of users losing their brews, not realizing
that they had deleted the files on their Google Drive. If you have a Homebrewery folder
on your Google Drive with *.txt files inside, <em>do not delete it</em>!
We cannot help you recover files that you have deleted from your own
Google Drive.
</li>
<li key='faq'>
<em>Protect your work! </em> <br />
If you opt not to use your Google Drive, keep in mind that we do not save a history of your projects. Please make frequent backups of your brews!&nbsp;
<a target='_blank' href='https://www.reddit.com/r/homebrewery/comments/adh6lh/faqs_psas_announcements/'>
See the FAQ
</a> to learn how to avoid losing your work!
</li>
</ul> </ul>
</Dialog>; </Dialog>;
}; };

View File

@@ -55,7 +55,10 @@
margin-top : 1.4em; margin-top : 1.4em;
font-size : 0.8em; font-size : 0.8em;
line-height : 1.4em; line-height : 1.4em;
em { font-weight : 800; } em {
text-transform:capitalize;
font-weight : 800;
}
} }
} }
} }

View File

@@ -314,7 +314,7 @@ const Editor = createClass({
}, },
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){ brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
if(!window || isJumping) if(!window || !this.isText() || isJumping)
return; return;
// Get current brewRenderer scroll position and calculate target position // Get current brewRenderer scroll position and calculate target position
@@ -355,7 +355,7 @@ const Editor = createClass({
}, },
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){ sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
if(!this.isText || isJumping) if(!this.isText() || isJumping)
return; return;
const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/; const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/;

View File

@@ -2,6 +2,7 @@
.editor { .editor {
position : relative; position : relative;
width : 100%; width : 100%;
container: editor / inline-size;
.codeEditor { .codeEditor {
height : 100%; height : 100%;

View File

@@ -150,18 +150,22 @@ const Snippetbar = createClass({
renderSnippetGroups : function(){ renderSnippetGroups : function(){
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view); const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
if(snippets.length === 0) return null;
return _.map(snippets, (snippetGroup)=>{ return <div className='snippets'>
return <SnippetGroup {_.map(snippets, (snippetGroup)=>{
brew={this.props.brew} return <SnippetGroup
groupName={snippetGroup.groupName} brew={this.props.brew}
icon={snippetGroup.icon} groupName={snippetGroup.groupName}
snippets={snippetGroup.snippets} icon={snippetGroup.icon}
key={snippetGroup.groupName} snippets={snippetGroup.snippets}
onSnippetClick={this.handleSnippetClick} key={snippetGroup.groupName}
cursorPos={this.props.cursorPos} onSnippetClick={this.handleSnippetClick}
/>; cursorPos={this.props.cursorPos}
}); />;
})
}
</div>;
}, },
replaceContent : function(item){ replaceContent : function(item){
@@ -203,57 +207,58 @@ const Snippetbar = createClass({
renderEditorButtons : function(){ renderEditorButtons : function(){
if(!this.props.showEditButtons) return; if(!this.props.showEditButtons) return;
let foldButtons; const foldButtons = <>
if(this.props.view == 'text'){ <div className={`editorTool foldAll ${this.props.view !== 'meta' && this.props.foldCode ? 'active' : ''}`}
foldButtons = onClick={this.props.foldCode} >
<> <i className='fas fa-compress-alt' />
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`} </div>
onClick={this.props.foldCode} > <div className={`editorTool unfoldAll ${this.props.view !== 'meta' && this.props.unfoldCode ? 'active' : ''}`}
<i className='fas fa-compress-alt' /> onClick={this.props.unfoldCode} >
</div> <i className='fas fa-expand-alt' />
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`} </div>
onClick={this.props.unfoldCode} > </>;
<i className='fas fa-expand-alt' />
</div>
</>;
}
return <div className='editors'> return <div className='editors'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`} <div className='historyTools'>
onClick={this.toggleHistoryMenu} > <div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
<i className='fas fa-clock-rotate-left' /> onClick={this.toggleHistoryMenu} >
{ this.state.showHistory && this.renderHistoryItems() } <i className='fas fa-clock-rotate-left' />
{ this.state.showHistory && this.renderHistoryItems() }
</div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
onClick={this.props.undo} >
<i className='fas fa-undo' />
</div>
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
onClick={this.props.redo} >
<i className='fas fa-redo' />
</div>
</div> </div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`} <div className='codeTools'>
onClick={this.props.undo} > {foldButtons}
<i className='fas fa-undo' /> <div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`}
</div> onClick={this.toggleThemeSelector} >
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`} <i className='fas fa-palette' />
onClick={this.props.redo} > {this.state.themeSelector && this.renderThemeSelector()}
<i className='fas fa-redo' /> </div>
</div>
<div className='divider'></div>
{foldButtons}
<div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()}
</div> </div>
<div className='divider'></div>
<div className={cx('text', { selected: this.props.view === 'text' })} <div className='tabs'>
onClick={()=>this.props.onViewChange('text')}> <div className={cx('text', { selected: this.props.view === 'text' })}
<i className='fa fa-beer' /> onClick={()=>this.props.onViewChange('text')}>
</div> <i className='fa fa-beer' />
<div className={cx('style', { selected: this.props.view === 'style' })} </div>
onClick={()=>this.props.onViewChange('style')}> <div className={cx('style', { selected: this.props.view === 'style' })}
<i className='fa fa-paint-brush' /> onClick={()=>this.props.onViewChange('style')}>
</div> <i className='fa fa-paint-brush' />
<div className={cx('meta', { selected: this.props.view === 'meta' })} </div>
onClick={()=>this.props.onViewChange('meta')}> <div className={cx('meta', { selected: this.props.view === 'meta' })}
<i className='fas fa-info-circle' /> onClick={()=>this.props.onViewChange('meta')}>
<i className='fas fa-info-circle' />
</div>
</div> </div>
</div>; </div>;
}, },
@@ -291,8 +296,9 @@ const SnippetGroup = createClass({
return _.map(snippets, (snippet)=>{ return _.map(snippets, (snippet)=>{
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}> return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}>
<i className={snippet.icon} /> <i className={snippet.icon} />
<span className='name'title={snippet.name}>{snippet.name}</span> <span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
{snippet.experimental && <span className='beta'>beta</span>} {snippet.experimental && <span className='beta'>beta</span>}
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
{snippet.subsnippets && <> {snippet.subsnippets && <>
<i className='fas fa-caret-right'></i> <i className='fas fa-caret-right'></i>
<div className='dropdown side'> <div className='dropdown side'>

View File

@@ -4,97 +4,114 @@
.snippetBar { .snippetBar {
@menuHeight : 25px; @menuHeight : 25px;
position : relative; position : relative;
height : @menuHeight; display : flex;
flex-wrap : wrap-reverse;
justify-content : space-between;
height : auto;
color : black; color : black;
background-color : #DDDDDD; background-color : #DDDDDD;
.editors { .snippets {
position : absolute;
top : 0px;
right : 0px;
display : flex; display : flex;
justify-content : space-between; justify-content : flex-start;
height : @menuHeight; min-width : 327.58px;
& > div { }
width : @menuHeight;
height : @menuHeight; .editors {
line-height : @menuHeight; display : flex;
text-align : center; justify-content : flex-end;
cursor : pointer; min-width : 225px;
&:hover,&.selected { background-color : #999999; }
&.text { &:only-child { margin-left : auto; }
.tooltipLeft('Brew Editor');
} >div {
&.style { display : flex;
.tooltipLeft('Style Editor'); flex : 1;
} justify-content : space-around;
&.meta {
.tooltipLeft('Properties'); &:first-child { border-left : none; }
}
&.undo { & > div {
.tooltipLeft('Undo'); position : relative;
font-size : 0.75em; width : @menuHeight;
color : grey; height : @menuHeight;
&.active { color : inherit; } line-height : @menuHeight;
} text-align : center;
&.redo { cursor : pointer;
.tooltipLeft('Redo'); &:hover,&.selected { background-color : #999999; }
font-size : 0.75em; &.text {
color : grey; .tooltipLeft('Brew Editor');
&.active { color : inherit; }
}
&.foldAll {
.tooltipLeft('Fold All');
font-size : 0.75em;
color : inherit;
}
&.unfoldAll {
.tooltipLeft('Unfold All');
font-size : 0.75em;
color : inherit;
}
&.history {
.tooltipLeft('History');
font-size : 0.75em;
color : grey;
position : relative;
&.active {
color : inherit;
} }
&>.dropdown{ &.style {
right : -1px; .tooltipLeft('Style Editor');
&>.snippet{ }
padding-right : 10px; &.meta {
.tooltipLeft('Properties');
}
&.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; }
& > .dropdown {
right : -1px;
& > .snippet { padding-right : 10px; }
} }
} }
} &.editorTheme {
&.editorTheme { .tooltipLeft('Editor Themes');
.tooltipLeft('Editor Themes'); font-size : 0.75em;
font-size : 0.75em; color : black;
color : black; &.active {
&.active { position : relative;
position : relative; background-color : #999999;
background-color : #999999; }
}
&.divider {
width : 5px;
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
&:hover { background-color : inherit; }
} }
} }
&.divider { .themeSelector {
width : 5px; position : absolute;
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%; top : 25px;
&:hover { background-color : inherit; } right : 0;
z-index : 10;
display : flex;
align-items : center;
justify-content : center;
width : 170px;
height : inherit;
background-color : inherit;
} }
} }
.themeSelector {
position : absolute;
top : 25px;
right : 0;
z-index : 10;
display : flex;
align-items : center;
justify-content : center;
width : 170px;
height : inherit;
background-color : inherit;
}
} }
.snippetBarButton { .snippetBarButton {
display : inline-block; display : inline-block;
@@ -104,6 +121,7 @@
font-weight : 800; font-weight : 800;
line-height : @menuHeight; line-height : @menuHeight;
text-transform : uppercase; text-transform : uppercase;
text-wrap : nowrap;
cursor : pointer; cursor : pointer;
&:hover, &.selected { background-color : #999999; } &:hover, &.selected { background-color : #999999; }
i { i {
@@ -120,7 +138,7 @@
.tooltipLeft('Edit Brew Properties'); .tooltipLeft('Edit Brew Properties');
} }
.snippetGroup { .snippetGroup {
border-right : 1px solid currentColor;
&:hover { &:hover {
& > .dropdown { visibility : visible; } & > .dropdown { visibility : visible; }
} }
@@ -142,11 +160,11 @@
cursor : pointer; cursor : pointer;
.animate(background-color); .animate(background-color);
i { i {
min-width : 25px;
height : 1.2em; height : 1.2em;
margin-right : 8px; margin-right : 8px;
font-size : 1.2em; font-size : 1.2em;
min-width: 25px; text-align : center;
text-align: center;
& ~ i { & ~ i {
margin-right : 0; margin-right : 0;
margin-left : 5px; margin-left : 5px;
@@ -179,6 +197,7 @@
} }
} }
.name { margin-right : auto; } .name { margin-right : auto; }
.disabled { text-decoration : line-through; }
.beta { .beta {
align-self : center; align-self : center;
padding : 4px 6px; padding : 4px 6px;
@@ -204,4 +223,19 @@
} }
} }
} }
} }
@container editor (width < 553px) {
.snippetBar {
.editors {
flex : 1;
justify-content : space-between;
border-bottom : 1px solid;
}
.snippets {
flex : 1;
justify-content : space-evenly;
}
.editors > div.history > .dropdown { right : unset; }
}
}

View File

@@ -228,8 +228,8 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text) htmlErrors : Markdown.validate(prevState.brew.text)
})); }));
await updateHistory(this.state.brew); await updateHistory(this.state.brew).catch(console.error);
await versionHistoryGarbageCollection(); await versionHistoryGarbageCollection().catch(console.error);
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);

View File

@@ -172,6 +172,11 @@ const errorIndex = (props)=>{
**Brew Title:** ${props.brew.brewTitle}`, **Brew Title:** ${props.brew.brewTitle}`,
// ####### Admin page error #######
'52': dedent`
## Access Denied
You need to provide correct administrator credentials to access this page.`,
'90' : dedent` An unexpected error occurred while looking for these brews. '90' : dedent` An unexpected error occurred while looking for these brews.
Try again in a few minutes.`, Try again in a few minutes.`,

1411
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "homebrewery", "name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown", "description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.15.0", "version": "3.16.0",
"engines": { "engines": {
"npm": "^10.2.x", "npm": "^10.2.x",
"node": "^20.17.x" "node": "^20.17.x"
@@ -86,10 +86,10 @@
] ]
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.25.7", "@babel/core": "^7.25.9",
"@babel/plugin-transform-runtime": "^7.25.7", "@babel/plugin-transform-runtime": "^7.25.9",
"@babel/preset-env": "^7.25.7", "@babel/preset-env": "^7.25.9",
"@babel/preset-react": "^7.25.7", "@babel/preset-react": "^7.25.9",
"@googleapis/drive": "^8.14.0", "@googleapis/drive": "^8.14.0",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -115,27 +115,27 @@
"marked-smartypants-lite": "^1.0.2", "marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.7.1", "mongoose": "^8.7.2",
"nanoid": "3.3.4", "nanoid": "3.3.4",
"nconf": "^0.12.1", "nconf": "^0.12.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-frame-component": "^4.1.3", "react-frame-component": "^4.1.3",
"react-router-dom": "6.26.2", "react-router-dom": "6.27.0",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"superagent": "^10.1.0", "superagent": "^10.1.1",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git" "vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^3.1.1", "@stylistic/stylelint-plugin": "^3.1.1",
"eslint": "^9.12.0", "eslint": "^9.13.0",
"eslint-plugin-jest": "^28.8.3", "eslint-plugin-jest": "^28.8.3",
"eslint-plugin-react": "^7.37.1", "eslint-plugin-react": "^7.37.2",
"globals": "^15.11.0", "globals": "^15.11.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^16.9.0", "stylelint": "^16.10.0",
"stylelint-config-recess-order": "^5.1.1", "stylelint-config-recess-order": "^5.1.1",
"stylelint-config-recommended": "^14.0.1", "stylelint-config-recommended": "^14.0.1",
"supertest": "^7.0.0" "supertest": "^7.0.0"

View File

@@ -2,7 +2,6 @@ const HomebrewModel = require('./homebrew.model.js').model;
const NotificationModel = require('./notifications.model.js').model; const NotificationModel = require('./notifications.model.js').model;
const router = require('express').Router(); const router = require('express').Router();
const Moment = require('moment'); const Moment = require('moment');
//const render = require('vitreum/steps/render');
const templateFn = require('../client/template.js'); const templateFn = require('../client/template.js');
const zlib = require('zlib'); const zlib = require('zlib');
@@ -23,7 +22,7 @@ const mw = {
if(process.env.ADMIN_USER === username && process.env.ADMIN_PASS === password){ if(process.env.ADMIN_USER === username && process.env.ADMIN_PASS === password){
return next(); return next();
} }
return res.status(401).send('Access denied'); throw { HBErrorCode: '52', code: 401, message: 'Access denied' };
} }
}; };
@@ -145,6 +144,7 @@ router.get('/admin/notification/all', async (req, res, next)=>{
try { try {
const notifications = await NotificationModel.getAll(); const notifications = await NotificationModel.getAll();
return res.json(notifications); return res.json(notifications);
} catch (error) { } catch (error) {
console.log('Error getting all notifications: ', error.message); console.log('Error getting all notifications: ', error.message);
return res.status(500).json({ message: error.message }); return res.status(500).json({ message: error.message });
@@ -152,7 +152,6 @@ router.get('/admin/notification/all', async (req, res, next)=>{
}); });
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{ router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
console.table(req.body);
try { try {
const notification = await NotificationModel.addNotification(req.body); const notification = await NotificationModel.addNotification(req.body);
return res.status(201).json(notification); return res.status(201).json(notification);

View File

@@ -10,7 +10,6 @@ const app = express();
const config = require('./config.js'); const config = require('./config.js');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js'); const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js');
const GoogleActions = require('./googleActions.js'); const GoogleActions = require('./googleActions.js');
const serveCompressedStaticAssets = require('./static-assets.mv.js'); const serveCompressedStaticAssets = require('./static-assets.mv.js');
@@ -32,6 +31,8 @@ const sanitizeBrew = (brew, accessType)=>{
return brew; return brew;
}; };
app.set('trust proxy', 1 /* number of proxies between user and server */)
app.use('/', serveCompressedStaticAssets(`build`)); app.use('/', serveCompressedStaticAssets(`build`));
app.use(require('./middleware/content-negotiation.js')); app.use(require('./middleware/content-negotiation.js'));
app.use(require('body-parser').json({ limit: '25mb' })); app.use(require('body-parser').json({ limit: '25mb' }));
@@ -257,6 +258,8 @@ app.get('/user/:username', async (req, res, next)=>{
console.log(err); console.log(err);
}); });
brews.forEach(brew => brew.stubbed = true); //All brews from MongoDB are "stubbed"
if(ownAccount && req?.account?.googleId){ if(ownAccount && req?.account?.googleId){
const auth = await GoogleActions.authCheck(req.account, res); const auth = await GoogleActions.authCheck(req.account, res);
let googleBrews = await GoogleActions.listGoogleBrews(auth) let googleBrews = await GoogleActions.listGoogleBrews(auth)
@@ -264,12 +267,12 @@ app.get('/user/:username', async (req, res, next)=>{
console.error(err); console.error(err);
}); });
// If stub matches file from Google, use Google metadata over stub metadata
if(googleBrews && googleBrews.length > 0) { if(googleBrews && googleBrews.length > 0) {
for (const brew of brews.filter((brew)=>brew.googleId)) { for (const brew of brews.filter((brew)=>brew.googleId)) {
const match = googleBrews.findIndex((b)=>b.editId === brew.editId); const match = googleBrews.findIndex((b)=>b.editId === brew.editId);
if(match !== -1) { if(match !== -1) {
brew.googleId = googleBrews[match].googleId; brew.googleId = googleBrews[match].googleId;
brew.stubbed = true;
brew.pageCount = googleBrews[match].pageCount; brew.pageCount = googleBrews[match].pageCount;
brew.renderer = googleBrews[match].renderer; brew.renderer = googleBrews[match].renderer;
brew.version = googleBrews[match].version; brew.version = googleBrews[match].version;
@@ -278,6 +281,7 @@ app.get('/user/:username', async (req, res, next)=>{
} }
} }
//Remaining unstubbed google brews display current user as author
googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] })); googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] }));
brews = _.concat(brews, googleBrews); brews = _.concat(brews, googleBrews);
} }
@@ -380,7 +384,7 @@ app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, r
app.get('/account', asyncHandler(async (req, res, next)=>{ app.get('/account', asyncHandler(async (req, res, next)=>{
const data = {}; const data = {};
data.title = 'Account Information Page'; data.title = 'Account Information Page';
if(!req.account) { if(!req.account) {
res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"'); res.set('WWW-Authenticate', 'Bearer realm="Authorization Required"');
const error = new Error('No valid account'); const error = new Error('No valid account');
@@ -394,22 +398,12 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
let googleCount = []; let googleCount = [];
if(req.account) { if(req.account) {
if(req.account.googleId) { if(req.account.googleId) {
try { auth = await GoogleActions.authCheck(req.account, res, false)
auth = await GoogleActions.authCheck(req.account, res, false);
} catch (e) { googleCount = await GoogleActions.listGoogleBrews(auth)
auth = undefined; .catch((err)=>{
console.log('Google auth check failed!'); console.error(err);
console.log(e); });
}
if(auth.credentials.access_token) {
try {
googleCount = await GoogleActions.listGoogleBrews(auth);
} catch (e) {
googleCount = undefined;
console.log('List Google files failed!');
console.log(e);
}
}
} }
const query = { authors: req.account.username, googleId: { $exists: false } }; const query = { authors: req.account.username, googleId: { $exists: false } };
@@ -423,7 +417,7 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
username : req.account.username, username : req.account.username,
issued : req.account.issued, issued : req.account.issued,
googleId : Boolean(req.account.googleId), googleId : Boolean(req.account.googleId),
authCheck : Boolean(req.account.googleId && auth.credentials.access_token), authCheck : Boolean(req.account.googleId && auth?.credentials.access_token),
mongoCount : mongoCount, mongoCount : mongoCount,
googleCount : googleCount?.length googleCount : googleCount?.length
}; };
@@ -468,8 +462,8 @@ app.get('/vault', asyncHandler(async(req, res, next)=>{
//Send rendered page //Send rendered page
app.use(asyncHandler(async (req, res, next)=>{ 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); const page = await renderPage(req, res);
if(!page) return; if(!page) return;
res.send(page); res.send(page);
@@ -481,7 +475,8 @@ const renderPage = async (req, res)=>{
const configuration = { const configuration = {
local : isLocalEnvironment, local : isLocalEnvironment,
publicUrl : config.get('publicUrl') ?? '', publicUrl : config.get('publicUrl') ?? '',
environment : nodeEnv environment : nodeEnv,
deployment : config.get('heroku_app_name') ?? ''
}; };
const props = { const props = {
version : require('./../package.json').version, version : require('./../package.json').version,

View File

@@ -25,6 +25,15 @@ if(!config.get('service_account')){
const defaultAuth = serviceAuth || config.get('google_api_key'); const defaultAuth = serviceAuth || config.get('google_api_key');
const retryConfig = {
retry: 3, // Number of retry attempts
retryDelay: 100, // Initial delay in milliseconds
retryDelayMultiplier: 2, // Multiplier for exponential backoff
maxRetryDelay: 32000, // Maximum delay in milliseconds
httpMethodsToRetry: ['PATCH'], // Only retry PATCH requests
statusCodesToRetry: [[429, 429]], // Only retry on 429 status code
};
const GoogleActions = { const GoogleActions = {
authCheck : (account, res, updateTokens=true)=>{ authCheck : (account, res, updateTokens=true)=>{
@@ -112,9 +121,7 @@ const GoogleActions = {
}) })
.catch((err)=>{ .catch((err)=>{
console.log(`Error Listing Google Brews`); console.log(`Error Listing Google Brews`);
console.error(err);
throw (err); throw (err);
//TODO: Should break out here, but continues on for some reason.
}); });
fileList.push(...obj.data.files); fileList.push(...obj.data.files);
NextPageToken = obj.data.nextPageToken; NextPageToken = obj.data.nextPageToken;
@@ -147,7 +154,7 @@ const GoogleActions = {
return brews; return brews;
}, },
updateGoogleBrew : async (brew)=>{ updateGoogleBrew : async (brew, userIp)=>{
const drive = googleDrive.drive({ version: 'v3', auth: defaultAuth }); const drive = googleDrive.drive({ version: 'v3', auth: defaultAuth });
await drive.files.update({ await drive.files.update({
@@ -168,7 +175,11 @@ const GoogleActions = {
media : { media : {
mimeType : 'text/plain', mimeType : 'text/plain',
body : brew.text body : brew.text
} },
headers: {
'X-Forwarded-For': userIp, // Set the X-Forwarded-For header
},
retryConfig
}) })
.catch((err)=>{ .catch((err)=>{
console.log('Error saving to google'); console.log('Error saving to google');

View File

@@ -353,7 +353,7 @@ const api = {
if(!brew.googleId) return; if(!brew.googleId) return;
} else if(brew.googleId) { } else if(brew.googleId) {
// If the google id exists and no other actions are being performed, update the google brew // If the google id exists and no other actions are being performed, update the google brew
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew)); const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew), req.ip);
if(!updated) return; if(!updated) return;
} }

View File

@@ -154,28 +154,6 @@ module.exports = [
] ]
}, },
{
name : 'Table of Contents Toggles',
icon : 'fas fa-book',
gen : `{{tocGlobalH4}}\n\n`,
subsnippets : [
{
name : 'Enable H1-H4 all pages',
icon : 'fas fa-dice-four',
gen : `{{tocGlobalH4}}\n\n`,
},
{
name : 'Enable H1-H5 all pages',
icon : 'fas fa-dice-five',
gen : `{{tocGlobalH5}}\n\n`,
},
{
name : 'Enable H1-H6 all pages',
icon : 'fas fa-dice-six',
gen : `{{tocGlobalH6}}\n\n`,
},
]
}
] ]
}, },
{ {
@@ -214,6 +192,27 @@ module.exports = [
line-height: 1em; line-height: 1em;
}\n\n` }\n\n`
}, },
{
name : 'Table of Contents Toggles',
icon : 'fas fa-book',
subsnippets : [
{
name : 'Enable H1-H4 all pages',
icon : 'fas fa-dice-four',
gen : `.page {\n\th4 {--TOC: include; }\n}\n\n`,
},
{
name : 'Enable H1-H5 all pages',
icon : 'fas fa-dice-five',
gen : `.page {\n\th4, h5 {--TOC: include; }\n}\n\n`,
},
{
name : 'Enable H1-H6 all pages',
icon : 'fas fa-dice-six',
gen : `.page {\n\th4, h5, h6 {--TOC: include; }\n}\n\n`,
},
]
}
] ]
}, },

View File

@@ -812,17 +812,8 @@ h6,
// Brew level default inclusion changes. // Brew level default inclusion changes.
// These add Headers 'back' to inclusion. // These add Headers 'back' to inclusion.
.pages:has(.tocGlobalH4) {
h4 {--TOC: include; }
}
.pages:has(.tocGlobalH5) { //NOTE: DO NOT USE :HAS WITH .PAGES!!! EXTREMELY SLOW TO RENDER ON LARGE DOCS!
h4, h5 {--TOC: include; }
}
.pages:has(.tocGlobalH6) {
h4, h5, h6 {--TOC: include; }
}
// Block level inclusion changes // Block level inclusion changes
// These include either a single (include) or a range (depth) // These include either a single (include) or a range (depth)