0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-27 22:33:07 +00:00

Compare commits

..

1 Commits

Author SHA1 Message Date
Trevor Buckner
7c49088656 Move Themes/styles into iFrame Head 2024-10-16 00:04:49 -04:00
97 changed files with 3313 additions and 3645 deletions

View File

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

View File

@@ -1,29 +1,26 @@
> [!TIP] <!--
> Before submitting a Pull Request, please consider the following to speed up reviews: 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. - 👷‍♀️ 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. - 🚩 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? - 🔧 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. - 💡 Is the solution agreed upon? Save rework time by discussing strategy before coding.
-->
## Description ## 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 ## 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 # - Closes #
## QA Instructions, Screenshots, Recordings ## QA Instructions, Screenshots, Recordings
_Replace this line with instructions on how to test or view your changes, as well as any before/after _Please 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._ images for UI changes._
### Reviewer Checklist ### Reviewer Checklist
_Replace the list below with specific features you want reviewers to look at._ _Please 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 * *Reviewers, refer to this list when testing features, or suggest new items *
- [ ] Verify new features are functional - [ ] Verify new features are functional
@@ -35,3 +32,5 @@ _Replace the list below with specific features you want reviewers to look at._
- [ ] Feature A handles negative numbers - [ ] Feature A handles negative numbers
- [ ] Identify opportunities for simplification and refactoring - [ ] Identify opportunities for simplification and refactoring
- [ ] Check for code legibility and appropriate comments - [ ] Check for code legibility and appropriate comments
<details><summary>Copy this list</summary>

View File

@@ -1,10 +0,0 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-transform-runtime",
"babel-plugin-transform-import-meta"
]
}

View File

@@ -85,52 +85,6 @@ 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).
### Wednesday 11/27/2024 - v3.16.1
{{taskList
##### 5e-Cleric
* [x] Allow linking to specific HTML IDs via `#ID` at the end of the URL, e.g.: `homebrewery.naturalcrit.com/share/share/a6RCXwaDS58i#p4` to link to Page 4 directly
Fixes issues [#2820](https://github.com/naturalcrit/homebrewery/issues/2820), [#3505](https://github.com/naturalcrit/homebrewery/issues/3505)
* [x] Fix generation of link to certain Google Drive brews
Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776)
##### abquintic
* [x] Fix blank pages appearing when pasting text
Fixes issue [#3718](https://github.com/naturalcrit/homebrewery/issues/3718)
##### Gazook89
* [x] Add new brew viewing options to the view toolbar
- {{fac,single-spread}} {{openSans **SINGLE PAGE**}}
- {{fac,facing-spread}} {{openSans **TWO PAGE**}}
- {{fac,flow-spread}} {{openSans **GRID**}}
Fixes issue [#1379](https://github.com/naturalcrit/homebrewery/issues/1379)
* [x] Updates to tag input boxes
##### G-Ambatte
* [x] Admin tools to fix certain corrupted documents
Fixes issue [#3801](https://github.com/naturalcrit/homebrewery/issues/3801)
* [x] Fix print window being affected by document zoom
Fixes issue [#3744](https://github.com/naturalcrit/homebrewery/issues/3744)
##### calculuschild, 5e-Cleric, G-Ambatte, Gazook89, abquintic
* [x] Multiple code refactors, cleanups, and security fixes
}}
### Saturday 10/12/2024 - v3.16.0 ### Saturday 10/12/2024 - v3.16.0
{{taskList {{taskList

View File

@@ -1,5 +1,3 @@
require('./brewLookup.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const cx = require('classnames'); const cx = require('classnames');
@@ -14,43 +12,22 @@ const BrewLookup = createClass({
}, },
getInitialState() { getInitialState() {
return { return {
query : '', query : '',
foundBrew : null, foundBrew : null,
searching : false, searching : false,
error : null, error : null
scriptCount : 0
}; };
}, },
handleChange(e){ handleChange(e){
this.setState({ query: e.target.value }); this.setState({ query: e.target.value });
}, },
lookup(){ lookup(){
this.setState({ searching: true, error: null, scriptCount: 0 }); this.setState({ searching: true, error: null });
request.get(`/admin/lookup/${this.state.query}`) request.get(`/admin/lookup/${this.state.query}`)
.then((res)=>{ .then((res)=>this.setState({ foundBrew: res.body }))
const foundBrew = res.body;
const scriptCheck = foundBrew?.text.match(/(<\/?s)cript/g);
this.setState({
foundBrew : foundBrew,
scriptCount : scriptCheck?.length || 0,
});
})
.catch((err)=>this.setState({ error: err })) .catch((err)=>this.setState({ error: err }))
.finally(()=>{ .finally(()=>this.setState({ searching: false }));
this.setState({
searching : false
});
});
},
async cleanScript(){
if(!this.state.foundBrew?.shareId) return;
await request.put(`/admin/clean/script/${this.state.foundBrew.shareId}`)
.catch((err)=>{ this.setState({ error: err }); return; });
this.lookup();
}, },
renderFoundBrew(){ renderFoundBrew(){
@@ -69,23 +46,12 @@ const BrewLookup = createClass({
<dt>Share Link</dt> <dt>Share Link</dt>
<dd><a href={`/share/${brew.shareId}`} target='_blank' rel='noopener noreferrer'>/share/{brew.shareId}</a></dd> <dd><a href={`/share/${brew.shareId}`} target='_blank' rel='noopener noreferrer'>/share/{brew.shareId}</a></dd>
<dt>Created Time</dt>
<dd>{brew.createdAt ? Moment(brew.createdAt).toLocaleString() : 'No creation date'}</dd>
<dt>Last Updated</dt> <dt>Last Updated</dt>
<dd>{Moment(brew.updatedAt).fromNow()}</dd> <dd>{Moment(brew.updatedAt).fromNow()}</dd>
<dt>Num of Views</dt> <dt>Num of Views</dt>
<dd>{brew.views}</dd> <dd>{brew.views}</dd>
<dt>SCRIPT tags detected</dt>
<dd>{this.state.scriptCount}</dd>
</dl> </dl>
{this.state.scriptCount > 0 &&
<div className='cleanButton'>
<button onClick={this.cleanScript}>CLEAN BREW</button>
</div>
}
</div>; </div>;
}, },

View File

@@ -1,6 +0,0 @@
.brewLookup {
.cleanButton {
display : inline-block;
width : 100%;
}
}

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='dismiss_notif_drive' placeholder='GOOGLEDRIVENOTIF'
/> />
</label> </label>

View File

@@ -14,6 +14,9 @@ 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>
@@ -22,9 +25,6 @@ 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,91 +0,0 @@
import React, { useState, useRef, forwardRef, useEffect, cloneElement, Children } from 'react';
import './Anchored.less';
// Anchored is a wrapper component that must have as children an <AnchoredTrigger> and a <AnchoredBox> component.
// AnchoredTrigger must have a unique `id` prop, which is passed up to Anchored, saved in state on mount, and
// then passed down through props into AnchoredBox. The `id` is used for the CSS Anchor Positioning properties.
// **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.
const Anchored = ({ children })=>{
const [visible, setVisible] = useState(false);
const [anchorId, setAnchorId] = useState(null);
const boxRef = useRef(null);
const triggerRef = useRef(null);
// promote trigger id to Anchored id (to pass it back down to the box as "anchorId")
useEffect(()=>{
if(triggerRef.current){
setAnchorId(triggerRef.current.id);
}
}, []);
// close box on outside click or Escape key
useEffect(()=>{
const handleClickOutside = (evt)=>{
if(
boxRef.current &&
!boxRef.current.contains(evt.target) &&
triggerRef.current &&
!triggerRef.current.contains(evt.target)
) {
setVisible(false);
}
};
const handleEscapeKey = (evt)=>{
if(evt.key === 'Escape') setVisible(false);
};
window.addEventListener('click', handleClickOutside);
window.addEventListener('keydown', handleEscapeKey);
return ()=>{
window.removeEventListener('click', handleClickOutside);
window.removeEventListener('keydown', handleEscapeKey);
};
}, []);
const toggleVisibility = ()=>setVisible((prev)=>!prev);
// Map children to inject necessary props
const mappedChildren = Children.map(children, (child)=>{
if(child.type === AnchoredTrigger) {
return cloneElement(child, { ref: triggerRef, toggleVisibility, visible });
}
if(child.type === AnchoredBox) {
return cloneElement(child, { ref: boxRef, visible, anchorId });
}
return child;
});
return <>{mappedChildren}</>;
};
// forward ref for AnchoredTrigger
const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
<button
ref={ref}
className={`anchored-trigger${visible ? ' active' : ''} ${className}`}
onClick={toggleVisibility}
style={{ anchorName: `--${props.id}` }} // setting anchor properties here allows greater recyclability.
{...props}
>
{children}
</button>
));
// forward ref for AnchoredBox
const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
<div
ref={ref}
className={`anchored-box${visible ? ' active' : ''} ${className}`}
style={{ positionAnchor: `--${anchorId}` }} // setting anchor properties here allows greater recyclability.
{...props}
>
{children}
</div>
));
export { Anchored, AnchoredTrigger, AnchoredBox };

View File

@@ -1,13 +0,0 @@
.anchored-box {
position:absolute;
@supports (inset-block-start: anchor(bottom)){
inset-block-start: anchor(bottom);
}
justify-self: anchor-center;
visibility: hidden;
&.active {
visibility: visible;
}
}

View File

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

@@ -5,7 +5,7 @@ const { useState, useRef, useCallback, useMemo } = React;
const _ = require('lodash'); const _ = require('lodash');
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js'); const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
import Markdown from 'naturalcrit/markdown.js'; const Markdown = require('naturalcrit/markdown.js');
const ErrorBar = require('./errorBar/errorBar.jsx'); const ErrorBar = require('./errorBar/errorBar.jsx');
const ToolBar = require('./toolBar/toolBar.jsx'); const ToolBar = require('./toolBar/toolBar.jsx');
@@ -16,7 +16,8 @@ const Frame = require('react-frame-component').default;
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
const { printCurrentBrew } = require('../../../shared/helpers.js'); const { printCurrentBrew } = require('../../../shared/helpers.js');
import { safeHTML } from './safeHTML.js'; const DOMPurify = require('dompurify');
const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false };
const PAGE_HEIGHT = 1056; const PAGE_HEIGHT = 1056;
@@ -28,7 +29,6 @@ const INITIAL_CONTENT = dedent`
<base target=_blank> <base target=_blank>
</head><body style='overflow: hidden'><div></div></body></html>`; </head><body style='overflow: hidden'><div></div></body></html>`;
//v=====----------------------< Brew Page Component >---------------------=====v// //v=====----------------------< Brew Page Component >---------------------=====v//
const BrewPage = (props)=>{ const BrewPage = (props)=>{
props = { props = {
@@ -36,8 +36,8 @@ const BrewPage = (props)=>{
index : 0, index : 0,
...props ...props
}; };
const cleanText = safeHTML(props.contents); const cleanText = props.contents; //DOMPurify.sanitize(props.contents, purifyConfig);
return <div className={props.className} id={`p${props.index + 1}`} style={props.style}> return <div className={props.className} id={`p${props.index + 1}`} >
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} /> <div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
</div>; </div>;
}; };
@@ -65,14 +65,8 @@ const BrewRenderer = (props)=>{
const [state, setState] = useState({ const [state, setState] = useState({
isMounted : false, isMounted : false,
visibility : 'hidden' visibility : 'hidden',
}); zoom : 100
const [displayOptions, setDisplayOptions] = useState({
zoomLevel : 100,
spread : 'single',
startOnRight : true,
pageShadows : true
}); });
const mainRef = useRef(null); const mainRef = useRef(null);
@@ -83,26 +77,6 @@ const BrewRenderer = (props)=>{
rawPages = props.text.split(/^\\page$/gm); rawPages = props.text.split(/^\\page$/gm);
} }
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 updateCurrentPage = useCallback(_.throttle((e)=>{ const updateCurrentPage = useCallback(_.throttle((e)=>{
const { scrollTop, clientHeight, scrollHeight } = e.target; const { scrollTop, clientHeight, scrollHeight } = e.target;
const totalScrollableHeight = scrollHeight - clientHeight; const totalScrollableHeight = scrollHeight - clientHeight;
@@ -131,9 +105,9 @@ const BrewRenderer = (props)=>{
}; };
const renderStyle = ()=>{ const renderStyle = ()=>{
const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>'; const cleanStyle = props.style; //DOMPurify.sanitize(props.style, purifyConfig);
const cleanStyle = safeHTML(`${themeStyles} \n\n <style> ${props.style} </style>`); const themeStyles = props.themeBundle?.joinedStyles ?? '@import url("/themes/V3/Blank/style.css");';
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: cleanStyle }} />; return `${themeStyles}\n\n${cleanStyle}`;
}; };
const renderPage = (pageText, index)=>{ const renderPage = (pageText, index)=>{
@@ -143,13 +117,7 @@ const BrewRenderer = (props)=>{
} else { } 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) 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 html = Markdown.render(pageText, index);
return <BrewPage className='page' index={index} key={index} contents={html} />;
const styles = {
...(!displayOptions.pageShadows ? { boxShadow: 'none' } : {})
// Add more conditions as needed
};
return <BrewPage className='page' index={index} key={index} contents={html} style={styles} />;
} }
}; };
@@ -161,8 +129,7 @@ const BrewRenderer = (props)=>{
renderedPages.length = 0; renderedPages.length = 0;
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first // Render currently-edited page first so cross-page effects (variables, links) can propagate out first
if(rawPages.length > props.currentEditorCursorPageNum -1) renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
_.forEach(rawPages, (page, index)=>{ _.forEach(rawPages, (page, index)=>{
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){ if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
@@ -183,8 +150,6 @@ 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
renderPages(); //Make sure page is renderable before showing renderPages(); //Make sure page is renderable before showing
setState((prevState)=>({ setState((prevState)=>({
@@ -200,24 +165,22 @@ const BrewRenderer = (props)=>{
document.dispatchEvent(new MouseEvent('click')); document.dispatchEvent(new MouseEvent('click'));
}; };
const handleDisplayOptionsChange = (newDisplayOptions)=>{ //Toolbar settings:
setDisplayOptions(newDisplayOptions); const handleZoom = (newZoom)=>{
}; setState((prevState)=>({
...prevState,
const pagesStyle = { zoom : newZoom
zoom : `${displayOptions.zoomLevel}%`, }));
columnGap : `${displayOptions.columnGap}px`,
rowGap : `${displayOptions.rowGap}px`
}; };
const styleObject = {}; const styleObject = {};
if(global.config.deployment) { 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>")`; 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='white' font-size='20'>${global.config.deployment}</text></svg>")`;
} }
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]); const renderedStyle = useMemo(()=> renderStyle(), [props.style?.length, props.themeBundle]);
renderedPages = useMemo(()=>renderPages(), [displayOptions.pageShadows, props.text]); renderedPages = useMemo(() => renderPages(), [props.text?.length]);
return ( return (
<> <>
@@ -236,10 +199,10 @@ const BrewRenderer = (props)=>{
<NotificationPopup /> <NotificationPopup />
</div> </div>
<ToolBar displayOptions={displayOptions} currentPage={props.currentBrewRendererPageNum} totalPages={rawPages.length} onDisplayOptionsChange={handleDisplayOptionsChange} /> <ToolBar onZoomChange={handleZoom} currentPage={props.currentBrewRendererPageNum} totalPages={rawPages.length}/>
{/*render in iFrame so broken code doesn't crash the site.*/} {/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT} <Frame id='BrewRenderer' initialContent={INITIAL_CONTENT} head={<style>{renderedStyle}</style>}
style={{ width: '100%', height: '100%', visibility: state.visibility }} style={{ width: '100%', height: '100%', visibility: state.visibility }}
contentDidMount={frameDidMount} contentDidMount={frameDidMount}
onClick={()=>{emitClick();}} onClick={()=>{emitClick();}}
@@ -254,9 +217,7 @@ const BrewRenderer = (props)=>{
{state.isMounted {state.isMounted
&& &&
<> <>
{renderedStyle} <div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}>
<div lang={`${props.lang || 'en'}`} style={pagesStyle} className={
`pages ${displayOptions.startOnRight ? 'recto' : 'verso'} ${displayOptions.spread}` } >
{renderedPages} {renderedPages}
</div> </div>
</> </>

View File

@@ -3,45 +3,13 @@
.brewRenderer { .brewRenderer {
overflow-y : scroll; overflow-y : scroll;
will-change : transform; will-change : transform;
padding-top : 60px; padding-top : 30px;
height : 100vh; height : 100vh;
&:has(.facing, .flow) {
padding : 60px 30px;
}
&.deployment { &.deployment {
background-color: darkred; background-color: darkred;
} }
:where(.pages) { :where(.pages) {
&.facing { margin : 30px 0px;
display: grid;
grid-template-columns: repeat(2, auto);
grid-template-rows: repeat(3, auto);
gap: 10px 10px;
justify-content: 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
grid-column-start: 2;
}
& :where(.page) {
margin-left: unset !important;
margin-right: unset !important;
}
}
&.flow {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-start;
& :where(.page) {
flex: 0 0 auto;
margin-left: unset !important;
margin-right: unset !important;
}
}
& > :where(.page) { & > :where(.page) {
width : 215.9mm; width : 215.9mm;
height : 279.4mm; height : 279.4mm;
@@ -50,9 +18,6 @@
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,62 +1,44 @@
require('./notificationPopup.less'); require('./notificationPopup.less');
import React, { useEffect, useState } from 'react'; const React = require('react');
import request from '../../utils/request-middleware.js'; const _ = require('lodash');
import Dialog from '../../../components/dialog.jsx'; import Dialog from '../../../components/dialog.jsx';
const DISMISS_KEY = 'dismiss_notification01-10-24';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />; const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
const NotificationPopup = ()=>{ const NotificationPopup = ()=>{
const [notifications, setNotifications] = useState([]); return <Dialog className='notificationPopup' dismissKey={DISMISS_KEY} closeText={DISMISS_BUTTON} >
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>
{renderNotificationsList()} <li key='Vault'>
<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,10 +55,7 @@
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 { em { font-weight : 800; }
text-transform:capitalize;
font-weight : 800;
}
} }
} }
} }

View File

@@ -1,46 +0,0 @@
// Derived from the vue-html-secure package, customized for Homebrewery
let doc = null;
let div = null;
function safeHTML(htmlString) {
// If the Document interface doesn't exist, exit
if(typeof document == 'undefined') return null;
// If the test document and div don't exist, create them
if(!doc) doc = document.implementation.createHTMLDocument('');
if(!div) div = doc.createElement('div');
// Set the test div contents to the evaluation string
div.innerHTML = htmlString;
// Grab all nodes from the test div
const elements = div.querySelectorAll('*');
// Blacklisted tags
const blacklistTags = ['script', 'noscript', 'noembed'];
// Tests to remove attributes
const blacklistAttrs = [
(test)=>{return test.localName.indexOf('on') == 0;},
(test)=>{return test.localName.indexOf('type') == 0 && test.value.match(/submit/i);},
(test)=>{return test.value.replace(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g, '').toLowerCase().trim().indexOf('javascript:') == 0;}
];
elements.forEach((element)=>{
// Check each element for blacklisted type
if(blacklistTags.includes(element?.localName?.toLowerCase())) {
element.remove();
return;
}
// Check remaining elements for blacklisted attributes
for (const attribute of element.attributes){
if(blacklistAttrs.some((test)=>{return test(attribute);})) {
element.removeAttribute(attribute.localName);
break;
};
};
});
return div.innerHTML;
};
module.exports.safeHTML = safeHTML;

View File

@@ -3,28 +3,26 @@ const React = require('react');
const { useState, useEffect } = React; const { useState, useEffect } = React;
const _ = require('lodash'); 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 MAX_ZOOM = 300;
const MIN_ZOOM = 10; const MIN_ZOOM = 10;
const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChange })=>{ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
const [pageNum, setPageNum] = useState(currentPage); const [zoomLevel, setZoomLevel] = useState(100);
const [pageNum, setPageNum] = useState(currentPage);
const [toolsVisible, setToolsVisible] = useState(true); const [toolsVisible, setToolsVisible] = useState(true);
useEffect(()=>{
onZoomChange(zoomLevel);
}, [zoomLevel]);
useEffect(()=>{ useEffect(()=>{
setPageNum(currentPage); setPageNum(currentPage);
}, [currentPage]); }, [currentPage]);
const handleZoomButton = (zoom)=>{ const handleZoomButton = (zoom)=>{
handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM))); setZoomLevel(_.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
};
const handleOptionChange = (optionKey, newValue)=>{
//setDisplayOptions(prevOptions => ({ ...prevOptions, [optionKey]: newValue }));
onDisplayOptionsChange({ ...displayOptions, [optionKey]: newValue });
}; };
const handlePageInput = (pageInput)=>{ const handlePageInput = (pageInput)=>{
@@ -65,51 +63,47 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
const margin = 5; // extra space so page isn't edge to edge (not truly "to fill") const margin = 5; // extra space so page isn't edge to edge (not truly "to fill")
const deltaZoom = (desiredZoom - displayOptions.zoomLevel) - margin; const deltaZoom = (desiredZoom - zoomLevel) - margin;
return deltaZoom; return deltaZoom;
}; };
return ( return (
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'> <div className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`}>
<button className='toggleButton' title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{setToolsVisible(!toolsVisible);}}><i className='fas fa-glasses' /></button> <button className='toggleButton' title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{setToolsVisible(!toolsVisible);}}><i className='fas fa-glasses' /></button>
{/*v=====----------------------< Zoom Controls >---------------------=====v*/} {/*v=====----------------------< Zoom Controls >---------------------=====v*/}
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}> <div className='group'>
<button <button
id='fill-width' id='fill-width'
className='tool' className='tool'
title='Set zoom to fill preview with one page' onClick={()=>handleZoomButton(zoomLevel + calculateChange('fill'))}
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
> >
<i className='fac fit-width' /> <i className='fac fit-width' />
</button> </button>
<button <button
id='zoom-to-fit' id='zoom-to-fit'
className='tool' className='tool'
title='Set zoom to fit entire page in preview' onClick={()=>handleZoomButton(zoomLevel + calculateChange('fit'))}
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
> >
<i className='fac zoom-to-fit' /> <i className='fac zoom-to-fit' />
</button> </button>
<button <button
id='zoom-out' id='zoom-out'
className='tool' className='tool'
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)} onClick={()=>handleZoomButton(zoomLevel - 20)}
disabled={displayOptions.zoomLevel <= MIN_ZOOM} disabled={zoomLevel <= MIN_ZOOM}
title='Zoom Out'
> >
<i className='fas fa-magnifying-glass-minus' /> <i className='fas fa-magnifying-glass-minus' />
</button> </button>
<input <input
id='zoom-slider' id='zoom-slider'
className='range-input tool hover-tooltip' className='range-input tool'
type='range' type='range'
name='zoom' name='zoom'
title='Set Zoom'
list='zoomLevels' list='zoomLevels'
min={MIN_ZOOM} min={MIN_ZOOM}
max={MAX_ZOOM} max={MAX_ZOOM}
step='1' step='1'
value={displayOptions.zoomLevel} value={zoomLevel}
onChange={(e)=>handleZoomButton(parseInt(e.target.value))} onChange={(e)=>handleZoomButton(parseInt(e.target.value))}
/> />
<datalist id='zoomLevels'> <datalist id='zoomLevels'>
@@ -119,72 +113,18 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
<button <button
id='zoom-in' id='zoom-in'
className='tool' className='tool'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)} onClick={()=>handleZoomButton(zoomLevel + 20)}
disabled={displayOptions.zoomLevel >= MAX_ZOOM} disabled={zoomLevel >= MAX_ZOOM}
title='Zoom In'
> >
<i className='fas fa-magnifying-glass-plus' /> <i className='fas fa-magnifying-glass-plus' />
</button> </button>
</div> </div>
{/*v=====----------------------< Spread Controls >---------------------=====v*/}
<div className='group' role='group' aria-label='Spread' aria-hidden={!toolsVisible}>
<div className='radio-group' role='radiogroup'>
<button role='radio'
id='single-spread'
className='tool'
title='Single Page'
onClick={()=>{handleOptionChange('spread', 'active');}}
aria-checked={displayOptions.spread === 'single'}
><i className='fac single-spread' /></button>
<button role='radio'
id='facing-spread'
className='tool'
title='Facing Pages'
onClick={()=>{handleOptionChange('spread', 'facing');}}
aria-checked={displayOptions.spread === 'facing'}
><i className='fac facing-spread' /></button>
<button role='radio'
id='flow-spread'
className='tool'
title='Flow Pages'
onClick={()=>{handleOptionChange('spread', 'flow');}}
aria-checked={displayOptions.spread === 'flow'}
><i className='fac flow-spread' /></button>
</div>
<Anchored>
<AnchoredTrigger id='spread-settings' className='tool' title='Spread options'><i className='fas fa-gear' /></AnchoredTrigger>
<AnchoredBox title='Options'>
<h1>Options</h1>
<label title='Modify the horizontal space between pages.'>
Column gap
<input type='range' min={0} max={200} defaultValue={10} className='range-input' onChange={(evt)=>handleOptionChange('columnGap', evt.target.value)} />
</label>
<label title='Modify the vertical space between rows of pages.'>
Row gap
<input type='range' min={0} max={200} defaultValue={10} className='range-input' onChange={(evt)=>handleOptionChange('rowGap', evt.target.value)} />
</label>
<label title='Start 1st page on the right side, such as if you have cover page.'>
Start on right
<input type='checkbox' checked={displayOptions.startOnRight} onChange={()=>{handleOptionChange('startOnRight', !displayOptions.startOnRight);}}
title={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
</label>
<label title='Toggle the page shadow on every page.'>
Page shadows
<input type='checkbox' checked={displayOptions.pageShadows} onChange={()=>{handleOptionChange('pageShadows', !displayOptions.pageShadows);}} />
</label>
</AnchoredBox>
</Anchored>
</div>
{/*v=====----------------------< Page Controls >---------------------=====v*/} {/*v=====----------------------< Page Controls >---------------------=====v*/}
<div className='group' role='group' aria-label='Pages' aria-hidden={!toolsVisible}> <div className='group'>
<button <button
id='previous-page' id='previous-page'
className='previousPage tool' className='previousPage tool'
type='button'
title='Previous Page(s)'
onClick={()=>scrollToPage(pageNum - 1)} onClick={()=>scrollToPage(pageNum - 1)}
disabled={pageNum <= 1} disabled={pageNum <= 1}
> >
@@ -197,7 +137,6 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
className='text-input' className='text-input'
type='text' type='text'
name='page' name='page'
title='Current page(s) in view'
inputMode='numeric' inputMode='numeric'
pattern='[0-9]' pattern='[0-9]'
value={pageNum} value={pageNum}
@@ -206,14 +145,12 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
onBlur={()=>scrollToPage(pageNum)} onBlur={()=>scrollToPage(pageNum)}
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)} onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
/> />
<span id='page-count' title='Total Page Count'>/ {totalPages}</span> <span id='page-count'>/ {totalPages}</span>
</div> </div>
<button <button
id='next-page' id='next-page'
className='tool' className='tool'
type='button'
title='Next Page(s)'
onClick={()=>scrollToPage(pageNum + 1)} onClick={()=>scrollToPage(pageNum + 1)}
disabled={pageNum >= totalPages} disabled={pageNum >= totalPages}
> >

