0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-24 09:53:01 +00:00

Compare commits

..

3 Commits

Author SHA1 Message Date
Trevor Buckner
0fd3448826 Fix check that editor is in Text tab 2024-10-15 08:01:41 -04:00
Trevor Buckner
3bd73417e6 Add optional chaining 2024-10-14 22:48:27 -04:00
Trevor Buckner
72b69ebb6a Memoize BrewRenderer
Only re-render BrewRenderer when specific props have changed.

Fixes lag during scrolling, which updates the "currentPage" prop, but isn't actually used until the text is changed.
2024-10-14 21:55:02 -04:00
91 changed files with 2347 additions and 3122 deletions

View File

@@ -76,9 +76,6 @@ jobs:
- run:
name: Test - Routes
command: npm run test:route
- run:
name: Test - HTML sanitization
command: npm run test:safehtml
- run:
name: Test - Coverage
command: npm run test:coverage

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
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
{{taskList

View File

@@ -1,5 +1,3 @@
require('./brewLookup.less');
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
@@ -14,43 +12,22 @@ const BrewLookup = createClass({
},
getInitialState() {
return {
query : '',
foundBrew : null,
searching : false,
error : null,
scriptCount : 0
query : '',
foundBrew : null,
searching : false,
error : null
};
},
handleChange(e){
this.setState({ query: e.target.value });
},
lookup(){
this.setState({ searching: true, error: null, scriptCount: 0 });
this.setState({ searching: true, error: null });
request.get(`/admin/lookup/${this.state.query}`)
.then((res)=>{
const foundBrew = res.body;
const scriptCheck = foundBrew?.text.match(/(<\/?s)cript/g);
this.setState({
foundBrew : foundBrew,
scriptCount : scriptCheck?.length || 0,
});
})
.then((res)=>this.setState({ foundBrew: res.body }))
.catch((err)=>this.setState({ error: err }))
.finally(()=>{
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();
.finally(()=>this.setState({ searching: false }));
},
renderFoundBrew(){
@@ -69,23 +46,12 @@ const BrewLookup = createClass({
<dt>Share Link</dt>
<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>
<dd>{Moment(brew.updatedAt).fromNow()}</dd>
<dt>Num of Views</dt>
<dd>{brew.views}</dd>
<dt>SCRIPT tags detected</dt>
<dd>{this.state.scriptCount}</dd>
</dl>
{this.state.scriptCount > 0 &&
<div className='cleanButton'>
<button onClick={this.cleanScript}>CLEAN BREW</button>
</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'>
Dismiss Key:
<input className='fieldInput' type='text' ref={dismissKeyRef} required
placeholder='dismiss_notif_drive'
placeholder='GOOGLEDRIVENOTIF'
/>
</label>

View File

@@ -14,6 +14,9 @@ const NotificationDetail = ({ notification, onDelete })=>(
<dt>Title</dt>
<dd>{notification.title || 'No Title'}</dd>
<dt>Text</dt>
<dd>{notification.text || 'No Text'}</dd>
<dt>Created</dt>
<dd>{Moment(notification.createdAt).format('LLLL')}</dd>
@@ -22,9 +25,6 @@ const NotificationDetail = ({ notification, onDelete })=>(
<dt>Stop</dt>
<dd>{Moment(notification.stopAt).format('LLLL') || 'No End Time'}</dd>
<dt>Text</dt>
<dd>{notification.text || 'No Text'}</dd>
</dl>
<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
import React from 'react';
const React = require('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);
useEffect(()=>{
if(dismisskeys.length !== 0) {
if(!dismissKey || !localStorage.getItem(dismissKey)) {
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
}
}, [dialogRef.current, dismisskeys]);
}, []);
const dismiss = ()=>{
dismisskeys.forEach((key)=>{
if(key) {
localStorage.setItem(key, 'true');
}
});
dismissKey && localStorage.setItem(dismissKey, true);
dialogRef.current?.close();
};
return (
return (
<dialog ref={dialogRef} onCancel={dismiss} {...rest}>
{rest.children}
<button className='dismiss' onClick={dismiss}>

View File

@@ -1,11 +1,11 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./brewRenderer.less');
const React = require('react');
const { useState, useRef, useCallback, useMemo } = React;
const { useState, useRef, useCallback, memo } = React;
const _ = require('lodash');
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 ToolBar = require('./toolBar/toolBar.jsx');
@@ -16,7 +16,8 @@ const Frame = require('react-frame-component').default;
const dedent = require('dedent-tabs').default;
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;
@@ -28,7 +29,6 @@ const INITIAL_CONTENT = dedent`
<base target=_blank>
</head><body style='overflow: hidden'><div></div></body></html>`;
//v=====----------------------< Brew Page Component >---------------------=====v//
const BrewPage = (props)=>{
props = {
@@ -36,18 +36,18 @@ const BrewPage = (props)=>{
index : 0,
...props
};
const cleanText = safeHTML(props.contents);
return <div className={props.className} id={`p${props.index + 1}`} style={props.style}>
const cleanText = props.contents; //DOMPurify.sanitize(props.contents, purifyConfig);
return <div className={props.className} id={`p${props.index + 1}`} >
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
</div>;
};
//v=====--------------------< Brew Renderer Component >-------------------=====v//
let renderedPages = [];
const renderedPages = [];
let rawPages = [];
const BrewRenderer = (props)=>{
const BrewRenderer = memo((props)=>{
props = {
text : '',
style : '',
@@ -65,14 +65,8 @@ const BrewRenderer = (props)=>{
const [state, setState] = useState({
isMounted : false,
visibility : 'hidden'
});
const [displayOptions, setDisplayOptions] = useState({
zoomLevel : 100,
spread : 'single',
startOnRight : true,
pageShadows : true
visibility : 'hidden',
zoom : 100
});
const mainRef = useRef(null);
@@ -83,26 +77,6 @@ const BrewRenderer = (props)=>{
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 { scrollTop, clientHeight, scrollHeight } = e.target;
const totalScrollableHeight = scrollHeight - clientHeight;
@@ -131,9 +105,9 @@ const BrewRenderer = (props)=>{
};
const renderStyle = ()=>{
const cleanStyle = props.style; //DOMPurify.sanitize(props.style, purifyConfig);
const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>';
const cleanStyle = safeHTML(`${themeStyles} \n\n <style> ${props.style} </style>`);
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: cleanStyle }} />;
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `${themeStyles} \n\n <style> ${cleanStyle} </style>` }} />;
};
const renderPage = (pageText, index)=>{
@@ -143,13 +117,7 @@ const BrewRenderer = (props)=>{
} else {
pageText += `\n\n&nbsp;\n\\column\n&nbsp;`; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
const html = Markdown.render(pageText, index);
const styles = {
...(!displayOptions.pageShadows ? { boxShadow: 'none' } : {})
// Add more conditions as needed
};
return <BrewPage className='page' index={index} key={index} contents={html} style={styles} />;
return <BrewPage className='page' index={index} key={index} contents={html} />;
}
};
@@ -161,8 +129,7 @@ const BrewRenderer = (props)=>{
renderedPages.length = 0;
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
if(rawPages.length > props.currentEditorCursorPageNum -1)
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
_.forEach(rawPages, (page, index)=>{
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
@@ -183,8 +150,6 @@ const BrewRenderer = (props)=>{
};
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
renderPages(); //Make sure page is renderable before showing
setState((prevState)=>({
@@ -200,25 +165,20 @@ const BrewRenderer = (props)=>{
document.dispatchEvent(new MouseEvent('click'));
};
const handleDisplayOptionsChange = (newDisplayOptions)=>{
setDisplayOptions(newDisplayOptions);
};
const pagesStyle = {
zoom : `${displayOptions.zoomLevel}%`,
columnGap : `${displayOptions.columnGap}px`,
rowGap : `${displayOptions.rowGap}px`
//Toolbar settings:
const handleZoom = (newZoom)=>{
setState((prevState)=>({
...prevState,
zoom : newZoom
}));
};
const styleObject = {};
if(global.config.deployment) {
styleObject.backgroundImage = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='40px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${global.config.deployment}</text></svg>")`;
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]);
renderedPages = useMemo(()=>renderPages(), [props.text]);
return (
<>
{/*render dummy page while iFrame is mounting.*/}
@@ -236,7 +196,7 @@ const BrewRenderer = (props)=>{
<NotificationPopup />
</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.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
@@ -254,10 +214,9 @@ const BrewRenderer = (props)=>{
{state.isMounted
&&
<>
{renderedStyle}
<div lang={`${props.lang || 'en'}`} style={pagesStyle} className={
`pages ${displayOptions.startOnRight ? 'recto' : 'verso'} ${displayOptions.spread}` } >
{renderedPages}
{renderStyle()}
<div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}>
{renderPages()}
</div>
</>
}
@@ -265,6 +224,19 @@ const BrewRenderer = (props)=>{
</Frame>
</>
);
};
}, arePropsEqual);
//Only re-render brewRenderer if arePropsEqual == true
function arePropsEqual(oldProps, newProps) {
return (
oldProps?.text?.length === newProps?.text?.length &&
oldProps?.style?.length === newProps?.style?.length &&
oldProps?.renderer === newProps?.renderer &&
oldProps?.theme === newProps?.theme &&
oldProps?.errors === newProps?.errors &&
oldProps?.themeBundle === newProps?.themeBundle &&
oldProps?.lang === newProps?.lang
);
}
module.exports = BrewRenderer;

View File

@@ -3,45 +3,13 @@
.brewRenderer {
overflow-y : scroll;
will-change : transform;
padding-top : 60px;
padding-top : 30px;
height : 100vh;
&:has(.facing, .flow) {
padding : 60px 30px;
}
&.deployment {
background-color: darkred;
}
:where(.pages) {
&.facing {
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;
}
}
margin : 30px 0px;
& > :where(.page) {
width : 215.9mm;
height : 279.4mm;
@@ -50,9 +18,6 @@
margin-left : auto;
box-shadow : 1px 4px 14px #000000;
}
*[id] {
scroll-margin-top:100px;
}
}
&::-webkit-scrollbar {
width : 20px;

View File

@@ -1,62 +1,44 @@
require('./notificationPopup.less');
import React, { useEffect, useState } from 'react';
import request from '../../utils/request-middleware.js';
const React = require('react');
const _ = require('lodash');
import Dialog from '../../../components/dialog.jsx';
const DISMISS_KEY = 'dismiss_notification01-10-24';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
const NotificationPopup = ()=>{
const [notifications, setNotifications] = useState([]);
const [dissmissKeyList, setDismissKeyList] = useState([]);
const [error, setError] = useState(null);
useEffect(()=>{
getNotifications();
}, []);
const getNotifications = async ()=>{
setError(null);
try {
const res = await request.get('/admin/notification/all');
pickActiveNotifications(res.body || []);
} catch (err) {
console.log(err);
setError(`Error looking up notifications: ${err?.response?.body?.message || err.message}`);
}
};
const pickActiveNotifications = (notifs)=>{
const now = new Date();
const filteredNotifications = notifs.filter((notification)=>{
const startDate = new Date(notification.startAt);
const stopDate = new Date(notification.stopAt);
const dismissed = localStorage.getItem(notification.dismissKey) ? true : false;
return now >= startDate && now <= stopDate && !dismissed;
});
setNotifications(filteredNotifications);
setDismissKeyList(filteredNotifications.map((notif)=>notif.dismissKey));
};
const renderNotificationsList = ()=>{
if(error) return <div className='error'>{error}</div>;
return notifications.map((notification)=>(
<li key={notification.dismissKey} >
<em>{notification.title}</em><br />
<p dangerouslySetInnerHTML={{ __html: notification.text }}></p>
</li>
));
};
return <Dialog className='notificationPopup' dismisskeys={dissmissKeyList} closeText={DISMISS_BUTTON} >
return <Dialog className='notificationPopup' dismissKey={DISMISS_KEY} closeText={DISMISS_BUTTON} >
<div className='header'>
<i className='fas fa-info-circle info'></i>
<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>
</div>
<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>
</Dialog>;
};

View File

@@ -55,10 +55,7 @@
margin-top : 1.4em;
font-size : 0.8em;
line-height : 1.4em;
em {
text-transform:capitalize;
font-weight : 800;
}
em { 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 _ = require('lodash');
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
// import * as ZoomIcons from '../../../icons/icon-components/zoomIcons.jsx';
const MAX_ZOOM = 300;
const MIN_ZOOM = 10;
const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChange })=>{
const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
const [pageNum, setPageNum] = useState(currentPage);
const [zoomLevel, setZoomLevel] = useState(100);
const [pageNum, setPageNum] = useState(currentPage);
const [toolsVisible, setToolsVisible] = useState(true);
useEffect(()=>{
onZoomChange(zoomLevel);
}, [zoomLevel]);
useEffect(()=>{
setPageNum(currentPage);
}, [currentPage]);
const handleZoomButton = (zoom)=>{
handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
};
const handleOptionChange = (optionKey, newValue)=>{
//setDisplayOptions(prevOptions => ({ ...prevOptions, [optionKey]: newValue }));
onDisplayOptionsChange({ ...displayOptions, [optionKey]: newValue });
setZoomLevel(_.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
};
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 deltaZoom = (desiredZoom - displayOptions.zoomLevel) - margin;
const deltaZoom = (desiredZoom - zoomLevel) - margin;
return deltaZoom;
};
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>
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
<div className='group'>
<button
id='fill-width'
className='tool'
title='Set zoom to fill preview with one page'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
onClick={()=>handleZoomButton(zoomLevel + calculateChange('fill'))}
>
<i className='fac fit-width' />
</button>
<button
id='zoom-to-fit'
className='tool'
title='Set zoom to fit entire page in preview'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
onClick={()=>handleZoomButton(zoomLevel + calculateChange('fit'))}
>
<i className='fac zoom-to-fit' />
</button>
<button
id='zoom-out'
className='tool'
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
disabled={displayOptions.zoomLevel <= MIN_ZOOM}
title='Zoom Out'
onClick={()=>handleZoomButton(zoomLevel - 20)}
disabled={zoomLevel <= MIN_ZOOM}
>
<i className='fas fa-magnifying-glass-minus' />
</button>
<input
id='zoom-slider'
className='range-input tool hover-tooltip'
className='range-input tool'
type='range'
name='zoom'
title='Set Zoom'
list='zoomLevels'
min={MIN_ZOOM}
max={MAX_ZOOM}
step='1'
value={displayOptions.zoomLevel}
value={zoomLevel}
onChange={(e)=>handleZoomButton(parseInt(e.target.value))}
/>
<datalist id='zoomLevels'>
@@ -119,72 +113,18 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
<button
id='zoom-in'
className='tool'
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
disabled={displayOptions.zoomLevel >= MAX_ZOOM}
title='Zoom In'
onClick={()=>handleZoomButton(zoomLevel + 20)}
disabled={zoomLevel >= MAX_ZOOM}
>
<i className='fas fa-magnifying-glass-plus' />
</button>
</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*/}
<div className='group' role='group' aria-label='Pages' aria-hidden={!toolsVisible}>
<div className='group'>
<button
id='previous-page'
className='previousPage tool'
type='button'
title='Previous Page(s)'
onClick={()=>scrollToPage(pageNum - 1)}
disabled={pageNum <= 1}
>
@@ -197,7 +137,6 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
className='text-input'
type='text'
name='page'
title='Current page(s) in view'
inputMode='numeric'
pattern='[0-9]'
value={pageNum}
@@ -206,14 +145,12 @@ const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChan
onBlur={()=>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>
<button
id='next-page'
className='tool'
type='button'
title='Next Page(s)'
onClick={()=>scrollToPage(pageNum + 1)}
disabled={pageNum >= totalPages}
>

View File

@@ -13,12 +13,11 @@
height : auto;
padding : 2px 0;
font-family : 'Open Sans', sans-serif;
font-size : 13px;
color : #CCCCCC;
background-color : #555555;
& > *:not(.toggleButton) {
opacity : 1;
transition : all 0.2s ease;
opacity: 1;
transition: all .2s ease;
}
.group {
@@ -35,70 +34,6 @@
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 {
position : relative;
height : 1.5em;
@@ -122,7 +57,7 @@
outline : none;
}
&.hover-tooltip[value]:hover::after {
&:hover::after {
position : absolute;
bottom : -30px;
left : 50%;
@@ -148,40 +83,46 @@
}
button {
box-sizing : border-box;
box-sizing : content-box;
display : flex;
align-items : center;
justify-content : center;
width : auto;
min-width : 46px;
height : 100%;
padding : 0 0px;
font-weight : unset;
color : inherit;
background-color : unset;
&:hover { background-color : #444444; }
&:focus { border : 1px solid #D3D3D3;outline : none;}
&:focus { outline : 1px solid #D3D3D3; }
&:disabled {
color : #777777;
background-color : unset !important;
}
i { font-size : 1.2em; }
i {
font-size:1.2em;
}
}
&.hidden {
flex-wrap : nowrap;
width : 32px;
overflow : hidden;
background-color : unset;
opacity : 0.5;
transition : all 0.3s ease;
width: 32px;
transition: all .3s ease;
flex-wrap:nowrap;
overflow: hidden;
background-color: unset;
opacity: .5;
& > *:not(.toggleButton) {
opacity : 0;
transition : all 0.2s ease;
opacity: 0;
transition: all .2s ease;
}
}
}
button.toggleButton {
position : absolute;
left : 0;
z-index : 5;
width : 32px;
min-width : unset;
z-index : 5;
position:absolute;
left: 0;
width: 32px;
min-width: unset;
}

View File

@@ -4,7 +4,7 @@ const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
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 SnippetBar = require('./snippetbar/snippetbar.jsx');
@@ -314,7 +314,7 @@ const Editor = createClass({
},
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
if(!window || !this.isText() || isJumping)
if(!window || isJumping)
return;
// Get current brewRenderer scroll position and calculate target position

View File

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

View File

@@ -3,10 +3,10 @@ require('./metadataEditor.less');
const React = require('react');
const createClass = require('create-react-class');
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 Combobox = require('client/components/combobox.jsx');
const TagInput = require('../tagInput/tagInput.jsx');
const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx');
const Themes = require('themes/themes.json');
@@ -341,11 +341,10 @@ const MetadataEditor = createClass({
{this.renderThumbnail()}
</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}
values={this.props.metadata.tags}
onChange={(e)=>this.handleFieldChange('tags', e)}
/>
onChange={(e)=>this.handleFieldChange('tags', e)}/>
<div className='field systems'>
<label>systems</label>
@@ -364,13 +363,12 @@ const MetadataEditor = createClass({
{this.renderAuthors()}
<TagInput label='invited authors' valuePatterns={[/.+/]}
<StringArrayEditor label='invited authors' valuePatterns={[/.+/]}
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
placeholder='invite author' unique={true}
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.']}
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}
/>
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}/>
<h2>Privacy</h2>

View File

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

View File

@@ -150,22 +150,18 @@ const Snippetbar = createClass({
renderSnippetGroups : function(){
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
if(snippets.length === 0) return null;
return <div className='snippets'>
{_.map(snippets, (snippetGroup)=>{
return <SnippetGroup
brew={this.props.brew}
groupName={snippetGroup.groupName}
icon={snippetGroup.icon}
snippets={snippetGroup.snippets}
key={snippetGroup.groupName}
onSnippetClick={this.handleSnippetClick}
cursorPos={this.props.cursorPos}
/>;
})
}
</div>;
return _.map(snippets, (snippetGroup)=>{
return <SnippetGroup
brew={this.props.brew}
groupName={snippetGroup.groupName}
icon={snippetGroup.icon}
snippets={snippetGroup.snippets}
key={snippetGroup.groupName}
onSnippetClick={this.handleSnippetClick}
cursorPos={this.props.cursorPos}
/>;
});
},
replaceContent : function(item){
@@ -207,58 +203,57 @@ const Snippetbar = createClass({
renderEditorButtons : function(){
if(!this.props.showEditButtons) return;
const foldButtons = <>
<div className={`editorTool foldAll ${this.props.view !== 'meta' && this.props.foldCode ? 'active' : ''}`}
onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.view !== 'meta' && this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
</>;
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'>
<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 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='codeTools'>
{foldButtons}
<div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()}
</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 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 className='tabs'>
<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 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>;
},
@@ -296,9 +291,8 @@ const SnippetGroup = createClass({
return _.map(snippets, (snippet)=>{
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}>
<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.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
{snippet.subsnippets && <>
<i className='fas fa-caret-right'></i>
<div className='dropdown side'>

View File

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

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

View File

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

View File

@@ -4,7 +4,7 @@ const React = require('react');
const _ = require('lodash');
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 Nav = require('naturalcrit/nav/nav.jsx');
@@ -23,7 +23,7 @@ const BrewRenderer = require('../../brewRenderer/brewRenderer.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 { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
@@ -228,8 +228,8 @@ const EditPage = createClass({
htmlErrors : Markdown.validate(prevState.brew.text)
}));
await updateHistory(this.state.brew).catch(console.error);
await versionHistoryGarbageCollection().catch(console.error);
await updateHistory(this.state.brew);
await versionHistoryGarbageCollection();
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
@@ -429,41 +429,41 @@ const EditPage = createClass({
<Meta name='robots' content='noindex, nofollow' />
{this.renderNavbar()}
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
<div className="content">
<SplitPane onDragFinish={this.handleSplitMove}>
<Editor
ref={this.editor}
brew={this.state.brew}
onTextChange={this.handleTextChange}
onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange}
reportError={this.errorReported}
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets}
updateBrew={this.updateBrew}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
allowPrint={true}
/>
</SplitPane>
<div className='content'>
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
<SplitPane onDragFinish={this.handleSplitMove}>
<Editor
ref={this.editor}
brew={this.state.brew}
onTextChange={this.handleTextChange}
onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange}
reportError={this.errorReported}
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
snippetBundle={this.state.themeBundle.snippets}
updateBrew={this.updateBrew}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
allowPrint={true}
/>
</SplitPane>
</div>
</div>;
}

View File

@@ -1,7 +1,7 @@
require('./errorPage.less');
const React = require('react');
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 ErrorPage = ({ brew })=>{

View File

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

View File

@@ -91,6 +91,13 @@ If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](
\page
## 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.

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
const React = require('react');
const { useState } = React;
const _ = require('lodash');
const createClass = require('create-react-class');
const _ = require('lodash');
const ListPage = require('../basePages/listPage/listPage.jsx');
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.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 VaultNavitem = require('../../navbar/vault.navitem.jsx');
const UserPage = (props)=>{
props = {
username : '',
brews : [],
query : '',
...props
};
const UserPage = createClass({
displayName : 'UserPage',
getDefaultProps : function() {
return {
username : '',
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 groupedBrews = _.groupBy(props.brews, (brew)=>brew.published ? 'published' : 'private');
const brewCollection = [
{
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 = [
{
title : `${usernameWithS} published brews`,
class : 'published',
brews : groupedBrews.published || []
},
...(props.username === global.account?.username ? [{
title : `${usernameWithS} unpublished brews`,
class : 'unpublished',
brews : groupedBrews.private || []
}] : [])
];
return {
brewCollection : brewCollection
};
},
errorReported : function(error) {
this.setState({
error
});
},
const navItems = (
<Navbar>
navItems : function() {
return <Navbar>
<Nav.section>
{error && (<ErrorNavItem error={error} parent={null}></ErrorNavItem>)}
{this.state.error ?
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
null
}
<NewBrew />
<HelpNavItem />
<VaultNavitem />
<VaultNavitem/>
<RecentNavItem />
<Account />
</Nav.section>
</Navbar>
);
</Navbar>;
},
return (
<ListPage brewCollection={brewCollection} navItems={navItems} query={props.query} reportError={(err)=>setError(err)} />
);
};
render : function(){
return <ListPage brewCollection={this.state.brewCollection} navItems={this.navItems()} query={this.props.query} reportError={this.errorReported}></ListPage>;
}
});
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 ErrorIndex = require('../errorPage/errors/errorIndex.js');
import request from '../../utils/request-middleware.js';
const request = require('../../utils/request-middleware.js');
const VaultPage = (props)=>{
const [pageState, setPageState] = useState(parseInt(props.query.page) || 1);
@@ -411,18 +411,19 @@ const VaultPage = (props)=>{
};
return (
<div className='sitePage vaultPage'>
<div className='vaultPage'>
<link href='/themes/V3/Blank/style.css' rel='stylesheet' />
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet' />
{renderNavItems()}
<div className="content">
<SplitPane showDividerButtons={false}>
<div className='form dataGroup'>{renderForm()}</div>
<div className='resultsContainer dataGroup'>
{renderSortBar()}
{renderFoundBrews()}
</div>
</SplitPane>
<div className='content'>
<SplitPane showDividerButtons={false}>
<div className='form dataGroup'>{renderForm()}</div>
<div className='resultsContainer dataGroup'>
{renderSortBar()}
{renderFoundBrews()}
</div>
</SplitPane>
</div>
</div>
);

View File

@@ -5,369 +5,373 @@
*:not(input) { user-select : none; }
.content .dataGroup {
width : 100%;
.content {
height : 100%;
background : white;
background : #2C3E50;
&.form .brewLookup {
position : relative;
padding : 50px clamp(20px, 4vw, 50px);
.dataGroup {
width : 100%;
height : 100%;
background : white;
small {
font-size : 10pt;
color : #555555;
&.form .brewLookup {
position : relative;
padding : 50px clamp(20px, 4vw, 50px);
small {
font-size : 10pt;
color : #555555;
a { color : #333333; }
}
a { color : #333333; }
}
code {
padding-inline : 5px;
font-family : monospace;
background : lightgrey;
border-radius : 5px;
}
code {
padding-inline : 5px;
font-family : monospace;
background : lightgrey;
border-radius : 5px;
}
h1, h2, h3, h4 {
font-family : 'CodeBold';
letter-spacing : 2px;
}
h1, h2, h3, h4 {
font-family : 'CodeBold';
letter-spacing : 2px;
}
legend {
h3 {
margin-block : 30px 20px;
font-size : 20px;
legend {
h3 {
margin-block : 30px 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;
border-bottom : 2px solid;
}
ul {
padding-inline : 30px 10px;
li {
margin-block : 5px;
line-height : calc(1em + 5px);
list-style : disc;
.formContents {
position : relative;
display : flex;
flex-direction : column;
label {
display : flex;
align-items : center;
margin : 10px 0;
}
}
}
select { margin : 0 10px; }
&::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; }
input {
margin : 0 10px;
.formTitle {
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); }
&: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;
&[type='checkbox'] {
position : relative;
display : inline-block;
width : 50px;
height : 30px;
font-family : 'WalterTurncoat';
font-size : 20px;
font-weight : 800;
color : white;
letter-spacing : 2px;
appearance : none;
background : red;
isolation : isolate;
border-radius : 5px;
&::before,&::after {
position : absolute;
inset : 0;
z-index : 5;
padding-top : 2px;
text-align : center;
}
&::before {
display : block;
content : 'No';
}
&::after {
display : none;
content : 'Yes';
}
&:checked {
background : green;
&::before { display : none; }
&::after { display : block; }
}
}
}
#searchButton {
position : absolute;
right : 20px;
bottom : 0;
i {
margin-left : 10px;
animation-duration : 1000s;
}
}
}
}
&.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;
&::before,&::after {
position : absolute;
inset : 0;
z-index : 5;
padding-top : 2px;
text-align : center;
}
&.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 {
width : max-content;
#searchButton {
position : absolute;
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 {
@@ -384,7 +388,7 @@
// media query for when the page is smaller than 1079 px in width
@media screen and (max-width : 1079px) {
.vaultPage {
.vaultPage .content {
.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);
@@ -9,4 +9,4 @@ const requestMiddleware = {
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_SLOTS = 5;
@@ -21,14 +21,12 @@ const HISTORY_SAVE_DELAYS = {
// '5' : 5
// };
const GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
// const GARBAGE_COLLECT_DELAY = 10;
const HB_DB = 'HOMEBREWERY-DB';
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){
// Return a string representing the key for this brew and history slot
@@ -55,6 +53,11 @@ function parseBrewForStorage(brew, slot = 0) {
return [key, archiveBrew];
}
// Create a custom IDB store
async function createHBStore(){
return await IDB.createStore(HB_DB, HB_STORE);
}
export async function loadHistory(brew){
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
const dataArray = await IDB.getMany(historyKeys);
const dataArray = await IDB.getMany(historyKeys, await createHBStore());
return dataArray.map((data)=>{ return data ?? DEFAULT_HISTORY_ITEM; });
}
@@ -94,7 +97,7 @@ export async function updateHistory(brew) {
// Update the most recent brew
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;
@@ -103,17 +106,14 @@ export async function updateHistory(brew) {
};
export async function versionHistoryGarbageCollection(){
const entries = await IDB.entries();
const expiredKeys = [];
const entries = await IDB.entries(await createHBStore());
for (const [key, value] of entries){
const expireAt = new Date(value.savedAt);
expireAt.setMinutes(expireAt.getMinutes() + GARBAGE_COLLECT_DELAY);
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 {
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 ssrModule = await import(`../build/${name}/ssr.cjs`);
return `<!DOCTYPE html>
<html>
<head>
@@ -23,7 +21,7 @@ const template = async function(name, title='', props = {}){
<title>${title.length ? `${title} - The Homebrewery`: 'The Homebrewery - NaturalCrit'}</title>
</head>
<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>start_app(${JSON.stringify(props)})</script>
</body>
@@ -31,4 +29,4 @@ const template = async function(name, title='', props = {}){
`;
};
export default template;
module.exports = template;

2049
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,10 @@
{
"name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.16.1",
"type": "module",
"version": "3.16.0",
"engines": {
"npm": "^10.2.x",
"node": "^20.18.x"
"node": "^20.17.x"
},
"repository": {
"type": "git",
@@ -39,7 +38,6 @@
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
"test:route": "jest tests/routes/static-pages.test.js --verbose",
"test:safehtml": "jest tests/html/safeHTML.test.js --verbose",
"phb": "node --experimental-require-module scripts/phb.js",
"prod": "set NODE_ENV=production && npm run build",
"postinstall": "npm run build",
@@ -57,9 +55,6 @@
"shared",
"server"
],
"transformIgnorePatterns": [
"node_modules/(?!nanoid/).*"
],
"coveragePathIgnorePatterns": [
"build/*"
],
@@ -81,11 +76,20 @@
"jest-expect-message"
]
},
"babel": {
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-transform-runtime"
]
},
"dependencies": {
"@babel/core": "^7.26.0",
"@babel/plugin-transform-runtime": "^7.25.9",
"@babel/preset-env": "^7.26.0",
"@babel/preset-react": "^7.25.9",
"@babel/core": "^7.25.8",
"@babel/plugin-transform-runtime": "^7.25.7",
"@babel/preset-env": "^7.25.8",
"@babel/preset-react": "^7.25.7",
"@googleapis/drive": "^8.14.0",
"body-parser": "^1.20.2",
"classnames": "^2.5.1",
@@ -93,11 +97,11 @@
"cookie-parser": "^1.4.7",
"create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3",
"dompurify": "^3.2.1",
"dompurify": "^3.1.7",
"expr-eval": "^2.0.2",
"express": "^4.21.1",
"express-async-handler": "^1.2.0",
"express-static-gzip": "2.2.0",
"express-static-gzip": "2.1.8",
"fs-extra": "11.2.0",
"idb-keyval": "^6.2.1",
"js-yaml": "^4.1.0",
@@ -105,35 +109,33 @@
"less": "^3.13.1",
"lodash": "^4.17.21",
"marked": "11.2.0",
"marked-emoji": "^1.4.3",
"marked-emoji": "^1.4.2",
"marked-extended-tables": "^1.0.10",
"marked-gfm-heading-id": "^3.2.0",
"marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
"mongoose": "^8.8.2",
"nanoid": "5.0.8",
"mongoose": "^8.7.1",
"nanoid": "3.3.4",
"nconf": "^0.12.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-frame-component": "^4.1.3",
"react-router-dom": "6.28.0",
"react-router-dom": "6.26.2",
"sanitize-filename": "1.6.3",
"superagent": "^10.1.1",
"superagent": "^10.1.0",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
},
"devDependencies": {
"@stylistic/stylelint-plugin": "^3.1.1",
"babel-plugin-transform-import-meta": "^2.2.1",
"eslint": "^9.15.0",
"eslint-plugin-jest": "^28.9.0",
"eslint-plugin-react": "^7.37.2",
"globals": "^15.12.0",
"eslint": "^9.12.0",
"eslint-plugin-jest": "^28.8.3",
"eslint-plugin-react": "^7.37.1",
"globals": "^15.11.0",
"jest": "^29.7.0",
"jest-expect-message": "^1.1.3",
"jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0",
"stylelint": "^16.10.0",
"stylelint": "^16.9.0",
"stylelint-config-recess-order": "^5.1.1",
"stylelint-config-recommended": "^14.0.1",
"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';
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 { pack } = require('vitreum');
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 = {
'.less' : lessTransform,
'*' : assetTransform('./build')
@@ -18,7 +17,7 @@ const build = async ({ bundle, render, ssr })=>{
const css = await lessTransform.generate({ paths: './shared' });
await fs.outputFile('./build/admin/bundle.css', css);
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');

View File

@@ -1,15 +1,14 @@
import fs from 'fs-extra';
import zlib from 'zlib';
import Proj from './project.json' with { type: 'json' };
import vitreum from 'vitreum';
const { pack, watchFile, livereload } = vitreum;
const fs = require('fs-extra');
const zlib = require('zlib');
const Proj = require('./project.json');
import lessTransform from 'vitreum/transforms/less.js';
import assetTransform from 'vitreum/transforms/asset.js';
import babel from '@babel/core';
import less from 'less';
const { pack, watchFile, livereload } = require('vitreum');
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 babel = require('@babel/core');
const less = require('less');
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;
@@ -25,7 +24,7 @@ const build = async ({ bundle, render, ssr })=>{
//css = `@layer bundle {\n${css}\n}`;
await fs.outputFile('./build/homebrew/bundle.css', css);
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');
@@ -52,7 +51,7 @@ fs.emptyDirSync('./build');
const themes = { Legacy: {}, V3: {} };
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());
themeData.path = dir;
themes.Legacy[dir] = (themeData);
@@ -69,7 +68,7 @@ fs.emptyDirSync('./build');
}
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());
themeData.path = dir;
themes.V3[dir] = (themeData);
@@ -105,14 +104,14 @@ fs.emptyDirSync('./build');
const editorThemesBuildDir = './build/homebrew/cm-themes';
await fs.copy('./node_modules/codemirror/theme', editorThemesBuildDir);
await fs.copy('./themes/codeMirror/customThemes', editorThemesBuildDir);
const editorThemeFiles = fs.readdirSync(editorThemesBuildDir);
editorThemeFiles = fs.readdirSync(editorThemesBuildDir);
const editorThemeFile = './themes/codeMirror/editorThemes.json';
if(fs.existsSync(editorThemeFile)) fs.rmSync(editorThemeFile);
const stream = fs.createWriteStream(editorThemeFile, { flags: 'a' });
stream.write('[\n"default"');
for (let themeFile of editorThemeFiles) {
for (themeFile of editorThemeFiles) {
stream.write(`,\n"${themeFile.slice(0, -4)}"`);
}
stream.write('\n]\n');

View File

@@ -1,12 +1,12 @@
import DB from './server/db.js';
import server from './server/app.js';
import config from './server/config.js';
const DB = require('./server/db.js');
const server = require('./server/app.js');
const config = require('./server/config.js');
DB.connect(config).then(()=>{
// Ensure that we have successfully connected to the database
// before launching server
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 bright = '\x1b[1m'; // Bright (bold) style
const cyan = '\x1b[36m'; // Cyan color

View File

@@ -1,15 +1,9 @@
import {model as HomebrewModel } from './homebrew.model.js';
import {model as NotificationModel } from './notifications.model.js';
import express from 'express';
import Moment from 'moment';
import zlib from 'zlib';
import templateFn from '../client/template.js';
import HomebrewAPI from './homebrew.api.js';
import asyncHandler from 'express-async-handler';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
const router = express.Router();
const HomebrewModel = require('./homebrew.model.js').model;
const NotificationModel = require('./notifications.model.js').model;
const router = require('express').Router();
const Moment = require('moment');
const templateFn = require('../client/template.js');
const zlib = require('zlib');
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
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 */
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{
return res.json(req.brew);
router.get('/admin/lookup/:id', mw.adminOnly, async (req, res, next)=>{
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 */
@@ -91,25 +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;
return await HomebrewAPI.updateBrew(req, res);
});
/* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{
@@ -154,7 +144,6 @@ router.get('/admin/notification/all', async (req, res, next)=>{
try {
const notifications = await NotificationModel.getAll();
return res.json(notifications);
} catch (error) {
console.log('Error getting all notifications: ', error.message);
return res.status(500).json({ message: error.message });
@@ -162,6 +151,7 @@ router.get('/admin/notification/all', async (req, res, next)=>{
});
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
console.table(req.body);
try {
const notification = await NotificationModel.addNotification(req.body);
return res.status(201).json(notification);
@@ -192,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';
import HBApp from './app.js';
import {model as NotificationModel } from './notifications.model.js';
const supertest = require('supertest');
const app = supertest.agent(require('app.js').app)
.set('X-Forwarded-Proto', 'https');
// Mimic https responses to avoid being redirected all the time
const app = supertest.agent(HBApp).set('X-Forwarded-Proto', 'https');
const NotificationModel = require('./notifications.model.js').model;
describe('Tests for admin api', ()=>{
afterEach(()=>{

View File

@@ -1,41 +1,25 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
// 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}/..`);
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 config = require('./config.js');
const fs = require('fs-extra');
import api from './homebrew.api.js';
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = api;
import adminApi from './admin.api.js';
import vaultApi from './vault.api.js';
import GoogleActions from './googleActions.js';
import serveCompressedStaticAssets from './static-assets.mv.js';
import sanitizeFilename from 'sanitize-filename';
import asyncHandler from 'express-async-handler';
import templateFn from '../client/template.js';
import {model as HomebrewModel } from './homebrew.model.js';
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js');
const GoogleActions = require('./googleActions.js');
const serveCompressedStaticAssets = require('./static-assets.mv.js');
const sanitizeFilename = require('sanitize-filename');
const asyncHandler = require('express-async-handler');
const templateFn = require('./../client/template.js');
import { DEFAULT_BREW } from './brewDefaults.js';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
const { DEFAULT_BREW } = require('./brewDefaults.js');
//==== Middleware Imports ====//
import contentNegotiation from './middleware/content-negotiation.js';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import forceSSL from './forcessl.mw.js';
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
const sanitizeBrew = (brew, accessType)=>{
@@ -50,10 +34,10 @@ const sanitizeBrew = (brew, accessType)=>{
app.set('trust proxy', 1 /* number of proxies between user and server */)
app.use('/', serveCompressedStaticAssets(`build`));
app.use(contentNegotiation);
app.use(bodyParser.json({ limit: '25mb' }));
app.use(cookieParser());
app.use(forceSSL);
app.use(require('./middleware/content-negotiation.js'));
app.use(require('body-parser').json({ limit: '25mb' }));
app.use(require('cookie-parser')());
app.use(require('./forcessl.mw.js'));
//Account Middleware
app.use((req, res, next)=>{
@@ -73,14 +57,15 @@ app.use((req, res, next)=>{
});
app.use(homebrewApi);
app.use(adminApi);
app.use(vaultApi);
app.use(require('./admin.api.js'));
app.use(require('./vault.api.js'));
const welcomeText = fs.readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
const welcomeTextLegacy = fs.readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
const migrateText = fs.readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8');
const changelogText = fs.readFileSync('changelog.md', 'utf8');
const faqText = fs.readFileSync('faq.md', 'utf8');
const HomebrewModel = require('./homebrew.model.js').model;
const welcomeText = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
const welcomeTextLegacy = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
const migrateText = require('fs').readFileSync('client/homebrew/pages/homePage/migrate.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);};
@@ -494,7 +479,7 @@ const renderPage = async (req, res)=>{
deployment : config.get('heroku_app_name') ?? ''
};
const props = {
version : version,
version : require('./../package.json').version,
url : req.customUrl || req.originalUrl,
brew : req.brew,
brews : req.brews,
@@ -571,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
const DEFAULT_BREW = {
@@ -32,7 +32,7 @@ const DEFAULT_BREW_LOAD = _.defaults(
},
DEFAULT_BREW);
export {
module.exports = {
DEFAULT_BREW,
DEFAULT_BREW_LOAD
};

View File

@@ -1,7 +1,5 @@
import nconf from 'nconf';
export default nconf
.argv()
.env({ lowerCase: true })
.file('environment', { file: `config/${process.env.NODE_ENV}.json` })
.file('defaults', { file: 'config/default.json' });
module.exports = require('nconf')
.argv()
.env({ lowerCase: true })
.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
// connection.
import Mongoose from 'mongoose';
const Mongoose = require('mongoose');
const getMongoDBURL = (config)=>{
return config.get('mongodb_uri') ||
@@ -31,7 +31,7 @@ const connect = async (config)=>{
.catch((error)=>handleConnectionError(error));
};
export default {
connect,
disconnect
module.exports = {
connect : connect,
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(req.header('x-forwarded-proto') !== 'https') {
return res.redirect(302, `https://${req.get('Host')}${req.url}`);

View File

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

View File

@@ -1,20 +1,18 @@
/* eslint-disable max-lines */
import _ from 'lodash';
import {model as HomebrewModel} from './homebrew.model.js';
import express from 'express';
import zlib from 'zlib';
import GoogleActions from './googleActions.js';
import Markdown from '../shared/naturalcrit/markdown.js';
import yaml from 'js-yaml';
import asyncHandler from 'express-async-handler';
import { nanoid } from 'nanoid';
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
import checkClientVersion from './middleware/check-client-version.js';
const _ = require('lodash');
const HomebrewModel = require('./homebrew.model.js').model;
const router = require('express').Router();
const zlib = require('zlib');
const GoogleActions = require('./googleActions.js');
const Markdown = require('../shared/naturalcrit/markdown.js');
const yaml = require('js-yaml');
const asyncHandler = require('express-async-handler');
const { nanoid } = require('nanoid');
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
const router = express.Router();
const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js');
import { DEFAULT_BREW, DEFAULT_BREW_LOAD } from './brewDefaults.js';
import Themes from '../themes/themes.json' with { type: 'json' };
const Themes = require('../themes/themes.json');
const isStaticTheme = (renderer, themeName)=>{
return Themes[renderer]?.[themeName] !== undefined;
@@ -89,18 +87,8 @@ const api = {
// Get relevant IDs for the brew
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.
let stub = await HomebrewModel.get(accessMap[accessType])
let stub = await HomebrewModel.get(accessType === 'edit' ? { editId: id } : { shareId: id })
.catch((err)=>{
if(googleId) {
console.warn(`Unable to find document stub for ${accessType}Id ${id}`);
@@ -307,8 +295,9 @@ const api = {
req.params.id = currentTheme.theme;
req.params.renderer = currentTheme.renderer;
} else {
}
//=== Static Themes ===//
else {
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\");`;
completeSnippets.push(localSnippets);
@@ -475,7 +464,7 @@ const api = {
}
};
router.use('/api', checkClientVersion);
router.use('/api', require('./middleware/check-client-version.js'));
router.post('/api', asyncHandler(api.newBrew));
router.put('/api/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.put('/api/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
@@ -483,4 +472,4 @@ router.delete('/api/:id', asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
export default api;
module.exports = api;

View File

@@ -36,9 +36,8 @@ describe('Tests for api', ()=>{
}
});
google = require('./googleActions.js').default;
model = require('./homebrew.model.js').model;
api = require('./homebrew.api').default;
google = require('./googleActions.js');
model = require('./homebrew.model.js').model;
jest.mock('./googleActions.js');
google.authCheck = jest.fn(()=>'client');
@@ -55,6 +54,8 @@ describe('Tests for api', ()=>{
setHeader : jest.fn(()=>{})
};
api = require('./homebrew.api');
hbBrew = {
text : `brew text`,
style : 'hello yes i am css',

View File

@@ -1,8 +1,7 @@
import mongoose from 'mongoose';
import { nanoid } from 'nanoid';
import _ from 'lodash';
import zlib from 'zlib';
const mongoose = require('mongoose');
const { nanoid } = require('nanoid');
const _ = require('lodash');
const zlib = require('zlib');
const HomebrewSchema = mongoose.Schema({
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()
.catch((error)=>{throw 'Can not find brew';});
if(!_.isNil(brew.textBin)) { // Uncompress zipped text field
const unzipped = zlib.inflateRawSync(brew.textBin);
unzipped = zlib.inflateRawSync(brew.textBin);
brew.text = unzipped.toString();
}
return brew;
@@ -63,7 +62,7 @@ HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, f
const Homebrew = mongoose.model('Homebrew', HomebrewSchema);
export {
HomebrewSchema as schema,
Homebrew as model
module.exports = {
schema : HomebrewSchema,
model : Homebrew,
};

View File

@@ -1,8 +1,6 @@
import packageJSON from '../../package.json' with { type: "json" };
const version = packageJSON.version;
export default (req, res, next)=>{
module.exports = (req, res, next)=>{
const userVersion = req.get('Homebrewery-Version');
const version = require('../../package.json').version;
if(userVersion != version) {
return res.status(412).send({

View File

@@ -1,8 +1,8 @@
import config from '../config.js';
const config = require('../config.js');
const nodeEnv = config.get('node_env');
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
export default (req, res, next)=>{
module.exports = (req, res, next)=>{
const isImageRequest = req.get('Accept')?.split(',')
?.filter((h)=>!h.includes('q='))
?.every((h)=>/image\/.*/.test(h));

View File

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

View File

@@ -1,5 +1,5 @@
import mongoose from 'mongoose';
import _ from 'lodash';
const mongoose = require('mongoose');
const _ = require('lodash');
const NotificationSchema = new mongoose.Schema({
dismissKey : { type: String, unique: true, required: true },
@@ -56,7 +56,7 @@ NotificationSchema.statics.getAll = async function() {
const Notification = mongoose.model('Notification', NotificationSchema);
export {
NotificationSchema as schema,
Notification as model
module.exports = {
schema : NotificationSchema,
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
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';
import config from './config.js';
const jwt = require('jwt-simple');
// Load configuration values
const config = require('./config.js');
// Generate an Access Token for the given User ID
const generateAccessToken = (account)=>{
@@ -22,4 +24,6 @@ const generateAccessToken = (account)=>{
return token;
};
export default generateAccessToken;
module.exports = {
generateAccessToken : generateAccessToken
};

View File

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

View File

@@ -1,6 +1,6 @@
import _ from 'lodash';
import yaml from 'js-yaml';
import request from '../client/homebrew/utils/request-middleware.js';
const _ = require('lodash');
const yaml = require('js-yaml');
const request = require('../client/homebrew/utils/request-middleware.js');
const splitTextStyleAndMetadata = (brew)=>{
brew.text = brew.text.replaceAll('\r\n', '\n');
@@ -51,7 +51,7 @@ const fetchThemeBundle = async (obj, renderer, theme)=>{
}));
};
export {
module.exports = {
splitTextStyleAndMetadata,
printCurrentBrew,
fetchThemeBundle,

View File

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

View File

@@ -1,19 +1,19 @@
/* eslint-disable max-lines */
import _ from 'lodash';
import { Parser as MathParser } from 'expr-eval';
import { marked as Marked } from 'marked';
import MarkedExtendedTables from 'marked-extended-tables';
import { markedSmartypantsLite as MarkedSmartypantsLite } from 'marked-smartypants-lite';
import { gfmHeadingId as MarkedGFMHeadingId, resetHeadings as MarkedGFMResetHeadingIDs } from 'marked-gfm-heading-id';
import { markedEmoji as MarkedEmojis } from 'marked-emoji';
const _ = require('lodash');
const Marked = require('marked');
const MarkedExtendedTables = require('marked-extended-tables');
const { markedSmartypantsLite: MarkedSmartypantsLite } = require('marked-smartypants-lite');
const { gfmHeadingId: MarkedGFMHeadingId, resetHeadings: MarkedGFMResetHeadingIDs } = require('marked-gfm-heading-id');
const { markedEmoji: MarkedEmojis } = require('marked-emoji');
//Icon fonts included so they can appear in emoji autosuggest dropdown
import diceFont from '../../themes/fonts/iconFonts/diceFont.js';
import elderberryInn from '../../themes/fonts/iconFonts/elderberryInn.js';
import gameIcons from '../../themes/fonts/iconFonts/gameIcons.js';
import fontAwesome from '../../themes/fonts/iconFonts/fontAwesome.js';
const diceFont = require('../../themes/fonts/iconFonts/diceFont.js');
const elderberryInn = require('../../themes/fonts/iconFonts/elderberryInn.js');
const fontAwesome = require('../../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();
//Limit math features to simple items
@@ -854,7 +854,7 @@ const globalVarsList = {};
let varsQueue = [];
let globalPageNumber = 0;
const Markdown = {
module.exports = {
marked : Marked,
render : (rawBrewText, pageNumber=0)=>{
globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order
@@ -865,7 +865,6 @@ const Markdown = {
}
rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`);
const opts = Marked.defaults;
rawBrewText = opts.hooks.preprocess(rawBrewText);
@@ -936,6 +935,3 @@ const Markdown = {
return errors;
},
};
export default Markdown;

View File

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

View File

@@ -1,110 +1,200 @@
require('./splitPane.less');
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)=>{
const {
onDragFinish = ()=>{},
showDividerButtons = true
} = props;
getInitialState : function() {
return {
currentDividerPos : null,
windowWidth : 0,
isDragging : false,
moveSource : false,
moveBrew : false,
showMoveArrows : true
};
},
const [isDragging, setIsDragging] = useState(false);
const [dividerPos, setDividerPos] = useState(null);
const [moveSource, setMoveSource] = useState(false);
const [moveBrew, setMoveBrew] = useState(false);
const [showMoveArrows, setShowMoveArrows] = useState(true);
const [liveScroll, setLiveScroll] = useState(false);
pane1 : React.createRef(null),
pane2 : React.createRef(null),
useEffect(()=>{
const savedPos = window.localStorage.getItem(storageKey);
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2);
setLiveScroll(window.localStorage.getItem('liveScroll') === 'true');
window.addEventListener('resize', handleResize);
return ()=>window.removeEventListener('resize', handleResize);
}, []);
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x)));
//when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position
const handleResize = () =>setDividerPos(limitPosition(window.localStorage.getItem(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);
componentDidMount : function() {
const dividerPos = window.localStorage.getItem(this.props.storageKey);
if(dividerPos){
this.setState({
currentDividerPos : this.limitPosition(dividerPos, 0.1*(window.innerWidth-13), 0.9*(window.innerWidth-13)),
userSetDividerPos : dividerPos,
windowWidth : window.innerWidth
});
} else {
this.setState({
currentDividerPos : window.innerWidth / 2,
userSetDividerPos : window.innerWidth / 2
});
}
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();
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)=>{
if(!isDragging) return;
handleDown : function(e){
e.preventDefault();
setDividerPos(limitPosition(e.pageX));
};
this.setState({ isDragging: true });
//this.unFocus()
},
const liveScrollToggle = ()=>{
window.localStorage.setItem('liveScroll', String(!liveScroll));
setLiveScroll(!liveScroll);
};
handleMove : function(e){
if(!this.state.isDragging) return;
const renderMoveArrows = (showMoveArrows &&
<>
<div className='arrow left'
onClick={()=>setMoveSource(!moveSource)} >
<i className='fas fa-arrow-left' />
</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>
</>
);
e.preventDefault();
const newSize = this.limitPosition(e.pageX);
this.setState({
currentDividerPos : newSize,
userSetDividerPos : newSize
});
},
const renderDivider = (
<div className={`divider ${isDragging && 'dragging'}`} onPointerDown={handleDown}>
{showDividerButtons && renderMoveArrows}
<div className='dots'>
<i className='fas fa-circle' />
<i className='fas fa-circle' />
<i className='fas fa-circle' />
</div>
</div>
);
liveScrollToggle : function() {
window.localStorage.setItem('liveScroll', String(!this.state.liveScroll));
this.setState({ liveScroll: !this.state.liveScroll });
},
/*
unFocus : function() {
if(document.selection){
document.selection.empty();
}else{
window.getSelection().removeAllRanges();
}
},
*/
return (
<div className='splitPane' onPointerMove={handleMove} onPointerUp={handleUp}>
<Pane width={dividerPos} moveBrew={moveBrew} moveSource={moveSource} liveScroll={liveScroll} setMoveArrows={setShowMoveArrows}>
{props.children[0]}
setMoveArrows : function(newState) {
if(this.state.showMoveArrows != newState){
this.setState({
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>
{renderDivider}
<Pane isDragging={isDragging}>{props.children[1]}</Pane>
</div>
);
};
{this.renderDivider()}
<Pane isDragging={this.state.isDragging}>{this.props.children[1]}</Pane>
</div>;
}
});
const Pane = ({ width, children, isDragging, moveBrew, moveSource, liveScroll, setMoveArrows })=>{
const styles = width
? { flex: 'none', width: `${width}px` }
: { pointerEvents: isDragging ? 'none' : 'auto' }; //Disable mouse capture in the right pane; else dragging into the iframe drops the divider
const Pane = createClass({
displayName : 'Pane',
getDefaultProps : function() {
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 (
<div className='pane' style={styles}>
{React.cloneElement(children, { moveBrew, moveSource, liveScroll, setMoveArrows })}
</div>
);
};
return <div className={cx('pane', this.props.className)} style={styles}>
{this.props.children}
</div>;
}
});
module.exports = SplitPane;

View File

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

View File

@@ -21,7 +21,10 @@ html,body, #reactRoot{
*{
box-sizing : border-box;
}
.colorButton(@backgroundColor : @green){
button{
.button();
}
.button(@backgroundColor : @green){
.animate(background-color);
display : inline-block;
padding : 0.6em 1.2em;

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
}
@@ -25,9 +25,3 @@
:where(table){
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 */
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() {
const source = '<div>*Bold text*</div>';

View File

@@ -1,6 +1,6 @@
/* eslint-disable max-lines */
import Markdown from 'naturalcrit/markdown.js';
const Markdown = require('naturalcrit/markdown.js');
describe('Inline Definition Lists', ()=>{
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;
// Marked.js adds line returns after closing tags on some default tokens.

View File

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

View File

@@ -1,7 +1,7 @@
/* eslint-disable max-lines */
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.
// This removes those line returns for comparison sake.

View File

@@ -1,7 +1,7 @@
/* eslint-disable max-lines */
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.
// This removes those line returns for comparison sake.

View File

@@ -1,8 +1,8 @@
import supertest from 'supertest';
import HBApp from 'app.js';
const supertest = require('supertest');
// 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', ()=>{
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;
}\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.
// 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
// 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 = {
createFooterFunc : function(headerSize=1){

View File

@@ -93,4 +93,4 @@ const diceFont = {
'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'
};
export default elderberryInn;
module.exports = elderberryInn;

View File

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

View File

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