View File

@@ -13,12 +13,11 @@
height : auto; height : auto;
padding : 2px 0; padding : 2px 0;
font-family : 'Open Sans', sans-serif; font-family : 'Open Sans', sans-serif;
font-size : 13px;
color : #CCCCCC; color : #CCCCCC;
background-color : #555555; background-color : #555555;
& > *:not(.toggleButton) { & > *:not(.toggleButton) {
opacity : 1; opacity: 1;
transition : all 0.2s ease; transition: all .2s ease;
} }
.group { .group {
@@ -35,70 +34,6 @@
align-items : center; align-items : center;
} }
.active, [aria-checked='true'] { background-color : #444444; }
.anchored-trigger {
&.active { background-color : #444444; }
}
.anchored-box {
--box-color : #555555;
top : 30px;
display : flex;
flex-direction : column;
gap : 5px;
padding : 15px;
margin-top : 10px;
font-size : 0.8em;
color : #CCCCCC;
background-color : var(--box-color);
border-radius : 5px;
h1 {
padding-bottom : 0.3em;
margin-bottom : 0.5em;
border-bottom : 1px solid currentColor;
}
h2 {
padding-bottom : 0.3em;
margin : 1em 0 0.5em 0;
color : lightgray;
border-bottom : 1px solid currentColor;
}
label {
display : flex;
gap : 6px;
align-items : center;
justify-content : space-between;
}
input {
height : unset;
&[type='range'] { padding : 0; }
}
&::before {
position : absolute;
top : -20px;
left : 50%;
width : 0px;
height : 0px;
pointer-events : none;
content : '';
border : 10px solid transparent;
border-bottom : 10px solid var(--box-color);
transform : translateX(-50%);
}
}
.radio-group:has(button[role='radio']) {
display : flex;
height : 100%;
border : 1px solid #333333;
}
input { input {
position : relative; position : relative;
height : 1.5em; height : 1.5em;
@@ -115,14 +50,14 @@
color : #D3D3D3; color : #D3D3D3;
accent-color : #D3D3D3; accent-color : #D3D3D3;
&::-webkit-slider-thumb, &::-moz-range-thumb { &::-webkit-slider-thumb, &::-moz-slider-thumb {
width : 5px; width : 5px;
height : 5px; height : 5px;
cursor : ew-resize; cursor : pointer;
outline : none; outline : none;
} }
&.hover-tooltip[value]:hover::after { &:hover::after {
position : absolute; position : absolute;
bottom : -30px; bottom : -30px;
left : 50%; left : 50%;
@@ -148,40 +83,46 @@
} }
button { button {
box-sizing : border-box; box-sizing : content-box;
display : flex; display : flex;
align-items : center; align-items : center;
justify-content : center; justify-content : center;
width : auto; width : auto;
min-width : 46px; min-width : 46px;
height : 100%; height : 100%;
padding : 0 0px;
font-weight : unset;
color : inherit;
background-color : unset;
&:hover { background-color : #444444; } &:hover { background-color : #444444; }
&:focus { border : 1px solid #D3D3D3;outline : none;} &:focus { outline : 1px solid #D3D3D3; }
&:disabled { &:disabled {
color : #777777; color : #777777;
background-color : unset !important; background-color : unset !important;
} }
i { font-size : 1.2em; } i {
font-size:1.2em;
}
} }
&.hidden { &.hidden {
flex-wrap : nowrap; width: 32px;
width : 32px; transition: all .3s ease;
overflow : hidden; flex-wrap:nowrap;
background-color : unset; overflow: hidden;
opacity : 0.5; background-color: unset;
transition : all 0.3s ease; opacity: .5;
& > *:not(.toggleButton) { & > *:not(.toggleButton) {
opacity : 0; opacity: 0;
transition : all 0.2s ease; transition: all .2s ease;
} }
} }
} }
button.toggleButton { button.toggleButton {
position : absolute; z-index : 5;
left : 0; position:absolute;
z-index : 5; left: 0;
width : 32px; width: 32px;
min-width : unset; min-width: unset;
} }

View File

@@ -4,7 +4,7 @@ const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
import Markdown from '../../../shared/naturalcrit/markdown.js'; const Markdown = require('../../../shared/naturalcrit/markdown.js');
const CodeEditor = require('naturalcrit/codeEditor/codeEditor.jsx'); const CodeEditor = require('naturalcrit/codeEditor/codeEditor.jsx');
const SnippetBar = require('./snippetbar/snippetbar.jsx'); const SnippetBar = require('./snippetbar/snippetbar.jsx');

View File

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

View File

@@ -3,10 +3,10 @@ require('./metadataEditor.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
import request from '../../utils/request-middleware.js'; const request = require('../../utils/request-middleware.js');
const Nav = require('naturalcrit/nav/nav.jsx'); const Nav = require('naturalcrit/nav/nav.jsx');
const Combobox = require('client/components/combobox.jsx'); const Combobox = require('client/components/combobox.jsx');
const TagInput = require('../tagInput/tagInput.jsx'); const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx');
const Themes = require('themes/themes.json'); const Themes = require('themes/themes.json');
@@ -341,11 +341,10 @@ const MetadataEditor = createClass({
{this.renderThumbnail()} {this.renderThumbnail()}
</div> </div>
<TagInput label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]} <StringArrayEditor label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
placeholder='add tag' unique={true} placeholder='add tag' unique={true}
values={this.props.metadata.tags} values={this.props.metadata.tags}
onChange={(e)=>this.handleFieldChange('tags', e)} onChange={(e)=>this.handleFieldChange('tags', e)}/>
/>
<div className='field systems'> <div className='field systems'>
<label>systems</label> <label>systems</label>
@@ -364,13 +363,12 @@ const MetadataEditor = createClass({
{this.renderAuthors()} {this.renderAuthors()}
<TagInput label='invited authors' valuePatterns={[/.+/]} <StringArrayEditor label='invited authors' valuePatterns={[/.+/]}
validators={[(v)=>!this.props.metadata.authors?.includes(v)]} validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
placeholder='invite author' unique={true} placeholder='invite author' unique={true}
values={this.props.metadata.invitedAuthors} values={this.props.metadata.invitedAuthors}
notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']} notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']}
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)} onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}/>
/>
<h2>Privacy</h2> <h2>Privacy</h2>

View File

@@ -79,7 +79,6 @@
text-overflow : ellipsis; text-overflow : ellipsis;
} }
button { button {
.colorButton();
padding : 0px 5px; padding : 0px 5px;
color : white; color : white;
background-color : black; background-color : black;
@@ -139,16 +138,16 @@
margin-bottom : 15px; margin-bottom : 15px;
button { width : 100%; } button { width : 100%; }
button.publish { button.publish {
.colorButton(@blueLight); .button(@blueLight);
} }
button.unpublish { button.unpublish {
.colorButton(@silver); .button(@silver);
} }
} }
.delete.field .value { .delete.field .value {
button { button {
.colorButton(@red); .button(@red);
} }
} }
.authors.field .value { .authors.field .value {
@@ -272,7 +271,7 @@
&:last-child { border-radius : 0 0.5em 0.5em 0; } &:last-child { border-radius : 0 0.5em 0.5em 0; }
} }
.tag { .badge {
padding : 0.3em; padding : 0.3em;
margin : 2px; margin : 2px;
font-size : 0.9em; font-size : 0.9em;

View File

@@ -150,22 +150,18 @@ 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 <div className='snippets'> return _.map(snippets, (snippetGroup)=>{
{_.map(snippets, (snippetGroup)=>{ return <SnippetGroup
return <SnippetGroup brew={this.props.brew}
brew={this.props.brew} groupName={snippetGroup.groupName}
groupName={snippetGroup.groupName} icon={snippetGroup.icon}
icon={snippetGroup.icon} snippets={snippetGroup.snippets}
snippets={snippetGroup.snippets} key={snippetGroup.groupName}
key={snippetGroup.groupName} onSnippetClick={this.handleSnippetClick}
onSnippetClick={this.handleSnippetClick} cursorPos={this.props.cursorPos}
cursorPos={this.props.cursorPos} />;
/>; });
})
}
</div>;
}, },
replaceContent : function(item){ replaceContent : function(item){
@@ -207,59 +203,58 @@ const Snippetbar = createClass({
renderEditorButtons : function(){ renderEditorButtons : function(){
if(!this.props.showEditButtons) return; if(!this.props.showEditButtons) return;
let foldButtons;
if(this.props.view == 'text'){
foldButtons =
<>
<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>
</>;
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' />
{ 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 className='codeTools'>
<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 className='tabs'> return <div className='editors'>
<div className={cx('text', { selected: this.props.view === 'text' })} <div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={()=>this.props.onViewChange('text')}> onClick={this.toggleHistoryMenu} >
<i className='fa fa-beer' /> <i className='fas fa-clock-rotate-left' />
</div> { this.state.showHistory && this.renderHistoryItems() }
<div className={cx('style', { selected: this.props.view === 'style' })} </div>
onClick={()=>this.props.onViewChange('style')}> <div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
<i className='fa fa-paint-brush' /> onClick={this.props.undo} >
</div> <i className='fas fa-undo' />
<div className={cx('meta', { selected: this.props.view === 'meta' })} </div>
onClick={()=>this.props.onViewChange('meta')}> <div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
<i className='fas fa-info-circle' /> onClick={this.props.redo} >
</div> <i className='fas fa-redo' />
</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> <div className='divider'></div>
) <div className={cx('text', { selected: this.props.view === 'text' })}
onClick={()=>this.props.onViewChange('text')}>
<i className='fa fa-beer' />
</div>
<div className={cx('style', { selected: this.props.view === 'style' })}
onClick={()=>this.props.onViewChange('style')}>
<i className='fa fa-paint-brush' />
</div>
<div className={cx('meta', { selected: this.props.view === 'meta' })}
onClick={()=>this.props.onViewChange('meta')}>
<i className='fas fa-info-circle' />
</div>
</div>;
}, },
render : function(){ render : function(){
@@ -296,9 +291,8 @@ 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${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span> <span className='name'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,119 +4,97 @@
.snippetBar { .snippetBar {
@menuHeight : 25px; @menuHeight : 25px;
position : relative; position : relative;
display : flex; height : @menuHeight;
flex-wrap : wrap-reverse;
justify-content : space-between;
height : auto;
color : black; color : black;
background-color : #DDDDDD; background-color : #DDDDDD;
.snippets {
display : flex;
justify-content : flex-start;
min-width : 327.58px;
}
.editors { .editors {
position : absolute;
top : 0px;
right : 0px;
display : flex; display : flex;
justify-content : flex-end; justify-content : space-between;
min-width : 225px; height : @menuHeight;
& > div {
&:only-child { margin-left : auto;min-width:unset;} width : @menuHeight;
height : @menuHeight;
>div { line-height : @menuHeight;
display : flex; text-align : center;
flex : 1; cursor : pointer;
justify-content : space-around; &:hover,&.selected { background-color : #999999; }
&.text {
&:first-child { border-left : none; } .tooltipLeft('Brew Editor');
}
& > div { &.style {
position : relative; .tooltipLeft('Style Editor');
width : @menuHeight; }
height : @menuHeight; &.meta {
line-height : @menuHeight; .tooltipLeft('Properties');
text-align : center; }
cursor : pointer; &.undo {
.tooltipLeft('Undo');
&.editorTool:not(.active) { font-size : 0.75em;
cursor:not-allowed; 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 : 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{
&:hover,&.selected { background-color : #999999; } right : -1px;
&.text { &>.snippet{
.tooltipLeft('Brew Editor'); padding-right : 10px;
}
&.style {
.tooltipLeft('Style Editor');
}
&.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 { }
.tooltipLeft('Editor Themes'); &.editorTheme {
font-size : 0.75em; .tooltipLeft('Editor Themes');
color : black; font-size : 0.75em;
&.active { color : black;
position : relative; &.active {
background-color : #999999; position : relative;
} background-color : #999999;
}
&.divider {
width : 5px;
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
&:hover { background-color : inherit; }
} }
} }
.themeSelector { &.divider {
position : absolute; width : 5px;
top : 25px; background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
right : 0; &:hover { background-color : inherit; }
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;
@@ -126,7 +104,6 @@
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 {
@@ -143,7 +120,7 @@
.tooltipLeft('Edit Brew Properties'); .tooltipLeft('Edit Brew Properties');
} }
.snippetGroup { .snippetGroup {
border-right : 1px solid currentColor;
&:hover { &:hover {
& > .dropdown { visibility : visible; } & > .dropdown { visibility : visible; }
} }
@@ -165,11 +142,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;
text-align : center; min-width: 25px;
text-align: center;
& ~ i { & ~ i {
margin-right : 0; margin-right : 0;
margin-left : 5px; margin-left : 5px;
@@ -202,7 +179,6 @@
} }
} }
.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;
@@ -228,19 +204,4 @@
} }
} }
} }
} }
@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

@@ -0,0 +1,149 @@
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const StringArrayEditor = createClass({
displayName : 'StringArrayEditor',
getDefaultProps : function() {
return {
label : '',
values : [],
valuePatterns : null,
validators : [],
placeholder : '',
notes : [],
unique : false,
cannotEdit : [],
onChange : ()=>{}
};
},
getInitialState : function() {
return {
valueContext : !!this.props.values ? this.props.values.map((value)=>({
value,
editing : false
})) : [],
temporaryValue : '',
updateValue : ''
};
},
componentDidUpdate : function(prevProps) {
if(!_.eq(this.props.values, prevProps.values)) {
this.setState({
valueContext : this.props.values ? this.props.values.map((newValue)=>({
value : newValue,
editing : this.state.valueContext.find(({ value })=>value === newValue)?.editing || false
})) : []
});
}
},
handleChange : function(value) {
this.props.onChange({
target : {
value
}
});
},
addValue : function(value){
this.handleChange(_.uniq([...this.props.values, value]));
this.setState({
temporaryValue : ''
});
},
removeValue : function(index){
this.handleChange(this.props.values.filter((_, i)=>i !== index));
},
updateValue : function(value, index){
const valueContext = this.state.valueContext;
valueContext[index].value = value;
valueContext[index].editing = false;
this.handleChange(valueContext.map((context)=>context.value));
this.setState({ valueContext, updateValue: '' });
},
editValue : function(index){
if(!!this.props.cannotEdit && this.props.cannotEdit.includes(this.props.values[index])) {
return;
}
const valueContext = this.state.valueContext.map((context, i)=>{
context.editing = index === i;
return context;
});
this.setState({ valueContext, updateValue: this.props.values[index] });
},
valueIsValid : function(value, index) {
const values = _.clone(this.props.values);
if(index !== undefined) {
values.splice(index, 1);
}
const matchesPatterns = !this.props.valuePatterns || this.props.valuePatterns.some((pattern)=>!!(value || '').match(pattern));
const uniqueIfSet = !this.props.unique || !values.includes(value);
const passesValidators = !this.props.validators || this.props.validators.every((validator)=>validator(value));
return matchesPatterns && uniqueIfSet && passesValidators;
},
handleValueInputKeyDown : function(event, index) {
if(event.key === 'Enter') {
if(this.valueIsValid(event.target.value, index)) {
if(index !== undefined) {
this.updateValue(event.target.value, index);
} else {
this.addValue(event.target.value);
}
}
} else if(event.key === 'Escape') {
this.closeEditInput(index);
}
},
closeEditInput : function(index) {
const valueContext = this.state.valueContext;
valueContext[index].editing = false;
this.setState({ valueContext, updateValue: '' });
},
render : function() {
const valueElements = Object.values(this.state.valueContext).map((context, i)=>context.editing
? <React.Fragment key={i}>
<div className='input-group'>
<input type='text' className={`value ${this.valueIsValid(this.state.updateValue, i) ? '' : 'invalid'}`} autoFocus placeholder={this.props.placeholder}
value={this.state.updateValue}
onKeyDown={(e)=>this.handleValueInputKeyDown(e, i)}
onChange={(e)=>this.setState({ updateValue: e.target.value })}/>
{<div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.closeEditInput(i); }}><i className='fa fa-undo fa-fw'/></div>}
{this.valueIsValid(this.state.updateValue, i) ? <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.updateValue(this.state.updateValue, i); }}><i className='fa fa-check fa-fw'/></div> : null}
</div>
</React.Fragment>
: <div className='badge' key={i} onClick={()=>this.editValue(i)}>{context.value}
{!!this.props.cannotEdit && this.props.cannotEdit.includes(context.value) ? null : <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.removeValue(i); }}><i className='fa fa-times fa-fw'/></div>}
</div>
);
return <div className='field'>
<label>{this.props.label}</label>
<div style={{ flex: '1 0' }} className='value'>
<div className='list'>
{valueElements}
<div className='input-group'>
<input type='text' className={`value ${this.valueIsValid(this.state.temporaryValue) ? '' : 'invalid'}`} placeholder={this.props.placeholder}
value={this.state.temporaryValue}
onKeyDown={(e)=>this.handleValueInputKeyDown(e)}
onChange={(e)=>this.setState({ temporaryValue: e.target.value })}/>
{this.valueIsValid(this.state.temporaryValue) ? <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.addValue(this.state.temporaryValue); }}><i className='fa fa-check fa-fw'/></div> : null}
</div>
</div>
{this.props.notes ? this.props.notes.map((n, index)=><p key={index}><small>{n}</small></p>) : null}
</div>
</div>;
}
});
module.exports = StringArrayEditor;

View File

@@ -1,105 +0,0 @@
require('./tagInput.less');
const React = require('react');
const { useState, useEffect } = React;
const _ = require('lodash');
const TagInput = ({ unique = true, values = [], ...props }) => {
const [tempInputText, setTempInputText] = useState('');
const [tagList, setTagList] = useState(values.map((value) => ({ value, editing: false })));
useEffect(()=>{
handleChange(tagList.map((context)=>context.value))
}, [tagList])
const handleChange = (value)=>{
props.onChange({
target : { value }
})
};
const handleInputKeyDown = ({ evt, value, index, options = {} }) => {
if (_.includes(['Enter', ','], evt.key)) {
evt.preventDefault();
submitTag(evt.target.value, value, index);
if (options.clear) {
setTempInputText('');
}
}
};
const submitTag = (newValue, originalValue, index) => {
setTagList((prevContext) => {
// remove existing tag
if(newValue === null){
return [...prevContext].filter((context, i)=>i !== index);
}
// add new tag
if(originalValue === null){
return [...prevContext, { value: newValue, editing: false }]
}
// update existing tag
return prevContext.map((context, i) => {
if (i === index) {
return { ...context, value: newValue, editing: false };
}
return context;
});
});
};
const editTag = (index) => {
setTagList((prevContext) => {
return prevContext.map((context, i) => {
if (i === index) {
return { ...context, editing: true };
}
return { ...context, editing: false };
});
});
};
const renderReadTag = (context, index) => {
return (
<li key={index}
data-value={context.value}
className='tag'
onClick={() => editTag(index)}>
{context.value}
<button onClick={(evt)=>{evt.stopPropagation(); submitTag(null, context.value, index)}}><i className='fa fa-times fa-fw'/></button>
</li>
);
};
const renderWriteTag = (context, index) => {
return (
<input type='text'
key={index}
defaultValue={context.value}
onKeyDown={(evt) => handleInputKeyDown({evt, value: context.value, index: index})}
autoFocus
/>
);
};
return (
<div className='field'>
<label>{props.label}</label>
<div className='value'>
<ul className='list'>
{tagList.map((context, index) => { return context.editing ? renderWriteTag(context, index) : renderReadTag(context, index); })}
</ul>
<input
type='text'
className='value'
placeholder={props.placeholder}
value={tempInputText}
onChange={(e) => setTempInputText(e.target.value)}
onKeyDown={(evt) => handleInputKeyDown({ evt, value: null, options: { clear: true } })}
/>
</div>
</div>
);
};
module.exports = TagInput;

View File

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

View File

@@ -25,11 +25,12 @@
.homebrew nav { .homebrew nav {
background-color : #333333; background-color : #333333;
position : relative; .navContent {
z-index : 2; position : relative;
display : flex; z-index : 2;
justify-content : space-between; display : flex;
justify-content : space-between;
}
.navSection { .navSection {
display : flex; display : flex;
align-items : center; align-items : center;

View File

@@ -2,7 +2,7 @@ require('./brewItem.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const moment = require('moment'); const moment = require('moment');
import request from '../../../../utils/request-middleware.js'; const request = require('../../../../utils/request-middleware.js');
const googleDriveIcon = require('../../../../googleDrive.svg'); const googleDriveIcon = require('../../../../googleDrive.svg');
const homebreweryIcon = require('../../../../thumbnail.png'); const homebreweryIcon = require('../../../../thumbnail.png');

View File

@@ -4,7 +4,7 @@ const React = require('react');
const _ = require('lodash'); const _ = require('lodash');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
import request from '../../utils/request-middleware.js'; const request = require('../../utils/request-middleware.js');
const { Meta } = require('vitreum/headtags'); const { Meta } = require('vitreum/headtags');
const Nav = require('naturalcrit/nav/nav.jsx'); const Nav = require('naturalcrit/nav/nav.jsx');
@@ -16,7 +16,6 @@ const PrintNavItem = require('../../navbar/print.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx'); const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const Account = require('../../navbar/account.navitem.jsx'); const Account = require('../../navbar/account.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both; const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const VaultNavItem = require('../../navbar/vault.navitem.jsx');
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx'); const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx'); const Editor = require('../../editor/editor.jsx');
@@ -24,7 +23,7 @@ const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
const LockNotification = require('./lockNotification/lockNotification.jsx'); const LockNotification = require('./lockNotification/lockNotification.jsx');
import Markdown from 'naturalcrit/markdown.js'; const Markdown = require('naturalcrit/markdown.js');
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js'); const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js'); const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
@@ -229,8 +228,8 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text) htmlErrors : Markdown.validate(prevState.brew.text)
})); }));
await updateHistory(this.state.brew).catch(console.error); await updateHistory(this.state.brew);
await versionHistoryGarbageCollection().catch(console.error); await versionHistoryGarbageCollection();
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
@@ -381,7 +380,7 @@ const EditPage = createClass({
**[Homebrewery Link](${global.config.publicUrl}/share/${shareLink})**`; **[Homebrewery Link](${global.config.publicUrl}/share/${shareLink})**`;
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title.toWellFormed())}&text=${encodeURIComponent(text)}`; return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title)}&text=${encodeURIComponent(text)}`;
}, },
renderNavbar : function(){ renderNavbar : function(){
@@ -418,7 +417,6 @@ const EditPage = createClass({
</Nav.item> </Nav.item>
</Nav.dropdown> </Nav.dropdown>
<PrintNavItem /> <PrintNavItem />
<VaultNavItem />
<RecentNavItem brew={this.state.brew} storageKey='edit' /> <RecentNavItem brew={this.state.brew} storageKey='edit' />
<Account /> <Account />
</Nav.section> </Nav.section>
@@ -431,41 +429,41 @@ const EditPage = createClass({
<Meta name='robots' content='noindex, nofollow' /> <Meta name='robots' content='noindex, nofollow' />
{this.renderNavbar()} {this.renderNavbar()}
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />} <div className='content'>
<div className="content"> {this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
<SplitPane onDragFinish={this.handleSplitMove}> <SplitPane onDragFinish={this.handleSplitMove}>
<Editor <Editor
ref={this.editor} ref={this.editor}
brew={this.state.brew} brew={this.state.brew}
onTextChange={this.handleTextChange} onTextChange={this.handleTextChange}
onStyleChange={this.handleStyleChange} onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange} onMetaChange={this.handleMetaChange}
reportError={this.errorReported} reportError={this.errorReported}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
userThemes={this.props.userThemes} userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets} snippetBundle={this.state.themeBundle.snippets}
updateBrew={this.updateBrew} updateBrew={this.updateBrew}
onCursorPageChange={this.handleEditorCursorPageChange} onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange} onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/> />
<BrewRenderer <BrewRenderer
text={this.state.brew.text} text={this.state.brew.text}
style={this.state.brew.style} style={this.state.brew.style}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
theme={this.state.brew.theme} theme={this.state.brew.theme}
themeBundle={this.state.themeBundle} themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors} errors={this.state.htmlErrors}
lang={this.state.brew.lang} lang={this.state.brew.lang}
onPageChange={this.handleBrewRendererPageChange} onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
allowPrint={true} allowPrint={true}
/> />
</SplitPane> </SplitPane>
</div> </div>
</div>; </div>;
} }

View File

@@ -1,7 +1,7 @@
require('./errorPage.less'); require('./errorPage.less');
const React = require('react'); const React = require('react');
const UIPage = require('../basePages/uiPage/uiPage.jsx'); const UIPage = require('../basePages/uiPage/uiPage.jsx');
import Markdown from '../../../../shared/naturalcrit/markdown.js'; const Markdown = require('../../../../shared/naturalcrit/markdown.js');
const ErrorIndex = require('./errors/errorIndex.js'); const ErrorIndex = require('./errors/errorIndex.js');
const ErrorPage = ({ brew })=>{ const ErrorPage = ({ brew })=>{

View File

@@ -2,7 +2,7 @@ require('./homePage.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const cx = require('classnames'); const cx = require('classnames');
import request from '../../utils/request-middleware.js'; const request = require('../../utils/request-middleware.js');
const { Meta } = require('vitreum/headtags'); const { Meta } = require('vitreum/headtags');
const Nav = require('naturalcrit/nav/nav.jsx'); const Nav = require('naturalcrit/nav/nav.jsx');
@@ -100,33 +100,35 @@ const HomePage = createClass({
return <div className='homePage sitePage'> return <div className='homePage sitePage'>
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' /> <Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
{this.renderNavbar()} {this.renderNavbar()}
<div className="content">
<SplitPane onDragFinish={this.handleSplitMove}> <div className='content'>
<Editor <SplitPane onDragFinish={this.handleSplitMove}>
ref={this.editor} <Editor
brew={this.state.brew} ref={this.editor}
onTextChange={this.handleTextChange} brew={this.state.brew}
renderer={this.state.brew.renderer} onTextChange={this.handleTextChange}
showEditButtons={false} renderer={this.state.brew.renderer}
snippetBundle={this.state.themeBundle.snippets} showEditButtons={false}
onCursorPageChange={this.handleEditorCursorPageChange} snippetBundle={this.state.themeBundle.snippets}
onViewPageChange={this.handleEditorViewPageChange} onCursorPageChange={this.handleEditorCursorPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum} onViewPageChange={this.handleEditorViewPageChange}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
/> currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
<BrewRenderer />
text={this.state.brew.text} <BrewRenderer
style={this.state.brew.style} text={this.state.brew.text}
renderer={this.state.brew.renderer} style={this.state.brew.style}
onPageChange={this.handleBrewRendererPageChange} renderer={this.state.brew.renderer}
currentEditorViewPageNum={this.state.currentEditorViewPageNum} onPageChange={this.handleBrewRendererPageChange}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
themeBundle={this.state.themeBundle} currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/> themeBundle={this.state.themeBundle}
</SplitPane> />
</SplitPane>
</div> </div>
<div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}> <div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}>
Save current <i className='fas fa-save' /> Save current <i className='fas fa-save' />
</div> </div>

View File

@@ -91,6 +91,13 @@ If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](
\page \page
## Markdown+ ## Markdown+
The Homebrewery aims to make homebrewing as simple as possible, providing a live editor with Markdown syntax that is more human-readable and faster to write with than raw HTML. The Homebrewery aims to make homebrewing as simple as possible, providing a live editor with Markdown syntax that is more human-readable and faster to write with than raw HTML.

View File

@@ -2,9 +2,9 @@
require('./newPage.less'); require('./newPage.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
import request from '../../utils/request-middleware.js'; const request = require('../../utils/request-middleware.js');
import Markdown from 'naturalcrit/markdown.js'; const Markdown = require('naturalcrit/markdown.js');
const Nav = require('naturalcrit/nav/nav.jsx'); const Nav = require('naturalcrit/nav/nav.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx'); const PrintNavItem = require('../../navbar/print.navitem.jsx');
@@ -223,38 +223,38 @@ const NewPage = createClass({
render : function(){ render : function(){
return <div className='newPage sitePage'> return <div className='newPage sitePage'>
{this.renderNavbar()} {this.renderNavbar()}
<div className="content"> <div className='content'>
<SplitPane onDragFinish={this.handleSplitMove}> <SplitPane onDragFinish={this.handleSplitMove}>
<Editor <Editor
ref={this.editor} ref={this.editor}
brew={this.state.brew} brew={this.state.brew}
onTextChange={this.handleTextChange} onTextChange={this.handleTextChange}
onStyleChange={this.handleStyleChange} onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange} onMetaChange={this.handleMetaChange}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
userThemes={this.props.userThemes} userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets} snippetBundle={this.state.themeBundle.snippets}
onCursorPageChange={this.handleEditorCursorPageChange} onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange} onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/> />
<BrewRenderer <BrewRenderer
text={this.state.brew.text} text={this.state.brew.text}
style={this.state.brew.style} style={this.state.brew.style}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
theme={this.state.brew.theme} theme={this.state.brew.theme}
themeBundle={this.state.themeBundle} themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors} errors={this.state.htmlErrors}
lang={this.state.brew.lang} lang={this.state.brew.lang}
onPageChange={this.handleBrewRendererPageChange} onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
allowPrint={true} allowPrint={true}
/> />
</SplitPane> </SplitPane>
</div> </div>
</div>; </div>;
} }

View File

@@ -8,7 +8,6 @@ const Navbar = require('../../navbar/navbar.jsx');
const MetadataNav = require('../../navbar/metadata.navitem.jsx'); const MetadataNav = require('../../navbar/metadata.navitem.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx'); const PrintNavItem = require('../../navbar/print.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both; const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const VaultNavItem = require('../../navbar/vault.navitem.jsx');
const Account = require('../../navbar/account.navitem.jsx'); const Account = require('../../navbar/account.navitem.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx'); const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
@@ -111,7 +110,6 @@ const SharePage = createClass({
</Nav.item> </Nav.item>
</Nav.dropdown> </Nav.dropdown>
</>} </>}
<VaultNavItem/>
<RecentNavItem brew={this.props.brew} storageKey='view' /> <RecentNavItem brew={this.props.brew} storageKey='view' />
<Account /> <Account />
</Nav.section> </Nav.section>

View File

@@ -1,5 +1,5 @@
.sharePage{ .sharePage{
nav .navSection.titleSection { .navContent .navSection.titleSection {
flex-grow: 1; flex-grow: 1;
justify-content: center; justify-content: center;
} }

View File

@@ -1,11 +1,12 @@
const React = require('react'); const React = require('react');
const { useState } = React; const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
const ListPage = require('../basePages/listPage/listPage.jsx'); const ListPage = require('../basePages/listPage/listPage.jsx');
const Nav = require('naturalcrit/nav/nav.jsx'); const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx'); const Navbar = require('../../navbar/navbar.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both; const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.navitem.jsx'); const Account = require('../../navbar/account.navitem.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx'); const NewBrew = require('../../navbar/newbrew.navitem.jsx');
@@ -13,48 +14,69 @@ const HelpNavItem = require('../../navbar/help.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx'); const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const VaultNavitem = require('../../navbar/vault.navitem.jsx'); const VaultNavitem = require('../../navbar/vault.navitem.jsx');
const UserPage = (props)=>{ const UserPage = createClass({
props = { displayName : 'UserPage',
username : '', getDefaultProps : function() {
brews : [], return {
query : '', username : '',
...props brews : [],
}; query : '',
error : null
};
},
getInitialState : function() {
const usernameWithS = this.props.username + (this.props.username.endsWith('s') ? `` : `s`);
const [error, setError] = useState(null); const brews = _.groupBy(this.props.brews, (brew)=>{
return (brew.published ? 'published' : 'private');
});
const usernameWithS = props.username + (props.username.endsWith('s') ? `` : `s`); const brewCollection = [
const groupedBrews = _.groupBy(props.brews, (brew)=>brew.published ? 'published' : 'private'); {
title : `${usernameWithS} published brews`,
class : 'published',
brews : brews.published
}
];
if(this.props.username == global.account?.username){
brewCollection.push(
{
title : `${usernameWithS} unpublished brews`,
class : 'unpublished',
brews : brews.private
}
);
}
const brewCollection = [ return {
{ brewCollection : brewCollection
title : `${usernameWithS} published brews`, };
class : 'published', },
brews : groupedBrews.published || [] errorReported : function(error) {
}, this.setState({
...(props.username === global.account?.username ? [{ error
title : `${usernameWithS} unpublished brews`, });
class : 'unpublished', },
brews : groupedBrews.private || []
}] : [])
];
const navItems = ( navItems : function() {
<Navbar> return <Navbar>
<Nav.section> <Nav.section>
{error && (<ErrorNavItem error={error} parent={null}></ErrorNavItem>)} {this.state.error ?
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
null
}
<NewBrew /> <NewBrew />
<HelpNavItem /> <HelpNavItem />
<VaultNavitem /> <VaultNavitem/>
<RecentNavItem /> <RecentNavItem />
<Account /> <Account />
</Nav.section> </Nav.section>
</Navbar> </Navbar>;
); },
return ( render : function(){
<ListPage brewCollection={brewCollection} navItems={navItems} query={props.query} reportError={(err)=>setError(err)} /> return <ListPage brewCollection={this.state.brewCollection} navItems={this.navItems()} query={this.props.query} reportError={this.errorReported}></ListPage>;
); }
}; });
module.exports = UserPage; module.exports = UserPage;

View File

@@ -15,7 +15,7 @@ const BrewItem = require('../basePages/listPage/brewItem/brewItem.jsx');
const SplitPane = require('../../../../shared/naturalcrit/splitPane/splitPane.jsx'); const SplitPane = require('../../../../shared/naturalcrit/splitPane/splitPane.jsx');
const ErrorIndex = require('../errorPage/errors/errorIndex.js'); const ErrorIndex = require('../errorPage/errors/errorIndex.js');
import request from '../../utils/request-middleware.js'; const request = require('../../utils/request-middleware.js');
const VaultPage = (props)=>{ const VaultPage = (props)=>{
const [pageState, setPageState] = useState(parseInt(props.query.page) || 1); const [pageState, setPageState] = useState(parseInt(props.query.page) || 1);
@@ -411,18 +411,19 @@ const VaultPage = (props)=>{
}; };
return ( return (
<div className='sitePage vaultPage'> <div className='vaultPage'>
<link href='/themes/V3/Blank/style.css' rel='stylesheet' /> <link href='/themes/V3/Blank/style.css' rel='stylesheet' />
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet' /> <link href='/themes/V3/5ePHB/style.css' rel='stylesheet' />
{renderNavItems()} {renderNavItems()}
<div className="content"> <div className='content'>
<SplitPane showDividerButtons={false}> <SplitPane showDividerButtons={false}>
<div className='form dataGroup'>{renderForm()}</div> <div className='form dataGroup'>{renderForm()}</div>
<div className='resultsContainer dataGroup'>
{renderSortBar()} <div className='resultsContainer dataGroup'>
{renderFoundBrews()} {renderSortBar()}
</div> {renderFoundBrews()}
</SplitPane> </div>
</SplitPane>
</div> </div>
</div> </div>
); );

View File

@@ -5,331 +5,373 @@
*:not(input) { user-select : none; } *:not(input) { user-select : none; }
.content .dataGroup { .content {
width : 100%;
height : 100%; height : 100%;
background : white; background : #2C3E50;
&.form .brewLookup { .dataGroup {
position : relative; width : 100%;
padding : 50px clamp(20px, 4vw, 50px); height : 100%;
background : white;
small { &.form .brewLookup {
font-size : 10pt; position : relative;
color : #555555; padding : 50px clamp(20px, 4vw, 50px);
small {
font-size : 10pt;
color : #555555;
a { color : #333333; } a { color : #333333; }
} }
code { code {
padding-inline : 5px; padding-inline : 5px;
font-family : monospace; font-family : monospace;
background : lightgrey; background : lightgrey;
border-radius : 5px; border-radius : 5px;
} }
h1, h2, h3, h4 { h1, h2, h3, h4 {
font-family : 'CodeBold'; font-family : 'CodeBold';
letter-spacing : 2px; letter-spacing : 2px;
} }
legend { legend {
h3 { h3 {
margin-block : 30px 20px; margin-block : 30px 20px;
font-size : 20px; font-size : 20px;
text-align : center;
border-bottom : 2px solid;
}
ul {
padding-inline : 30px 10px;
li {
margin-block : 5px;
line-height : calc(1em + 5px);
list-style : disc;
}
}
}
&::after {
position : absolute;
top : 0;
right : 0;
left : 0;
display : block;
padding : 10px;
font-weight : 900;
color : white;
white-space : pre-wrap;
content : 'Error:\A At least one renderer should be enabled to make a search';
background : rgb(255, 60, 60);
opacity : 0;
transition : opacity 0.5s;
}
&:not(:has(input[type='checkbox']:checked))::after { opacity : 1; }
.formTitle {
margin : 20px 0;
font-size : 30px;
color : black;
text-align : center; text-align : center;
border-bottom : 2px solid; border-bottom : 2px solid;
} }
ul {
padding-inline : 30px 10px; .formContents {
li { position : relative;
margin-block : 5px; display : flex;
line-height : calc(1em + 5px); flex-direction : column;
list-style : disc;
label {
display : flex;
align-items : center;
margin : 10px 0;
} }
} select { margin : 0 10px; }
}
&::after { input {
position : absolute; margin : 0 10px;
top : 0;
right : 0;
left : 0;
display : block;
padding : 10px;
font-weight : 900;
color : white;
white-space : pre-wrap;
content : 'Error:\A At least one renderer should be enabled to make a search';
background : rgb(255, 60, 60);
opacity : 0;
transition : opacity 0.5s;
}
&:not(:has(input[type='checkbox']:checked))::after { opacity : 1; }
.formTitle { &:invalid { background : rgb(255, 188, 181); }
margin : 20px 0;
font-size : 30px;
color : black;
text-align : center;
border-bottom : 2px solid;
}
.formContents {
position : relative;
display : flex;
flex-direction : column;
label {
display : flex;
align-items : center;
margin : 10px 0;
}
select { margin : 0 10px; }
input {
margin : 0 10px;
&: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;
#searchButton { &::before,&::after {
.colorButton(@green); position : absolute;
position : absolute; inset : 0;
right : 20px; z-index : 5;
bottom : 0; padding-top : 2px;
text-align : center;
i {
margin-left : 10px;
animation-duration : 1000s;
}
}
}
}
&.resultsContainer {
display : flex;
flex-direction : column;
height : 100%;
overflow-y : auto;
font-size : 0.34cm;
h3 {
font-family : 'Open Sans';
font-weight : 900;
color : white;
}
.sort-container {
display : flex;
flex-wrap : wrap;
column-gap : 15px;
justify-content : center;
height : 30px;
color : white;
background-color : #555555;
border-top : 1px solid #666666;
border-bottom : 1px solid #666666;
.sort-option {
display : flex;
align-items : center;
padding : 0 8px;
&:hover { background-color : #444444; }
&.active {
background-color : #333333;
button {
font-weight : 800;
color : white;
& + .sortDir { padding-left : 5px; }
}
}
button {
padding : 0;
font-size : 11px;
font-weight : normal;
color : #CCCCCC;
text-transform : uppercase;
background-color : transparent;
&:hover { background : none; }
}
}
}
.foundBrews {
position : relative;
width : 100%;
height : 100%;
max-height : 100%;
padding : 50px 50px 70px 50px;
overflow-y : scroll;
background-color : #2C3E50;
h3 { font-size : 25px; }
&.noBrews {
display : grid;
place-items : center;
color : white;
}
&.searching {
display : grid;
place-items : center;
color : white;
h3 { position : relative; }
h3.searchAnim::after {
position : absolute;
top : 50%;
right : 0;
width : max-content;
height : 1em;
content : '';
translate : calc(100% + 5px) -50%;
animation : trailingDots 2s ease infinite;
}
}
.totalBrews {
position : fixed;
right : 0;
bottom : 0;
z-index : 1000;
padding : 8px 10px;
font-family : 'Open Sans';
font-size : 11px;
font-weight : 800;
color : white;
background-color : #333333;
.searchAnim {
position : relative;
display : inline-block;
width : 3ch;
height : 1em;
}
.searchAnim::after {
position : absolute;
top : 50%;
right : 0;
width : max-content;
height : 1em;
content : '';
translate : -50% -50%;
animation : trailingDots 2s ease infinite;
}
}
.brewItem {
width : 47%;
margin-right : 40px;
color : black;
isolation : isolate;
&::after {
position : absolute;
inset : 0;
z-index : -2;
display : block;
content : '';
background-image : url('/assets/parchmentBackground.jpg');
}
&:nth-child(even of .brewItem) { margin-right : 0; }
h2 {
font-family : 'MrEavesRemake';
font-size : 0.75cm;
font-weight : 800;
line-height : 0.988em;
color : var(--HB_Color_HeaderText);
}
.info {
position : relative;
z-index : 2;
font-family : 'ScalySansRemake';
font-size : 1.2em;
>span {
margin-right : 12px;
line-height : 1.5em;
}
}
.links { z-index : 2; }
hr {
margin : 0px;
visibility : hidden;
}
.thumbnail { z-index : -1; }
}
.paginationControls {
position : absolute;
left : 50%;
display : grid;
grid-template-areas : 'previousPage currentPage nextPage';
grid-template-columns : 50px 1fr 50px;
place-items : center;
width : auto;
translate : -50%;
.pages {
display : flex;
grid-area : currentPage;
justify-content : space-evenly;
width : 100%;
height : 100%;
padding : 5px 8px;
text-align : center;
.pageNumber {
margin-inline : 1vw;
font-family : 'Open Sans';
font-weight : 900;
color : white;
text-underline-position : under;
text-wrap : nowrap;
cursor : pointer;
&.currentPage {
color : gold;
text-decoration : underline;
pointer-events : none;
} }
&.firstPage { margin-right : -5px; } &::before {
display : block;
content : 'No';
}
&.lastPage { margin-left : -5px; } &::after {
display : none;
content : 'Yes';
}
&:checked {
background : green;
&::before { display : none; }
&::after { display : block; }
}
} }
} }
button { #searchButton {
.colorButton(@green); position : absolute;
width : max-content; right : 20px;
bottom : 0;
&.previousPage { grid-area : previousPage; } i {
margin-left : 10px;
animation-duration : 1000s;
}
}
}
}
&.nextPage { grid-area : nextPage; } &.resultsContainer {
display : flex;
flex-direction : column;
height : 100%;
overflow-y : auto;
font-family : 'BookInsanityRemake';
font-size : 0.34cm;
h3 {
font-family : 'Open Sans';
font-weight : 900;
color : white;
}
.sort-container {
display : flex;
flex-wrap : wrap;
column-gap : 15px;
justify-content : center;
height : 30px;
color : white;
background-color : #555555;
border-top : 1px solid #666666;
border-bottom : 1px solid #666666;
.sort-option {
display : flex;
align-items : center;
padding : 0 8px;
&:hover { background-color : #444444; }
&.active {
background-color : #333333;
button {
font-weight : 800;
color : white;
& + .sortDir { padding-left : 5px; }
}
}
button {
padding : 0;
font-size : 11px;
font-weight : normal;
color : #CCCCCC;
text-transform : uppercase;
background-color : transparent;
&:hover { background : none; }
}
}
}
.foundBrews {
position : relative;
width : 100%;
height : 100%;
max-height : 100%;
padding : 50px 50px 70px 50px;
overflow-y : scroll;
background-color : #2C3E50;
h3 { font-size : 25px; }
&.noBrews {
display : grid;
place-items : center;
color : white;
} }
&.searching {
display : grid;
place-items : center;
color : white;
h3 { position : relative; }
h3.searchAnim::after {
position : absolute;
top : 50%;
right : 0;
width : max-content;
height : 1em;
content : '';
translate : calc(100% + 5px) -50%;
animation : trailingDots 2s ease infinite;
}
}
.totalBrews {
position : fixed;
right : 0;
bottom : 0;
z-index : 1000;
padding : 8px 10px;
font-family : 'Open Sans';
font-size : 11px;
font-weight : 800;
color : white;
background-color : #333333;
.searchAnim {
position : relative;
display : inline-block;
width : 3ch;
height : 1em;
}
.searchAnim::after {
position : absolute;
top : 50%;
right : 0;
width : max-content;
height : 1em;
content : '';
translate : -50% -50%;
animation : trailingDots 2s ease infinite;
}
}
.brewItem {
width : 47%;
margin-right : 40px;
color : black;
isolation : isolate;
&::after {
position : absolute;
inset : 0;
z-index : -2;
display : block;
content : '';
background-image : url('/assets/parchmentBackground.jpg');
}
&:nth-child(even of .brewItem) { margin-right : 0; }
h2 {
font-family : 'MrEavesRemake';
font-size : 0.75cm;
font-weight : 800;
line-height : 0.988em;
color : var(--HB_Color_HeaderText);
}
.info {
position : relative;
z-index : 2;
font-family : 'ScalySansRemake';
font-size : 1.2em;
>span {
margin-right : 12px;
line-height : 1.5em;
}
}
.links { z-index : 2; }
hr {
margin : 0px;
visibility : hidden;
}
.thumbnail { z-index : -1; }
}
.paginationControls {
position : absolute;
left : 50%;
display : grid;
grid-template-areas : 'previousPage currentPage nextPage';
grid-template-columns : 50px 1fr 50px;
place-items : center;
width : auto;
translate : -50%;
.pages {
display : flex;
grid-area : currentPage;
justify-content : space-evenly;
width : 100%;
height : 100%;
padding : 5px 8px;
text-align : center;
.pageNumber {
margin-inline : 1vw;
font-family : 'Open Sans';
font-weight : 900;
color : white;
text-underline-position : under;
text-wrap : nowrap;
cursor : pointer;
&.currentPage {
color : gold;
text-decoration : underline;
pointer-events : none;
}
&.firstPage { margin-right : -5px; }
&.lastPage { margin-left : -5px; }
}
}
button {
width : max-content;
&.previousPage { grid-area : previousPage; }
&.nextPage { grid-area : nextPage; }
}
}
} }
} }
} }
} }
} }
@keyframes trailingDots { @keyframes trailingDots {
@@ -346,7 +388,7 @@
// media query for when the page is smaller than 1079 px in width // media query for when the page is smaller than 1079 px in width
@media screen and (max-width : 1079px) { @media screen and (max-width : 1079px) {
.vaultPage { .vaultPage .content {
.dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; } .dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; }

View File

@@ -1,19 +0,0 @@
import * as IDB from 'idb-keyval/dist/index.js';
export function initCustomStore(db, store){
const createCustomStore = async ()=>IDB.createStore(db, store);
return {
entries : async ()=>IDB.entries(await createCustomStore()),
keys : async ()=>IDB.keys(await createCustomStore()),
values : async ()=>IDB.values(await createCustomStore()),
clear : async ()=>IDB.clear(await createCustomStore),
get : async (key)=>IDB.get(key, await createCustomStore()),
getMany : async (keys)=>IDB.getMany(keys, await createCustomStore()),
set : async (key, value)=>IDB.set(key, value, await createCustomStore()),
setMany : async (entries)=>IDB.setMany(entries, await createCustomStore()),
update : async (key, updateFn)=>IDB.update(key, updateFn, await createCustomStore()),
del : async (key)=>IDB.del(key, await createCustomStore()),
delMany : async (keys)=>IDB.delMany(keys, await createCustomStore())
};
};

View File

@@ -1,4 +1,4 @@
import request from 'superagent'; const request = require('superagent');
const addHeader = (request)=>request.set('Homebrewery-Version', global.version); const addHeader = (request)=>request.set('Homebrewery-Version', global.version);
@@ -9,4 +9,4 @@ const requestMiddleware = {
delete : (path)=>addHeader(request.delete(path)), delete : (path)=>addHeader(request.delete(path)),
}; };
export default requestMiddleware; module.exports = requestMiddleware;

View File

@@ -1,4 +1,4 @@
import { initCustomStore } from './customIDBStore.js'; import * as IDB from 'idb-keyval/dist/index.js';
export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY'; export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY';
export const HISTORY_SLOTS = 5; export const HISTORY_SLOTS = 5;
@@ -21,14 +21,12 @@ const HISTORY_SAVE_DELAYS = {
// '5' : 5 // '5' : 5
// }; // };
const GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
// const GARBAGE_COLLECT_DELAY = 10;
const HB_DB = 'HOMEBREWERY-DB'; const HB_DB = 'HOMEBREWERY-DB';
const HB_STORE = 'HISTORY'; const HB_STORE = 'HISTORY';
const IDB = initCustomStore(HB_DB, HB_STORE); const GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
// const GARBAGE_COLLECT_DELAY = 10;
function getKeyBySlot(brew, slot){ function getKeyBySlot(brew, slot){
// Return a string representing the key for this brew and history slot // Return a string representing the key for this brew and history slot
@@ -55,6 +53,11 @@ function parseBrewForStorage(brew, slot = 0) {
return [key, archiveBrew]; return [key, archiveBrew];
} }
// Create a custom IDB store
async function createHBStore(){
return await IDB.createStore(HB_DB, HB_STORE);
}
export async function loadHistory(brew){ export async function loadHistory(brew){
const DEFAULT_HISTORY_ITEM = { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true }; const DEFAULT_HISTORY_ITEM = { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true };
@@ -66,7 +69,7 @@ export async function loadHistory(brew){
}; };
// Load all keys from IDB at once // Load all keys from IDB at once
const dataArray = await IDB.getMany(historyKeys); const dataArray = await IDB.getMany(historyKeys, await createHBStore());
return dataArray.map((data)=>{ return data ?? DEFAULT_HISTORY_ITEM; }); return dataArray.map((data)=>{ return data ?? DEFAULT_HISTORY_ITEM; });
} }
@@ -94,7 +97,7 @@ export async function updateHistory(brew) {
// Update the most recent brew // Update the most recent brew
historyUpdate.push(parseBrewForStorage(brew, 1)); historyUpdate.push(parseBrewForStorage(brew, 1));
await IDB.setMany(historyUpdate); await IDB.setMany(historyUpdate, await createHBStore());
// Break out of data checks because we found an expired value // Break out of data checks because we found an expired value
break; break;
@@ -103,17 +106,14 @@ export async function updateHistory(brew) {
}; };
export async function versionHistoryGarbageCollection(){ export async function versionHistoryGarbageCollection(){
const entries = await IDB.entries();
const expiredKeys = []; const entries = await IDB.entries(await createHBStore());
for (const [key, value] of entries){ for (const [key, value] of entries){
const expireAt = new Date(value.savedAt); const expireAt = new Date(value.savedAt);
expireAt.setMinutes(expireAt.getMinutes() + GARBAGE_COLLECT_DELAY); expireAt.setMinutes(expireAt.getMinutes() + GARBAGE_COLLECT_DELAY);
if(new Date() > expireAt){ if(new Date() > expireAt){
expiredKeys.push(key); await IDB.del(key, await createHBStore());
}; };
}; };
if(expiredKeys.length > 0){
await IDB.delMany(expiredKeys);
}
}; };

View File

@@ -73,12 +73,3 @@
.fit-width { .fit-width {
mask-image: url('../icons/fit-width.svg'); mask-image: url('../icons/fit-width.svg');
} }
.single-spread {
mask-image: url('../icons/single-spread.svg');
}
.facing-spread {
mask-image: url('../icons/facing-spread.svg');
}
.flow-spread {
mask-image: url('../icons/flow-spread.svg');
}

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.979101,0,0,0.919064,-29.0748,1.98095)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.979101,0,0,0.919064,23.058,1.98095)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.0781,0,0,1.0781,-3.90545,-3.90502)">
<g transform="matrix(0.590052,0,0,0.553871,-13.8993,-2.19227)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.590052,0,0,0.553871,-13.8993,44.3152)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.590052,0,0,0.553871,17.5184,-2.19227)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.590052,0,0,0.553871,50.0095,-2.19227)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.590052,0,0,0.553871,17.5184,44.3152)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
<g transform="matrix(0.590052,0,0,0.553871,50.0095,44.3152)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.41826,0,0,1.3313,-26.7845,-19.5573)">
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 777 B

View File

@@ -8,8 +8,6 @@ const template = async function(name, title='', props = {}){
}); });
const ogMetaTags = ogTags.join('\n'); const ogMetaTags = ogTags.join('\n');
const ssrModule = await import(`../build/${name}/ssr.cjs`);
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -23,7 +21,7 @@ const template = async function(name, title='', props = {}){
<title>${title.length ? `${title} - The Homebrewery`: 'The Homebrewery - NaturalCrit'}</title> <title>${title.length ? `${title} - The Homebrewery`: 'The Homebrewery - NaturalCrit'}</title>
</head> </head>
<body> <body>
<main id="reactRoot">${ssrModule.default(props)}</main> <main id="reactRoot">${require(`../build/${name}/ssr.js`)(props)}</main>
<script src=${`/${name}/bundle.js`}></script> <script src=${`/${name}/bundle.js`}></script>
<script>start_app(${JSON.stringify(props)})</script> <script>start_app(${JSON.stringify(props)})</script>
</body> </body>
@@ -31,4 +29,4 @@ const template = async function(name, title='', props = {}){
`; `;
}; };
export default template; module.exports = template;

19
faq.md
View File

@@ -69,6 +69,7 @@ pre {
You can check the site status here: [Everyone or Just Me](https://downforeveryoneorjustme.com/homebrewery.naturalcrit.com) 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? ### 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. 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.
@@ -81,17 +82,12 @@ 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? ### Is there a way to restore a previous version of my brew?
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. 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.
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! ### 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. 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 \column
### Why is only Chrome supported? ### Why is only Chrome supported?
@@ -116,7 +112,10 @@ Once you have an image you would like to use, it is recommended to host it somew
\page \page
### A particular font does not work for my language, what do I do? ### 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. 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.
### I have white borders on the bottom/sides of the print preview. ### I have white borders on the bottom/sides of the print preview.
@@ -127,8 +126,4 @@ 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? ### 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. We recommend whitelisting homebrewery.naturalcrit.com in your ad-blocking software, as we have no ads. Your ad-blocking software is mistakenly assuming your text to be an ad. Whitelist homebrewery.naturalcrit.com in your ad-blocking software.
### 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.

3056
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,10 @@
{ {
"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.16.1", "version": "3.16.0",
"type": "module",
"engines": { "engines": {
"npm": "^10.2.x", "npm": "^10.2.x",
"node": "^20.18.x" "node": "^20.17.x"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -27,7 +26,6 @@
"test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose", "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:css": "jest \"server/.*.spec.js\" -t \"Get CSS\" --verbose",
"test:api-unit:notifications": "jest \"server/.*.spec.js\" -t \"Notifications\" --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:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch", "test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose", "test:basic": "jest tests/markdown/basic.test.js --verbose",
@@ -38,10 +36,8 @@
"test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace", "test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace",
"test:definition-lists": "jest tests/markdown/definition-lists.test.js --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: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:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
"test:route": "jest tests/routes/static-pages.test.js --verbose", "test:route": "jest tests/routes/static-pages.test.js --verbose",
"test:safehtml": "jest tests/html/safeHTML.test.js --verbose",
"phb": "node --experimental-require-module scripts/phb.js", "phb": "node --experimental-require-module scripts/phb.js",
"prod": "set NODE_ENV=production && npm run build", "prod": "set NODE_ENV=production && npm run build",
"postinstall": "npm run build", "postinstall": "npm run build",
@@ -59,9 +55,6 @@
"shared", "shared",
"server" "server"
], ],
"transformIgnorePatterns": [
"node_modules/(?!nanoid/).*"
],
"coveragePathIgnorePatterns": [ "coveragePathIgnorePatterns": [
"build/*" "build/*"
], ],
@@ -83,25 +76,32 @@
"jest-expect-message" "jest-expect-message"
] ]
}, },
"babel": {
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-transform-runtime"
]
},
"dependencies": { "dependencies": {
"@babel/core": "^7.26.0", "@babel/core": "^7.25.8",
"@babel/plugin-transform-runtime": "^7.25.9", "@babel/plugin-transform-runtime": "^7.25.7",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.25.8",
"@babel/preset-react": "^7.26.3", "@babel/preset-react": "^7.25.7",
"@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",
"codemirror": "^5.65.6", "codemirror": "^5.65.6",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"core-js": "^3.39.0",
"cors": "^2.8.5",
"create-react-class": "^15.7.0", "create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3", "dedent-tabs": "^0.10.3",
"dompurify": "^3.2.3", "dompurify": "^3.1.7",
"expr-eval": "^2.0.2", "expr-eval": "^2.0.2",
"express": "^4.21.2", "express": "^4.21.1",
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-static-gzip": "2.2.0", "express-static-gzip": "2.1.8",
"fs-extra": "11.2.0", "fs-extra": "11.2.0",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
@@ -109,35 +109,33 @@
"less": "^3.13.1", "less": "^3.13.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "11.2.0", "marked": "11.2.0",
"marked-emoji": "^1.4.3", "marked-emoji": "^1.4.2",
"marked-extended-tables": "^1.0.10", "marked-extended-tables": "^1.0.10",
"marked-gfm-heading-id": "^3.2.0", "marked-gfm-heading-id": "^3.2.0",
"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.9.2", "mongoose": "^8.7.1",
"nanoid": "5.0.9", "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": "^7.0.2", "react-router-dom": "6.27.0",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"superagent": "^10.1.1", "superagent": "^10.1.0",
"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",
"babel-plugin-transform-import-meta": "^2.2.1", "eslint": "^9.12.0",
"eslint": "^9.17.0", "eslint-plugin-jest": "^28.8.3",
"eslint-plugin-jest": "^28.10.0", "eslint-plugin-react": "^7.37.1",
"eslint-plugin-react": "^7.37.2", "globals": "^15.11.0",
"globals": "^15.14.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^16.12.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

@@ -1,14 +1,13 @@
const fs = require('fs-extra');
const Proj = require('./project.json');
import fs from 'fs-extra'; const { pack } = require('vitreum');
import Proj from './project.json' with { type: 'json' };
import vitreum from 'vitreum';
const { pack } = vitreum;
import lessTransform from 'vitreum/transforms/less.js';
import assetTransform from 'vitreum/transforms/asset.js';
const isDev = !!process.argv.find((arg)=>arg=='--dev'); const isDev = !!process.argv.find((arg)=>arg=='--dev');
const lessTransform = require('vitreum/transforms/less.js');
const assetTransform = require('vitreum/transforms/asset.js');
//const Meta = require('vitreum/headtags');
const transforms = { const transforms = {
'.less' : lessTransform, '.less' : lessTransform,
'*' : assetTransform('./build') '*' : assetTransform('./build')
@@ -18,7 +17,7 @@ const build = async ({ bundle, render, ssr })=>{
const css = await lessTransform.generate({ paths: './shared' }); const css = await lessTransform.generate({ paths: './shared' });
await fs.outputFile('./build/admin/bundle.css', css); await fs.outputFile('./build/admin/bundle.css', css);
await fs.outputFile('./build/admin/bundle.js', bundle); await fs.outputFile('./build/admin/bundle.js', bundle);
await fs.outputFile('./build/admin/ssr.cjs', ssr); await fs.outputFile('./build/admin/ssr.js', ssr);
}; };
fs.emptyDirSync('./build/admin'); fs.emptyDirSync('./build/admin');

View File

@@ -1,18 +1,16 @@
import fs from 'fs-extra'; const fs = require('fs-extra');
import zlib from 'zlib'; const zlib = require('zlib');
import Proj from './project.json' with { type: 'json' }; const Proj = require('./project.json');
import vitreum from 'vitreum';
const { pack, watchFile, livereload } = vitreum;
import lessTransform from 'vitreum/transforms/less.js'; const { pack, watchFile, livereload } = require('vitreum');
import assetTransform from 'vitreum/transforms/asset.js'; const isDev = !!process.argv.find((arg)=>arg=='--dev');
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 lessTransform = require('vitreum/transforms/less.js');
const assetTransform = require('vitreum/transforms/asset.js');
const babel = require('@babel/core');
const less = require('less');
const babelify = async (code)=>(await babel.transformAsync(code, babelConfig)).code; 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 transforms = { const transforms = {
'.js' : (code, filename, opts)=>babelify(code), '.js' : (code, filename, opts)=>babelify(code),
@@ -26,7 +24,7 @@ const build = async ({ bundle, render, ssr })=>{
//css = `@layer bundle {\n${css}\n}`; //css = `@layer bundle {\n${css}\n}`;
await fs.outputFile('./build/homebrew/bundle.css', css); await fs.outputFile('./build/homebrew/bundle.css', css);
await fs.outputFile('./build/homebrew/bundle.js', bundle); await fs.outputFile('./build/homebrew/bundle.js', bundle);
await fs.outputFile('./build/homebrew/ssr.cjs', ssr); await fs.outputFile('./build/homebrew/ssr.js', ssr);
await fs.copy('./client/homebrew/favicon.ico', './build/assets/favicon.ico'); await fs.copy('./client/homebrew/favicon.ico', './build/assets/favicon.ico');
@@ -53,7 +51,7 @@ fs.emptyDirSync('./build');
const themes = { Legacy: {}, V3: {} }; const themes = { Legacy: {}, V3: {} };
let themeFiles = fs.readdirSync('./themes/Legacy'); let themeFiles = fs.readdirSync('./themes/Legacy');
for (let dir of themeFiles) { for (dir of themeFiles) {
const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`).toString()); const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`).toString());
themeData.path = dir; themeData.path = dir;
themes.Legacy[dir] = (themeData); themes.Legacy[dir] = (themeData);
@@ -70,7 +68,7 @@ fs.emptyDirSync('./build');
} }
themeFiles = fs.readdirSync('./themes/V3'); themeFiles = fs.readdirSync('./themes/V3');
for (let dir of themeFiles) { for (dir of themeFiles) {
const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`).toString()); const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`).toString());
themeData.path = dir; themeData.path = dir;
themes.V3[dir] = (themeData); themes.V3[dir] = (themeData);
@@ -106,14 +104,14 @@ fs.emptyDirSync('./build');
const editorThemesBuildDir = './build/homebrew/cm-themes'; const editorThemesBuildDir = './build/homebrew/cm-themes';
await fs.copy('./node_modules/codemirror/theme', editorThemesBuildDir); await fs.copy('./node_modules/codemirror/theme', editorThemesBuildDir);
await fs.copy('./themes/codeMirror/customThemes', editorThemesBuildDir); await fs.copy('./themes/codeMirror/customThemes', editorThemesBuildDir);
const editorThemeFiles = fs.readdirSync(editorThemesBuildDir); editorThemeFiles = fs.readdirSync(editorThemesBuildDir);
const editorThemeFile = './themes/codeMirror/editorThemes.json'; const editorThemeFile = './themes/codeMirror/editorThemes.json';
if(fs.existsSync(editorThemeFile)) fs.rmSync(editorThemeFile); if(fs.existsSync(editorThemeFile)) fs.rmSync(editorThemeFile);
const stream = fs.createWriteStream(editorThemeFile, { flags: 'a' }); const stream = fs.createWriteStream(editorThemeFile, { flags: 'a' });
stream.write('[\n"default"'); stream.write('[\n"default"');
for (let themeFile of editorThemeFiles) { for (themeFile of editorThemeFiles) {
stream.write(`,\n"${themeFile.slice(0, -4)}"`); stream.write(`,\n"${themeFile.slice(0, -4)}"`);
} }
stream.write('\n]\n'); stream.write('\n]\n');

View File

@@ -1,12 +1,12 @@
import DB from './server/db.js'; const DB = require('./server/db.js');
import server from './server/app.js'; const server = require('./server/app.js');
import config from './server/config.js'; const config = require('./server/config.js');
DB.connect(config).then(()=>{ DB.connect(config).then(()=>{
// Ensure that we have successfully connected to the database // Ensure that we have successfully connected to the database
// before launching server // before launching server
const PORT = process.env.PORT || config.get('web_port') || 8000; const PORT = process.env.PORT || config.get('web_port') || 8000;
server.listen(PORT, ()=>{ server.app.listen(PORT, ()=>{
const reset = '\x1b[0m'; // Reset to default style const reset = '\x1b[0m'; // Reset to default style
const bright = '\x1b[1m'; // Bright (bold) style const bright = '\x1b[1m'; // Bright (bold) style
const cyan = '\x1b[36m'; // Cyan color const cyan = '\x1b[36m'; // Cyan color

View File

@@ -1,15 +1,9 @@
import { model as HomebrewModel } from './homebrew.model.js'; const HomebrewModel = require('./homebrew.model.js').model;
import { model as NotificationModel } from './notifications.model.js'; const NotificationModel = require('./notifications.model.js').model;
import express from 'express'; const router = require('express').Router();
import Moment from 'moment'; const Moment = require('moment');
import zlib from 'zlib'; const templateFn = require('../client/template.js');
import templateFn from '../client/template.js'; const zlib = require('zlib');
import HomebrewAPI from './homebrew.api.js';
import asyncHandler from 'express-async-handler';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
const router = express.Router();
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin'; process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3'; process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
@@ -72,8 +66,23 @@ router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
}); });
/* Searches for matching edit or share id, also attempts to partial match */ /* Searches for matching edit or share id, also attempts to partial match */
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{ router.get('/admin/lookup/:id', mw.adminOnly, async (req, res, next)=>{
return res.json(req.brew); HomebrewModel.findOne({
$or : [
{ editId: { $regex: req.params.id, $options: 'i' } },
{ shareId: { $regex: req.params.id, $options: 'i' } },
]
}).exec()
.then((brew)=>{
if(!brew) // No document found
return res.status(404).json({ error: 'Document not found' });
else
return res.json(brew);
})
.catch((err)=>{
console.error(err);
return res.status(500).json({ error: 'Internal Server Error' });
});
}); });
/* Find 50 brews that aren't compressed yet */ /* Find 50 brews that aren't compressed yet */
@@ -91,28 +100,6 @@ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
}); });
}); });
/* Cleans `<script` and `</script>` from the "text" field of a brew */
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
console.log(`[ADMIN] Cleaning script tags from ShareID ${req.params.id}`);
function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');};
const brew = req.brew;
const properties = ['text', 'description', 'title'];
properties.forEach((property)=>{
brew[property] = cleanText(brew[property]);
});
splitTextStyleAndMetadata(brew);
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);
});
/* Compresses the "text" field of a brew to binary */ /* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{ router.put('/admin/compress/:id', (req, res)=>{
@@ -157,7 +144,6 @@ 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 });
@@ -165,6 +151,7 @@ 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);
@@ -195,4 +182,4 @@ router.get('/admin', mw.adminOnly, (req, res)=>{
}); });
}); });
export default router; module.exports = router;

View File

@@ -1,10 +1,9 @@
import supertest from 'supertest'; const supertest = require('supertest');
import HBApp from './app.js';
import {model as NotificationModel } from './notifications.model.js';
const app = supertest.agent(require('app.js').app)
.set('X-Forwarded-Proto', 'https');
// Mimic https responses to avoid being redirected all the time const NotificationModel = require('./notifications.model.js').model;
const app = supertest.agent(HBApp).set('X-Forwarded-Proto', 'https');
describe('Tests for admin api', ()=>{ describe('Tests for admin api', ()=>{
afterEach(()=>{ afterEach(()=>{

View File

@@ -1,41 +1,25 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/ /*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
// Set working directory to project root // Set working directory to project root
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import packageJSON from './../package.json' with { type: 'json' };
const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(`${__dirname}/..`); process.chdir(`${__dirname}/..`);
const version = packageJSON.version;
import _ from 'lodash';
import jwt from 'jwt-simple';
import express from 'express';
import yaml from 'js-yaml';
import config from './config.js';
import fs from 'fs-extra';
const _ = require('lodash');
const jwt = require('jwt-simple');
const express = require('express');
const yaml = require('js-yaml');
const app = express(); const app = express();
const config = require('./config.js');
const fs = require('fs-extra');
import api from './homebrew.api.js'; const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js');
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = api; const GoogleActions = require('./googleActions.js');
import adminApi from './admin.api.js'; const serveCompressedStaticAssets = require('./static-assets.mv.js');
import vaultApi from './vault.api.js'; const sanitizeFilename = require('sanitize-filename');
import GoogleActions from './googleActions.js'; const asyncHandler = require('express-async-handler');
import serveCompressedStaticAssets from './static-assets.mv.js'; const templateFn = require('./../client/template.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 { DEFAULT_BREW } from './brewDefaults.js'; const { DEFAULT_BREW } = require('./brewDefaults.js');
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
//==== Middleware Imports ====// const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
import contentNegotiation from './middleware/content-negotiation.js';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import forceSSL from './forcessl.mw.js';
const sanitizeBrew = (brew, accessType)=>{ const sanitizeBrew = (brew, accessType)=>{
@@ -47,47 +31,13 @@ const sanitizeBrew = (brew, accessType)=>{
return brew; 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('/', serveCompressedStaticAssets(`build`));
app.use(contentNegotiation); app.use(require('./middleware/content-negotiation.js'));
app.use(bodyParser.json({ limit: '25mb' })); app.use(require('body-parser').json({ limit: '25mb' }));
app.use(cookieParser()); app.use(require('cookie-parser')());
app.use(forceSSL); app.use(require('./forcessl.mw.js'));
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 //Account Middleware
app.use((req, res, next)=>{ app.use((req, res, next)=>{
@@ -96,9 +46,7 @@ app.use((req, res, next)=>{
req.account = jwt.decode(req.cookies.nc_session, config.get('secret')); req.account = jwt.decode(req.cookies.nc_session, config.get('secret'));
//console.log("Just loaded up JWT from cookie:"); //console.log("Just loaded up JWT from cookie:");
//console.log(req.account); //console.log(req.account);
} catch (e){ } catch (e){}
console.log(e);
}
} }
req.config = { req.config = {
@@ -109,14 +57,15 @@ app.use((req, res, next)=>{
}); });
app.use(homebrewApi); app.use(homebrewApi);
app.use(adminApi); app.use(require('./admin.api.js'));
app.use(vaultApi); app.use(require('./vault.api.js'));
const welcomeText = fs.readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8'); const HomebrewModel = require('./homebrew.model.js').model;
const welcomeTextLegacy = fs.readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8'); const welcomeText = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
const migrateText = fs.readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8'); const welcomeTextLegacy = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
const changelogText = fs.readFileSync('changelog.md', 'utf8'); const migrateText = require('fs').readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8');
const faqText = fs.readFileSync('faq.md', 'utf8'); const changelogText = require('fs').readFileSync('changelog.md', 'utf8');
const faqText = require('fs').readFileSync('faq.md', 'utf8');
String.prototype.replaceAll = function(s, r){return this.split(s).join(r);}; String.prototype.replaceAll = function(s, r){return this.split(s).join(r);};
@@ -309,7 +258,7 @@ 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" 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);
@@ -348,34 +297,6 @@ app.get('/user/:username', async (req, res, next)=>{
return 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 //Edit Page
app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{ app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
req.brew = req.brew.toObject ? req.brew.toObject() : req.brew; req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
@@ -463,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');
@@ -477,7 +398,7 @@ 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) {
auth = await GoogleActions.authCheck(req.account, res, false); auth = await GoogleActions.authCheck(req.account, res, false)
googleCount = await GoogleActions.listGoogleBrews(auth) googleCount = await GoogleActions.listGoogleBrews(auth)
.catch((err)=>{ .catch((err)=>{
@@ -512,6 +433,8 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
return next(); return next();
})); }));
const nodeEnv = config.get('node_env');
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
// Local only // Local only
if(isLocalEnvironment){ if(isLocalEnvironment){
// Login // Login
@@ -539,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);
@@ -556,7 +479,7 @@ const renderPage = async (req, res)=>{
deployment : config.get('heroku_app_name') ?? '' deployment : config.get('heroku_app_name') ?? ''
}; };
const props = { const props = {
version : version, version : require('./../package.json').version,
url : req.customUrl || req.originalUrl, url : req.customUrl || req.originalUrl,
brew : req.brew, brew : req.brew,
brews : req.brews, brews : req.brews,
@@ -633,4 +556,6 @@ app.use((req, res)=>{
}); });
//^=====--------------------------------------=====^// //^=====--------------------------------------=====^//
export default app; module.exports = {
app : app
};

View File

@@ -1,4 +1,4 @@
import _ from 'lodash'; const _ = require('lodash');
// Default properties for newly-created brews // Default properties for newly-created brews
const DEFAULT_BREW = { const DEFAULT_BREW = {
@@ -32,7 +32,7 @@ const DEFAULT_BREW_LOAD = _.defaults(
}, },
DEFAULT_BREW); DEFAULT_BREW);
export { module.exports = {
DEFAULT_BREW, DEFAULT_BREW,
DEFAULT_BREW_LOAD DEFAULT_BREW_LOAD
}; };

View File

@@ -1,7 +1,5 @@
import nconf from 'nconf'; module.exports = require('nconf')
.argv()
export default nconf .env({ lowerCase: true })
.argv() .file('environment', { file: `config/${process.env.NODE_ENV}.json` })
.env({ lowerCase: true }) .file('defaults', { file: 'config/default.json' });
.file('environment', { file: `config/${process.env.NODE_ENV}.json` })
.file('defaults', { file: 'config/default.json' });

View File

@@ -5,7 +5,7 @@
// reused by both the main application and all tests which require database // reused by both the main application and all tests which require database
// connection. // connection.
import Mongoose from 'mongoose'; const Mongoose = require('mongoose');
const getMongoDBURL = (config)=>{ const getMongoDBURL = (config)=>{
return config.get('mongodb_uri') || return config.get('mongodb_uri') ||
@@ -31,7 +31,7 @@ const connect = async (config)=>{
.catch((error)=>handleConnectionError(error)); .catch((error)=>handleConnectionError(error));
}; };
export default { module.exports = {
connect, connect : connect,
disconnect disconnect : disconnect
}; };

View File

@@ -1,4 +1,4 @@
export default (req, res, next)=>{ module.exports = (req, res, next)=>{
if(process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'docker') return next(); if(process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'docker') return next();
if(req.header('x-forwarded-proto') !== 'https') { if(req.header('x-forwarded-proto') !== 'https') {
return res.redirect(302, `https://${req.get('Host')}${req.url}`); return res.redirect(302, `https://${req.get('Host')}${req.url}`);

View File

@@ -1,9 +1,8 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import googleDrive from '@googleapis/drive'; const googleDrive = require('@googleapis/drive');
import { nanoid } from 'nanoid'; const { nanoid } = require('nanoid');
import token from './token.js'; const token = require('./token.js');
import config from './config.js'; const config = require('./config.js');
let serviceAuth; let serviceAuth;
if(!config.get('service_account')){ if(!config.get('service_account')){
@@ -60,7 +59,7 @@ const GoogleActions = {
account.googleRefreshToken = tokens.refresh_token; account.googleRefreshToken = tokens.refresh_token;
} }
account.googleAccessToken = tokens.access_token; account.googleAccessToken = tokens.access_token;
const JWTToken = token(account); const JWTToken = token.generateAccessToken(account);
//Save updated token to cookie //Save updated token to cookie
//res.cookie('nc_session', JWTToken, { maxAge: 1000*60*60*24*365, path: '/', sameSite: 'lax' }); //res.cookie('nc_session', JWTToken, { maxAge: 1000*60*60*24*365, path: '/', sameSite: 'lax' });
@@ -73,7 +72,7 @@ const GoogleActions = {
getGoogleFolder : async (auth)=>{ getGoogleFolder : async (auth)=>{
const drive = googleDrive.drive({ version: 'v3', auth }); const drive = googleDrive.drive({ version: 'v3', auth });
const fileMetadata = { fileMetadata = {
'name' : 'Homebrewery', 'name' : 'Homebrewery',
'mimeType' : 'application/vnd.google-apps.folder' 'mimeType' : 'application/vnd.google-apps.folder'
}; };
@@ -241,8 +240,8 @@ const GoogleActions = {
return obj.data.id; return obj.data.id;
}, },
getGoogleBrew : async (auth = defaultAuth, id, accessId, accessType)=>{ getGoogleBrew : async (id, accessId, accessType)=>{
const drive = googleDrive.drive({ version: 'v3', auth: auth }); const drive = googleDrive.drive({ version: 'v3', auth: defaultAuth });
const obj = await drive.files.get({ const obj = await drive.files.get({
fileId : id, fileId : id,
@@ -345,4 +344,4 @@ const GoogleActions = {
} }
}; };
export default GoogleActions; module.exports = GoogleActions;

View File

@@ -1,20 +1,18 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import _ from 'lodash'; const _ = require('lodash');
import {model as HomebrewModel} from './homebrew.model.js'; const HomebrewModel = require('./homebrew.model.js').model;
import express from 'express'; const router = require('express').Router();
import zlib from 'zlib'; const zlib = require('zlib');
import GoogleActions from './googleActions.js'; const GoogleActions = require('./googleActions.js');
import Markdown from '../shared/naturalcrit/markdown.js'; const Markdown = require('../shared/naturalcrit/markdown.js');
import yaml from 'js-yaml'; const yaml = require('js-yaml');
import asyncHandler from 'express-async-handler'; const asyncHandler = require('express-async-handler');
import { nanoid } from 'nanoid'; const { nanoid } = require('nanoid');
import { splitTextStyleAndMetadata } from '../shared/helpers.js'; const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
import checkClientVersion from './middleware/check-client-version.js';
const router = express.Router(); const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js');
import { DEFAULT_BREW, DEFAULT_BREW_LOAD } from './brewDefaults.js'; const Themes = require('../themes/themes.json');
import Themes from '../themes/themes.json' with { type: 'json' };
const isStaticTheme = (renderer, themeName)=>{ const isStaticTheme = (renderer, themeName)=>{
return Themes[renderer]?.[themeName] !== undefined; return Themes[renderer]?.[themeName] !== undefined;
@@ -87,68 +85,66 @@ const api = {
// Create middleware with the accessType passed in as part of the scope // Create middleware with the accessType passed in as part of the scope
return async (req, res, next)=>{ return async (req, res, next)=>{
// Get relevant IDs for the brew // Get relevant IDs for the brew
let { id, googleId } = api.getId(req); const { id, googleId } = api.getId(req);
const accessMap = {
edit : { editId: id },
share : { 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. // Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine.
let stub = await HomebrewModel.get(accessMap[accessType]) let stub = await HomebrewModel.get(accessType === 'edit' ? { editId: id } : { shareId: id })
.catch((err)=>{ .catch((err)=>{
if(googleId) if(googleId) {
console.warn(`Unable to find document stub for ${accessType}Id ${id}`); console.warn(`Unable to find document stub for ${accessType}Id ${id}`);
else } else {
console.warn(err); console.warn(err);
}
}); });
stub = stub?.toObject(); stub = stub?.toObject();
googleId ??= stub?.googleId;
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(stub?.lock?.locked && accessType != 'edit') { if(stub?.lock?.locked && accessType != 'edit') {
throw { HBErrorCode: '51', code: stub?.lock.code, message: stub?.lock.shareMessage, brewId: stub?.shareId, brewTitle: stub?.title }; 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 there is a google id, try to find the google brew
if(googleId && (!stubOnly || !stub)) { if(!stubOnly && (googleId || stub?.googleId)) {
const oAuth2Client = isOwner ? GoogleActions.authCheck(req.account, res) : undefined; let googleError;
const googleBrew = await GoogleActions.getGoogleBrew(googleId || stub?.googleId, id, accessType)
const googleBrew = await GoogleActions.getGoogleBrew(oAuth2Client, googleId, id, accessType) .catch((err)=>{
.catch((googleError)=>{ googleError = err;
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 // 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; 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 after all of that we still don't have a brew, throw an exception
if(!stub) if(!stub && !stubOnly) {
throw { name: 'BrewLoad Error', message: 'Brew not found', status: 404, HBErrorCode: '05', accessType: accessType, brewId: id }; 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 // Clean up brew: fill in missing fields with defaults / fix old invalid values
stub.tags = stub.tags || undefined; // Clear empty strings if(stub) {
stub.renderer = stub.renderer || undefined; // Clear empty strings stub.tags = stub.tags || undefined; // Clear empty strings
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields 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(); next();
}; };
}, },
@@ -299,8 +295,9 @@ const api = {
req.params.id = currentTheme.theme; req.params.id = currentTheme.theme;
req.params.renderer = currentTheme.renderer; req.params.renderer = currentTheme.renderer;
} else { }
//=== Static Themes ===// //=== Static Themes ===//
else {
const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client
const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`; const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`;
completeSnippets.push(localSnippets); completeSnippets.push(localSnippets);
@@ -467,11 +464,12 @@ const api = {
} }
}; };
router.post('/api', checkClientVersion, asyncHandler(api.newBrew)); router.use('/api', require('./middleware/check-client-version.js'));
router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew)); router.post('/api', asyncHandler(api.newBrew));
router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew)); router.put('/api/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew)); router.put('/api/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew)); router.delete('/api/:id', asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle)); router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
export default api; module.exports = api;

View File

@@ -1,7 +1,5 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
describe('Tests for api', ()=>{ describe('Tests for api', ()=>{
let api; let api;
let google; let google;
@@ -38,9 +36,8 @@ describe('Tests for api', ()=>{
} }
}); });
google = require('./googleActions.js').default; google = require('./googleActions.js');
model = require('./homebrew.model.js').model; model = require('./homebrew.model.js').model;
api = require('./homebrew.api').default;
jest.mock('./googleActions.js'); jest.mock('./googleActions.js');
google.authCheck = jest.fn(()=>'client'); google.authCheck = jest.fn(()=>'client');
@@ -57,6 +54,8 @@ describe('Tests for api', ()=>{
setHeader : jest.fn(()=>{}) setHeader : jest.fn(()=>{})
}; };
api = require('./homebrew.api');
hbBrew = { hbBrew = {
text : `brew text`, text : `brew text`,
style : 'hello yes i am css', style : 'hello yes i am css',
@@ -298,7 +297,7 @@ describe('Tests for api', ()=>{
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
expect(api.getId).toHaveBeenCalledWith(req); expect(api.getId).toHaveBeenCalledWith(req);
expect(model.get).toHaveBeenCalledWith({ shareId: '1' }); expect(model.get).toHaveBeenCalledWith({ shareId: '1' });
expect(google.getGoogleBrew).toHaveBeenCalledWith(undefined, '2', '1', 'share'); expect(google.getGoogleBrew).toHaveBeenCalledWith('2', '1', 'share');
}); });
it('access is denied to a locked brew', async()=>{ it('access is denied to a locked brew', async()=>{
@@ -970,57 +969,4 @@ brew`);
expect(res.send).toHaveBeenCalledWith(''); 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,8 +1,7 @@
import mongoose from 'mongoose'; const mongoose = require('mongoose');
import { nanoid } from 'nanoid'; const { nanoid } = require('nanoid');
import _ from 'lodash'; const _ = require('lodash');
import zlib from 'zlib'; const zlib = require('zlib');
const HomebrewSchema = mongoose.Schema({ const HomebrewSchema = mongoose.Schema({
shareId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } }, shareId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
@@ -45,7 +44,7 @@ HomebrewSchema.statics.get = async function(query, fields=null){
const brew = await Homebrew.findOne(query, fields).orFail() const brew = await Homebrew.findOne(query, fields).orFail()
.catch((error)=>{throw 'Can not find brew';}); .catch((error)=>{throw 'Can not find brew';});
if(!_.isNil(brew.textBin)) { // Uncompress zipped text field if(!_.isNil(brew.textBin)) { // Uncompress zipped text field
const unzipped = zlib.inflateRawSync(brew.textBin); unzipped = zlib.inflateRawSync(brew.textBin);
brew.text = unzipped.toString(); brew.text = unzipped.toString();
} }
return brew; return brew;
@@ -63,7 +62,7 @@ HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, f
const Homebrew = mongoose.model('Homebrew', HomebrewSchema); const Homebrew = mongoose.model('Homebrew', HomebrewSchema);
export { module.exports = {
HomebrewSchema as schema, schema : HomebrewSchema,
Homebrew as model model : Homebrew,
}; };

View File

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

View File

@@ -1,12 +1,12 @@
import config from '../config.js'; const config = require('../config.js');
const nodeEnv = config.get('node_env'); const nodeEnv = config.get('node_env');
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv); const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
export default (req, res, next)=>{ module.exports = (req, res, next)=>{
const isImageRequest = req.get('Accept')?.split(',') const isImageRequest = req.get('Accept')?.split(',')
?.filter((h)=>!h.includes('q=')) ?.filter((h)=>!h.includes('q='))
?.every((h)=>/image\/.*/.test(h)); ?.every((h)=>/image\/.*/.test(h));
if(isImageRequest && !(isLocalEnvironment && req.url?.startsWith('/staticImages'))) { if(isImageRequest && !isLocalEnvironment && !req.url?.startsWith('/staticImages')) {
return res.status(406).send({ return res.status(406).send({
message : 'Request for image at this URL is not supported' message : 'Request for image at this URL is not supported'
}); });

View File

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

View File

@@ -1,5 +1,5 @@
import mongoose from 'mongoose'; const mongoose = require('mongoose');
import _ from 'lodash'; const _ = require('lodash');
const NotificationSchema = new mongoose.Schema({ const NotificationSchema = new mongoose.Schema({
dismissKey : { type: String, unique: true, required: true }, dismissKey : { type: String, unique: true, required: true },
@@ -56,7 +56,7 @@ NotificationSchema.statics.getAll = async function() {
const Notification = mongoose.model('Notification', NotificationSchema); const Notification = mongoose.model('Notification', NotificationSchema);
export { module.exports = {
NotificationSchema as schema, schema : NotificationSchema,
Notification as model model : Notification,
}; };

View File

@@ -1,4 +1,4 @@
import expressStaticGzip from 'express-static-gzip'; const expressStaticGzip = require('express-static-gzip');
// Serve brotli-compressed static files if available // Serve brotli-compressed static files if available
const customCacheControlHandler=(response, path)=>{ const customCacheControlHandler=(response, path)=>{
@@ -28,4 +28,4 @@ const init=(pathToAssets)=>{
} }); } });
}; };
export default init; module.exports = init;

View File

@@ -1,5 +1,7 @@
import jwt from 'jwt-simple'; const jwt = require('jwt-simple');
import config from './config.js';
// Load configuration values
const config = require('./config.js');
// Generate an Access Token for the given User ID // Generate an Access Token for the given User ID
const generateAccessToken = (account)=>{ const generateAccessToken = (account)=>{
@@ -22,4 +24,6 @@ const generateAccessToken = (account)=>{
return token; return token;
}; };
export default generateAccessToken; module.exports = {
generateAccessToken : generateAccessToken
};

View File

@@ -1,6 +1,6 @@
import express from 'express'; const express = require('express');
import asyncHandler from 'express-async-handler'; const asyncHandler = require('express-async-handler');
import {model as HomebrewModel } from './homebrew.model.js'; const HomebrewModel = require('./homebrew.model.js').model;
const router = express.Router(); const router = express.Router();
@@ -106,4 +106,4 @@ const findTotal = async (req, res)=>{
router.get('/api/vault/total', asyncHandler(findTotal)); router.get('/api/vault/total', asyncHandler(findTotal));
router.get('/api/vault', asyncHandler(findBrews)); router.get('/api/vault', asyncHandler(findBrews));
export default router; module.exports = router;

View File

@@ -1,6 +1,6 @@
import _ from 'lodash'; const _ = require('lodash');
import yaml from 'js-yaml'; const yaml = require('js-yaml');
import request from '../client/homebrew/utils/request-middleware.js'; const request = require('../client/homebrew/utils/request-middleware.js');
const splitTextStyleAndMetadata = (brew)=>{ const splitTextStyleAndMetadata = (brew)=>{
brew.text = brew.text.replaceAll('\r\n', '\n'); brew.text = brew.text.replaceAll('\r\n', '\n');
@@ -21,9 +21,6 @@ const splitTextStyleAndMetadata = (brew)=>{
brew.snippets = brew.text.slice(11, index - 1); brew.snippets = brew.text.slice(11, index - 1);
brew.text = brew.text.slice(index + 5); 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 = ()=>{ const printCurrentBrew = ()=>{
@@ -47,14 +44,14 @@ const fetchThemeBundle = async (obj, renderer, theme)=>{
if(!res) return; if(!res) return;
const themeBundle = res.body; const themeBundle = res.body;
themeBundle.joinedStyles = themeBundle.styles.map((style)=>`<style>${style}</style>`).join('\n\n'); themeBundle.joinedStyles = themeBundle.styles.map((style)=>`${style}`).join('\n\n');
obj.setState((prevState)=>({ obj.setState((prevState)=>({
...prevState, ...prevState,
themeBundle : themeBundle themeBundle : themeBundle
})); }));
}; };
export { module.exports = {
splitTextStyleAndMetadata, splitTextStyleAndMetadata,
printCurrentBrew, printCurrentBrew,
fetchThemeBundle, fetchThemeBundle,

View File

@@ -1,7 +1,7 @@
import diceFont from '../../../themes/fonts/iconFonts/diceFont.js'; const diceFont = require('../../../themes/fonts/iconFonts/diceFont.js');
import elderberryInn from '../../../themes/fonts/iconFonts/elderberryInn.js'; const elderberryInn = require('../../../themes/fonts/iconFonts/elderberryInn.js');
import fontAwesome from '../../../themes/fonts/iconFonts/fontAwesome.js'; const fontAwesome = require('../../../themes/fonts/iconFonts/fontAwesome.js');
import gameIcons from '../../../themes/fonts/iconFonts/gameIcons.js'; const gameIcons = require('../../../themes/fonts/iconFonts/gameIcons.js');
const emojis = { const emojis = {
...diceFont, ...diceFont,

View File

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

View File

@@ -1,19 +1,19 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import _ from 'lodash'; const _ = require('lodash');
import { Parser as MathParser } from 'expr-eval'; const Marked = require('marked');
import { marked as Marked } from 'marked'; const MarkedExtendedTables = require('marked-extended-tables');
import MarkedExtendedTables from 'marked-extended-tables'; const { markedSmartypantsLite: MarkedSmartypantsLite } = require('marked-smartypants-lite');
import { markedSmartypantsLite as MarkedSmartypantsLite } from 'marked-smartypants-lite'; const { gfmHeadingId: MarkedGFMHeadingId, resetHeadings: MarkedGFMResetHeadingIDs } = require('marked-gfm-heading-id');
import { gfmHeadingId as MarkedGFMHeadingId, resetHeadings as MarkedGFMResetHeadingIDs } from 'marked-gfm-heading-id'; const { markedEmoji: MarkedEmojis } = require('marked-emoji');
import { markedEmoji as MarkedEmojis } from 'marked-emoji';
//Icon fonts included so they can appear in emoji autosuggest dropdown //Icon fonts included so they can appear in emoji autosuggest dropdown
import diceFont from '../../themes/fonts/iconFonts/diceFont.js'; const diceFont = require('../../themes/fonts/iconFonts/diceFont.js');
import elderberryInn from '../../themes/fonts/iconFonts/elderberryInn.js'; const elderberryInn = require('../../themes/fonts/iconFonts/elderberryInn.js');
import gameIcons from '../../themes/fonts/iconFonts/gameIcons.js'; const fontAwesome = require('../../themes/fonts/iconFonts/fontAwesome.js');
import fontAwesome from '../../themes/fonts/iconFonts/fontAwesome.js'; const gameIcons = require('../../themes/fonts/iconFonts/gameIcons.js');
const renderer = new Marked.Renderer(); const MathParser = require('expr-eval').Parser;
const renderer = new Marked.Renderer();
const tokenizer = new Marked.Tokenizer(); const tokenizer = new Marked.Tokenizer();
//Limit math features to simple items //Limit math features to simple items
@@ -391,31 +391,10 @@ 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 = { const definitionListsSingleLine = {
name : 'definitionListsSingleLine', name : 'definitionListsSingleLine',
level : 'block', 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) { tokenizer(src, tokens) {
const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym; const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym;
let match; let match;
@@ -769,12 +748,11 @@ const tableTerminators = [
]; ];
Marked.use(MarkedVariables()); Marked.use(MarkedVariables());
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
nonbreakingSpaces, superSubScripts, mustacheSpans, mustacheDivs, mustacheInjectInline] }); mustacheSpans, mustacheDivs, mustacheInjectInline] });
Marked.use(mustacheInjectBlock); Marked.use(mustacheInjectBlock);
Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false }); Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }), Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
function cleanUrl(href) { function cleanUrl(href) {
try { try {
@@ -876,7 +854,7 @@ const globalVarsList = {};
let varsQueue = []; let varsQueue = [];
let globalPageNumber = 0; let globalPageNumber = 0;
const Markdown = { module.exports = {
marked : Marked, marked : Marked,
render : (rawBrewText, pageNumber=0)=>{ render : (rawBrewText, pageNumber=0)=>{
globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order
@@ -887,7 +865,6 @@ const Markdown = {
} }
rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`); rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`);
const opts = Marked.defaults; const opts = Marked.defaults;
rawBrewText = opts.hooks.preprocess(rawBrewText); rawBrewText = opts.hooks.preprocess(rawBrewText);
@@ -958,6 +935,3 @@ const Markdown = {
return errors; return errors;
}, },
}; };
export default Markdown;

View File

@@ -12,8 +12,10 @@ const Nav = {
displayName : 'Nav.base', displayName : 'Nav.base',
render : function(){ render : function(){
return <nav> return <nav>
<div className='navContent'>
{this.props.children} {this.props.children}
</nav>; </div>
</nav>;
} }
}), }),
logo : function(){ logo : function(){

View File

@@ -1,110 +1,200 @@
require('./splitPane.less'); require('./splitPane.less');
const React = require('react'); const React = require('react');
const { useState, useEffect } = React; const createClass = require('create-react-class');
const cx = require('classnames');
const storageKey = 'naturalcrit-pane-split'; const SplitPane = createClass({
displayName : 'SplitPane',
getDefaultProps : function() {
return {
storageKey : 'naturalcrit-pane-split',
onDragFinish : function(){}, //fires when dragging
showDividerButtons : true
};
},
const SplitPane = (props)=>{ getInitialState : function() {
const { return {
onDragFinish = ()=>{}, currentDividerPos : null,
showDividerButtons = true windowWidth : 0,
} = props; isDragging : false,
moveSource : false,
moveBrew : false,
showMoveArrows : true
};
},
const [isDragging, setIsDragging] = useState(false); pane1 : React.createRef(null),
const [dividerPos, setDividerPos] = useState(null); pane2 : React.createRef(null),
const [moveSource, setMoveSource] = useState(false);
const [moveBrew, setMoveBrew] = useState(false);
const [showMoveArrows, setShowMoveArrows] = useState(true);
const [liveScroll, setLiveScroll] = useState(false);
useEffect(()=>{ componentDidMount : function() {
const savedPos = window.localStorage.getItem(storageKey); const dividerPos = window.localStorage.getItem(this.props.storageKey);
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2); if(dividerPos){
setLiveScroll(window.localStorage.getItem('liveScroll') === 'true'); this.setState({
currentDividerPos : this.limitPosition(dividerPos, 0.1*(window.innerWidth-13), 0.9*(window.innerWidth-13)),
window.addEventListener('resize', handleResize); userSetDividerPos : dividerPos,
return ()=>window.removeEventListener('resize', handleResize); windowWidth : window.innerWidth
}, []); });
} else {
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x))); this.setState({
currentDividerPos : window.innerWidth / 2,
//when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position userSetDividerPos : window.innerWidth / 2
const handleResize = () =>setDividerPos(limitPosition(window.localStorage.getItem(storageKey), 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13))); });
const handleUp =(e)=>{
e.preventDefault();
if(isDragging) {
onDragFinish(dividerPos);
window.localStorage.setItem(storageKey, dividerPos);
} }
setIsDragging(false); window.addEventListener('resize', this.handleWindowResize);
};
const handleDown = (e)=>{ // This lives here instead of in the initial render because you cannot touch localStorage until the componant mounts.
const loadLiveScroll = window.localStorage.getItem('liveScroll') === 'true';
this.setState({ liveScroll: loadLiveScroll });
},
componentWillUnmount : function() {
window.removeEventListener('resize', this.handleWindowResize);
},
handleWindowResize : function() {
// Allow divider to increase in size to last user-set position
// Limit current position to between 10% and 90% of visible space
const newLoc = this.limitPosition(this.state.userSetDividerPos, 0.1*(window.innerWidth-13), 0.9*(window.innerWidth-13));
this.setState({
currentDividerPos : newLoc,
windowWidth : window.innerWidth
});
},
limitPosition : function(x, min = 1, max = window.innerWidth - 13) {
const result = Math.round(Math.min(max, Math.max(min, x)));
return result;
},
handleUp : function(e){
e.preventDefault(); e.preventDefault();
setIsDragging(true); if(this.state.isDragging){
}; this.props.onDragFinish(this.state.currentDividerPos);
window.localStorage.setItem(this.props.storageKey, this.state.currentDividerPos);
}
this.setState({ isDragging: false });
},
const handleMove = (e)=>{ handleDown : function(e){
if(!isDragging) return;
e.preventDefault(); e.preventDefault();
setDividerPos(limitPosition(e.pageX)); this.setState({ isDragging: true });
}; //this.unFocus()
},
const liveScrollToggle = ()=>{ handleMove : function(e){
window.localStorage.setItem('liveScroll', String(!liveScroll)); if(!this.state.isDragging) return;
setLiveScroll(!liveScroll);
};
const renderMoveArrows = (showMoveArrows && e.preventDefault();
<> const newSize = this.limitPosition(e.pageX);
<div className='arrow left' this.setState({
onClick={()=>setMoveSource(!moveSource)} > currentDividerPos : newSize,
<i className='fas fa-arrow-left' /> userSetDividerPos : newSize
</div> });
<div className='arrow right' },
onClick={()=>setMoveBrew(!moveBrew)} >
<i className='fas fa-arrow-right' />
</div>
<div id='scrollToggleDiv' className={liveScroll ? 'arrow lock' : 'arrow unlock'}
onClick={liveScrollToggle} >
<i id='scrollToggle' className={liveScroll ? 'fas fa-lock' : 'fas fa-unlock'} />
</div>
</>
);
const renderDivider = ( liveScrollToggle : function() {
<div className={`divider ${isDragging && 'dragging'}`} onPointerDown={handleDown}> window.localStorage.setItem('liveScroll', String(!this.state.liveScroll));
{showDividerButtons && renderMoveArrows} this.setState({ liveScroll: !this.state.liveScroll });
<div className='dots'> },
<i className='fas fa-circle' /> /*
<i className='fas fa-circle' /> unFocus : function() {
<i className='fas fa-circle' /> if(document.selection){
</div> document.selection.empty();
</div> }else{
); window.getSelection().removeAllRanges();
}
},
*/
return ( setMoveArrows : function(newState) {
<div className='splitPane' onPointerMove={handleMove} onPointerUp={handleUp}> if(this.state.showMoveArrows != newState){
<Pane width={dividerPos} moveBrew={moveBrew} moveSource={moveSource} liveScroll={liveScroll} setMoveArrows={setShowMoveArrows}> this.setState({
{props.children[0]} showMoveArrows : newState
});
}
},
renderMoveArrows : function(){
if(this.state.showMoveArrows) {
return <>
<div className='arrow left'
style={{ left: this.state.currentDividerPos-4 }}
onClick={()=>this.setState({ moveSource: !this.state.moveSource })} >
<i className='fas fa-arrow-left' />
</div>
<div className='arrow right'
style={{ left: this.state.currentDividerPos-4 }}
onClick={()=>this.setState({ moveBrew: !this.state.moveBrew })} >
<i className='fas fa-arrow-right' />
</div>
<div id='scrollToggleDiv' className={this.state.liveScroll ? 'arrow lock' : 'arrow unlock'}
style={{ left: this.state.currentDividerPos-4 }}
onClick={this.liveScrollToggle} >
<i id='scrollToggle' className={this.state.liveScroll ? 'fas fa-lock' : 'fas fa-unlock'} />
</div>
</>;
}
},
renderDivider : function(){
return <>
{this.props.showDividerButtons && this.renderMoveArrows()}
<div className='divider' onPointerDown={this.handleDown} >
<div className='dots'>
<i className='fas fa-circle' />
<i className='fas fa-circle' />
<i className='fas fa-circle' />
</div>
</div>
</>;
},
render : function(){
return <div className='splitPane' onPointerMove={this.handleMove} onPointerUp={this.handleUp}>
<Pane
width={this.state.currentDividerPos}
>
{React.cloneElement(this.props.children[0], {
...(this.props.showDividerButtons && {
moveBrew : this.state.moveBrew,
moveSource : this.state.moveSource,
liveScroll : this.state.liveScroll,
setMoveArrows : this.setMoveArrows,
}),
})}
</Pane> </Pane>
{renderDivider} {this.renderDivider()}
<Pane isDragging={isDragging}>{props.children[1]}</Pane> <Pane isDragging={this.state.isDragging}>{this.props.children[1]}</Pane>
</div> </div>;
); }
}; });
const Pane = ({ width, children, isDragging, moveBrew, moveSource, liveScroll, setMoveArrows })=>{ const Pane = createClass({
const styles = width displayName : 'Pane',
? { flex: 'none', width: `${width}px` } getDefaultProps : function() {
: { pointerEvents: isDragging ? 'none' : 'auto' }; //Disable mouse capture in the right pane; else dragging into the iframe drops the divider return {
width : null
};
},
render : function(){
let styles = {};
if(this.props.width){
styles = {
flex : 'none',
width : `${this.props.width}px`
};
} else {
styles = {
pointerEvents : this.props.isDragging ? 'none' : 'auto' //Disable mouse capture in the rightmost pane; dragging into the iframe drops the divider otherwise
};
}
return ( return <div className={cx('pane', this.props.className)} style={styles}>
<div className='pane' style={styles}> {this.props.children}
{React.cloneElement(children, { moveBrew, moveSource, liveScroll, setMoveArrows })} </div>;
</div> }
); });
};
module.exports = SplitPane; module.exports = SplitPane;

View File

@@ -1,68 +1,69 @@
.splitPane { .splitPane{
position : relative; position : relative;
display : flex; display : flex;
flex-direction : row;
height : 100%; height : 100%;
outline : none; outline : none;
.pane { flex-direction : row;
flex : 1; .pane{
overflow-x : hidden; overflow-x : hidden;
overflow-y : hidden; overflow-y : hidden;
flex : 1;
} }
.divider { .divider{
position : relative;
display : table;
width : 15px;
height : 100%;
text-align : center;
touch-action : none; touch-action : none;
display : table;
height : 100%;
width : 15px;
cursor : ew-resize; cursor : ew-resize;
background-color : #BBBBBB; background-color : #bbb;
.dots { text-align : center;
.dots{
display : table-cell; display : table-cell;
text-align : center;
vertical-align : middle; vertical-align : middle;
i { text-align : center;
i{
display : block !important; display : block !important;
margin : 10px 0px; margin : 10px 0px;
font-size : 6px; font-size : 6px;
color : #666666; color : #666;
} }
} }
&:hover,&.dragging { background-color : #999999; } &:hover{
background-color: #999;
}
} }
.arrow { .arrow{
position : absolute; position : absolute;
left : 50%;
z-index : 999;
width : 25px; width : 25px;
height : 25px; height : 25px;
font-size : 1.2em; border : 2px solid #bbb;
text-align : center;
cursor : pointer;
background-color : #DDDDDD;
border : 2px solid #BBBBBB;
border-radius : 15px; border-radius : 15px;
box-shadow : 0 4px 5px #0000007F; text-align : center;
translate : -50%; font-size : 1.2em;
&.left { cursor : pointer;
background-color : #ddd;
z-index : 999;
box-shadow : 0 4px 5px #0000007f;
&.left{
.tooltipLeft('Jump to location in Editor'); .tooltipLeft('Jump to location in Editor');
top : 30px; top : 30px;
} }
&.right { &.right{
.tooltipRight('Jump to location in Preview'); .tooltipRight('Jump to location in Preview');
top : 60px; top : 60px;
} }
&.lock { &.lock{
.tooltipRight('De-sync Editor and Preview locations.'); .tooltipRight('De-sync Editor and Preview locations.');
top : 90px; top : 90px;
background : #666666; background: #666;
} }
&.unlock { &.unlock{
.tooltipRight('Sync Editor and Preview locations'); .tooltipRight('Sync Editor and Preview locations');
top : 90px; top : 90px;
} }
&:hover { background-color : #666666; } &:hover{
background-color: #666;
}
} }
} }

View File

@@ -21,7 +21,10 @@ html,body, #reactRoot{
*{ *{
box-sizing : border-box; box-sizing : border-box;
} }
.colorButton(@backgroundColor : @green){ button{
.button();
}
.button(@backgroundColor : @green){
.animate(background-color); .animate(background-color);
display : inline-block; display : inline-block;
padding : 0.6em 1.2em; padding : 0.6em 1.2em;
@@ -43,6 +46,5 @@ html,body, #reactRoot{
} }
&:disabled{ &:disabled{
background-color : @silver !important; background-color : @silver !important;
cursor:not-allowed;
} }
} }

View File

@@ -1,4 +1,4 @@
:where(html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,button,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video){ :where(html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video){
border:0;font-size:100%;font:inherit;vertical-align:baseline;margin:0;padding:0 border:0;font-size:100%;font:inherit;vertical-align:baseline;margin:0;padding:0
} }
@@ -25,9 +25,3 @@
:where(table){ :where(table){
border-collapse:collapse;border-spacing:0 border-collapse:collapse;border-spacing:0
} }
:where(button) {
background-color: unset;
text-transform: unset;
color: unset;
}

View File

@@ -1,50 +0,0 @@
require('jsdom-global')();
import { safeHTML } from '../../client/homebrew/brewRenderer/safeHTML';
test('Javascript via href', function() {
const source = `<a href="javascript:alert('This is a JavaScript injection via href attribute')">Click me</a>`;
const rendered = safeHTML(source);
expect(rendered).toBe('<a>Click me</a>');
});
test('Javascript via src', function() {
const source = `<img src="javascript:alert('This is a JavaScript injection via src attribute')">`;
const rendered = safeHTML(source);
expect(rendered).toBe('<img>');
});
test('Javascript via form submit action', function() {
const source = `<form action="javascript:alert('This is a JavaScript injection via action attribute')">\n<input type="submit" value="Submit">\n</form>`;
const rendered = safeHTML(source);
expect(rendered).toBe('<form>\n<input value=\"Submit\">\n</form>');
});
test('Javascript via inline event handler - onClick', function() {
const source = `<div style="background-color: red; color: white; width: 100px; height: 100px;" onclick="alert('This is a JavaScript injection via inline event handler')">\nClick me\n</div>`;
const rendered = safeHTML(source);
expect(rendered).toBe('<div style=\"background-color: red; color: white; width: 100px; height: 100px;\">\nClick me\n</div>');
});
test('Javascript via inline event handler - onMouseOver', function() {
const source = `<div onmouseover="alert('This is a JavaScript injection via inline event handler')">Hover over me</div>`;
const rendered = safeHTML(source);
expect(rendered).toBe('<div>Hover over me</div>');
});
test('Javascript via data attribute', function() {
const source = `<div data-code="javascript:alert('This is a JavaScript injection via data attribute')">Test</div>`;
const rendered = safeHTML(source);
expect(rendered).toBe('<div>Test</div>');
});
test('Javascript via event delegation', function() {
const source = `<div id="parent"><button id="child">Click me</button></div><script>document.getElementById('parent').addEventListener('click', function(event) {if (event.target.id === 'child') {console.log('This is JavaScript executed via event delegation');}});</script>`;
const rendered = safeHTML(source);
expect(rendered).toBe('<div id="parent"><button id="child">Click me</button></div>');
});

View File

@@ -1,6 +1,6 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import Markdown from 'naturalcrit/markdown.js'; const Markdown = require('naturalcrit/markdown.js');
test('Processes the markdown within an HTML block if its just a class wrapper', function() { test('Processes the markdown within an HTML block if its just a class wrapper', function() {
const source = '<div>*Bold text*</div>'; const source = '<div>*Bold text*</div>';

View File

@@ -1,6 +1,6 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import Markdown from 'naturalcrit/markdown.js'; const Markdown = require('naturalcrit/markdown.js');
describe('Inline Definition Lists', ()=>{ describe('Inline Definition Lists', ()=>{
test('No Term 1 Definition', function() { test('No Term 1 Definition', function() {

View File

@@ -1,4 +1,4 @@
import Markdown from 'naturalcrit/markdown.js'; const Markdown = require('naturalcrit/markdown.js');
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
// Marked.js adds line returns after closing tags on some default tokens. // Marked.js adds line returns after closing tags on some default tokens.

View File

@@ -1,6 +1,6 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import Markdown from 'naturalcrit/markdown.js'; const Markdown = require('naturalcrit/markdown.js');
describe('Hard Breaks', ()=>{ describe('Hard Breaks', ()=>{
test('Single Break', function() { test('Single Break', function() {

View File

@@ -1,7 +1,7 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
import Markdown from 'naturalcrit/markdown.js'; const Markdown = require('naturalcrit/markdown.js');
// Marked.js adds line returns after closing tags on some default tokens. // Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake. // This removes those line returns for comparison sake.

View File

@@ -1,72 +0,0 @@
/* 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

@@ -1,7 +1,7 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
import Markdown from 'naturalcrit/markdown.js'; const Markdown = require('naturalcrit/markdown.js');
// Marked.js adds line returns after closing tags on some default tokens. // Marked.js adds line returns after closing tags on some default tokens.
// This removes those line returns for comparison sake. // This removes those line returns for comparison sake.

View File

@@ -1,8 +1,8 @@
import supertest from 'supertest'; const supertest = require('supertest');
import HBApp from 'app.js';
// Mimic https responses to avoid being redirected all the time // Mimic https responses to avoid being redirected all the time
const app = supertest.agent(HBApp).set('X-Forwarded-Proto', 'https'); const app = supertest.agent(require('app.js').app)
.set('X-Forwarded-Proto', 'https');
describe('Tests for static pages', ()=>{ describe('Tests for static pages', ()=>{
it('Home page works', ()=>{ it('Home page works', ()=>{

View File

@@ -154,6 +154,28 @@ 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`,
},
]
}
] ]
}, },
{ {
@@ -192,27 +214,6 @@ 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,8 +812,17 @@ 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; }
}
//NOTE: DO NOT USE :HAS WITH .PAGES!!! EXTREMELY SLOW TO RENDER ON LARGE DOCS! .pages:has(.tocGlobalH5) {
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)

View File

@@ -1,4 +1,4 @@
import Markdown from '../../../../shared/naturalcrit/markdown.js'; const Markdown = require('../../../../shared/naturalcrit/markdown.js');
module.exports = { module.exports = {
createFooterFunc : function(headerSize=1){ createFooterFunc : function(headerSize=1){

View File

@@ -93,4 +93,4 @@ const diceFont = {
'df_solid_small_dot_d6_6' : 'df solid-small-dot-d6-6' 'df_solid_small_dot_d6_6' : 'df solid-small-dot-d6-6'
}; };
export default diceFont; module.exports = diceFont;

View File

@@ -206,4 +206,4 @@ const elderberryInn = {
'ei_wish' : 'ei wish' 'ei_wish' : 'ei wish'
}; };
export default elderberryInn; module.exports = elderberryInn;

View File

@@ -2051,4 +2051,4 @@ const fontAwesome = {
'fab_zhihu' : 'fab fa-zhihu' 'fab_zhihu' : 'fab fa-zhihu'
}; };
export default fontAwesome; module.exports = fontAwesome;

View File

@@ -506,4 +506,4 @@ const gameIcons = {
'gi_acid' : 'gi acid' 'gi_acid' : 'gi acid'
}; };
export default gameIcons; module.exports = gameIcons;