0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-27 11:43:09 +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
44 changed files with 1875 additions and 2632 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/ /*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./brewRenderer.less'); require('./brewRenderer.less');
const React = require('react'); const React = require('react');
const { useState, useRef, useCallback, useMemo } = React; const { useState, useRef, useCallback, memo } = React;
const _ = require('lodash'); const _ = require('lodash');
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js'); const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
@@ -16,7 +16,8 @@ const Frame = require('react-frame-component').default;
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
const { printCurrentBrew } = require('../../../shared/helpers.js'); const { printCurrentBrew } = require('../../../shared/helpers.js');
import { safeHTML } from './safeHTML.js'; const DOMPurify = require('dompurify');
const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false };
const PAGE_HEIGHT = 1056; const PAGE_HEIGHT = 1056;
@@ -28,7 +29,6 @@ const INITIAL_CONTENT = dedent`
<base target=_blank> <base target=_blank>
</head><body style='overflow: hidden'><div></div></body></html>`; </head><body style='overflow: hidden'><div></div></body></html>`;
//v=====----------------------< Brew Page Component >---------------------=====v// //v=====----------------------< Brew Page Component >---------------------=====v//
const BrewPage = (props)=>{ const BrewPage = (props)=>{
props = { props = {
@@ -36,18 +36,18 @@ const BrewPage = (props)=>{
index : 0, index : 0,
...props ...props
}; };
const cleanText = safeHTML(props.contents); const cleanText = props.contents; //DOMPurify.sanitize(props.contents, purifyConfig);
return <div className={props.className} id={`p${props.index + 1}`} style={props.style}> return <div className={props.className} id={`p${props.index + 1}`} >
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} /> <div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
</div>; </div>;
}; };
//v=====--------------------< Brew Renderer Component >-------------------=====v// //v=====--------------------< Brew Renderer Component >-------------------=====v//
let renderedPages = []; const renderedPages = [];
let rawPages = []; let rawPages = [];
const BrewRenderer = (props)=>{ const BrewRenderer = memo((props)=>{
props = { props = {
text : '', text : '',
style : '', style : '',
@@ -65,14 +65,8 @@ const BrewRenderer = (props)=>{
const [state, setState] = useState({ const [state, setState] = useState({
isMounted : false, isMounted : false,
visibility : 'hidden' visibility : 'hidden',
}); zoom : 100
const [displayOptions, setDisplayOptions] = useState({
zoomLevel : 100,
spread : 'single',
startOnRight : true,
pageShadows : true
}); });
const mainRef = useRef(null); const mainRef = useRef(null);
@@ -83,26 +77,6 @@ const BrewRenderer = (props)=>{
rawPages = props.text.split(/^\\page$/gm); rawPages = props.text.split(/^\\page$/gm);
} }
const scrollToHash = (hash)=>{
if(!hash) return;
const iframeDoc = document.getElementById('BrewRenderer').contentDocument;
let anchor = iframeDoc.querySelector(hash);
if(anchor) {
anchor.scrollIntoView({ behavior: 'smooth' });
} else {
// Use MutationObserver to wait for the element if it's not immediately available
new MutationObserver((mutations, obs)=>{
anchor = iframeDoc.querySelector(hash);
if(anchor) {
anchor.scrollIntoView({ behavior: 'smooth' });
obs.disconnect();
}
}).observe(iframeDoc, { childList: true, subtree: true });
}
};
const updateCurrentPage = useCallback(_.throttle((e)=>{ const updateCurrentPage = useCallback(_.throttle((e)=>{
const { scrollTop, clientHeight, scrollHeight } = e.target; const { scrollTop, clientHeight, scrollHeight } = e.target;
const totalScrollableHeight = scrollHeight - clientHeight; const totalScrollableHeight = scrollHeight - clientHeight;
@@ -131,9 +105,9 @@ const BrewRenderer = (props)=>{
}; };
const renderStyle = ()=>{ const renderStyle = ()=>{
const cleanStyle = props.style; //DOMPurify.sanitize(props.style, purifyConfig);
const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>'; 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: `${themeStyles} \n\n <style> ${cleanStyle} </style>` }} />;
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: cleanStyle }} />;
}; };
const renderPage = (pageText, index)=>{ const renderPage = (pageText, index)=>{
@@ -143,13 +117,7 @@ const BrewRenderer = (props)=>{
} else { } else {
pageText += `\n\n&nbsp;\n\\column\n&nbsp;`; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear) pageText += `\n\n&nbsp;\n\\column\n&nbsp;`; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
const html = Markdown.render(pageText, index); const html = Markdown.render(pageText, index);
return <BrewPage className='page' index={index} key={index} contents={html} />;
const styles = {
...(!displayOptions.pageShadows ? { boxShadow: 'none' } : {})
// Add more conditions as needed
};
return <BrewPage className='page' index={index} key={index} contents={html} style={styles} />;
} }
}; };
@@ -182,8 +150,6 @@ const BrewRenderer = (props)=>{
}; };
const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount" const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount"
scrollToHash(window.location.hash);
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
renderPages(); //Make sure page is renderable before showing renderPages(); //Make sure page is renderable before showing
setState((prevState)=>({ setState((prevState)=>({
@@ -199,25 +165,20 @@ const BrewRenderer = (props)=>{
document.dispatchEvent(new MouseEvent('click')); document.dispatchEvent(new MouseEvent('click'));
}; };
const handleDisplayOptionsChange = (newDisplayOptions)=>{ //Toolbar settings:
setDisplayOptions(newDisplayOptions); const handleZoom = (newZoom)=>{
}; setState((prevState)=>({
...prevState,
const pagesStyle = { zoom : newZoom
zoom : `${displayOptions.zoomLevel}%`, }));
columnGap : `${displayOptions.columnGap}px`,
rowGap : `${displayOptions.rowGap}px`
}; };
const styleObject = {}; const styleObject = {};
if(global.config.deployment) { if(global.config.deployment) {
styleObject.backgroundImage = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='40px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${global.config.deployment}</text></svg>")`; styleObject.backgroundImage = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='40px' width='200px'><text x='0' y='15' fill='white' font-size='20'>${global.config.deployment}</text></svg>")`;
} }
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
renderedPages = useMemo(()=>renderPages(), [props.text]);
return ( return (
<> <>
{/*render dummy page while iFrame is mounting.*/} {/*render dummy page while iFrame is mounting.*/}
@@ -235,7 +196,7 @@ const BrewRenderer = (props)=>{
<NotificationPopup /> <NotificationPopup />
</div> </div>
<ToolBar displayOptions={displayOptions} currentPage={props.currentBrewRendererPageNum} totalPages={rawPages.length} onDisplayOptionsChange={handleDisplayOptionsChange} /> <ToolBar onZoomChange={handleZoom} currentPage={props.currentBrewRendererPageNum} totalPages={rawPages.length}/>
{/*render in iFrame so broken code doesn't crash the site.*/} {/*render in iFrame so broken code doesn't crash the site.*/}
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT} <Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
@@ -253,10 +214,9 @@ const BrewRenderer = (props)=>{
{state.isMounted {state.isMounted
&& &&
<> <>
{renderedStyle} {renderStyle()}
<div lang={`${props.lang || 'en'}`} style={pagesStyle} className={ <div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}>
`pages ${displayOptions.startOnRight ? 'recto' : 'verso'} ${displayOptions.spread}` } > {renderPages()}
{renderedPages}
</div> </div>
</> </>
} }
@@ -264,6 +224,19 @@ const BrewRenderer = (props)=>{
</Frame> </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; module.exports = BrewRenderer;

View File

@@ -3,45 +3,13 @@
.brewRenderer { .brewRenderer {
overflow-y : scroll; overflow-y : scroll;
will-change : transform; will-change : transform;
padding-top : 60px; padding-top : 30px;
height : 100vh; height : 100vh;
&:has(.facing, .flow) {
padding : 60px 30px;
}
&.deployment { &.deployment {
background-color: darkred; background-color: darkred;
} }
:where(.pages) { :where(.pages) {
&.facing { margin : 30px 0px;
display: grid;
grid-template-columns: repeat(2, auto);
grid-template-rows: repeat(3, auto);
gap: 10px 10px;
justify-content: center;
&.recto .page:first-child {
// sets first page on 'right' ('recto') of the preview, as if for a Cover page.
// todo: add a checkbox to toggle this setting
grid-column-start: 2;
}
& :where(.page) {
margin-left: unset !important;
margin-right: unset !important;
}
}
&.flow {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-start;
& :where(.page) {
flex: 0 0 auto;
margin-left: unset !important;
margin-right: unset !important;
}
}
& > :where(.page) { & > :where(.page) {
width : 215.9mm; width : 215.9mm;
height : 279.4mm; height : 279.4mm;
@@ -50,9 +18,6 @@
margin-left : auto; margin-left : auto;
box-shadow : 1px 4px 14px #000000; box-shadow : 1px 4px 14px #000000;
} }
*[id] {
scroll-margin-top:100px;
}
} }
&::-webkit-scrollbar { &::-webkit-scrollbar {
width : 20px; width : 20px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -314,7 +314,7 @@ const Editor = createClass({
}, },
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){ brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
if(!window || !this.isText() || isJumping) if(!window || isJumping)
return; return;
// Get current brewRenderer scroll position and calculate target position // Get current brewRenderer scroll position and calculate target position

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -100,33 +100,35 @@ const HomePage = createClass({
return <div className='homePage sitePage'> return <div className='homePage sitePage'>
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' /> <Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
{this.renderNavbar()} {this.renderNavbar()}
<div className="content">
<SplitPane onDragFinish={this.handleSplitMove}> <div className='content'>
<Editor <SplitPane onDragFinish={this.handleSplitMove}>
ref={this.editor} <Editor
brew={this.state.brew} ref={this.editor}
onTextChange={this.handleTextChange} brew={this.state.brew}
renderer={this.state.brew.renderer} onTextChange={this.handleTextChange}
showEditButtons={false} renderer={this.state.brew.renderer}
snippetBundle={this.state.themeBundle.snippets} showEditButtons={false}
onCursorPageChange={this.handleEditorCursorPageChange} snippetBundle={this.state.themeBundle.snippets}
onViewPageChange={this.handleEditorViewPageChange} onCursorPageChange={this.handleEditorCursorPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum} onViewPageChange={this.handleEditorViewPageChange}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
/> currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
<BrewRenderer />
text={this.state.brew.text} <BrewRenderer
style={this.state.brew.style} text={this.state.brew.text}
renderer={this.state.brew.renderer} style={this.state.brew.style}
onPageChange={this.handleBrewRendererPageChange} renderer={this.state.brew.renderer}
currentEditorViewPageNum={this.state.currentEditorViewPageNum} onPageChange={this.handleBrewRendererPageChange}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum} currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
themeBundle={this.state.themeBundle} currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/> themeBundle={this.state.themeBundle}
</SplitPane> />
</SplitPane>
</div> </div>
<div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}> <div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}>
Save current <i className='fas fa-save' /> Save current <i className='fas fa-save' />
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -411,18 +411,19 @@ const VaultPage = (props)=>{
}; };
return ( return (
<div className='sitePage vaultPage'> <div className='vaultPage'>
<link href='/themes/V3/Blank/style.css' rel='stylesheet' /> <link href='/themes/V3/Blank/style.css' rel='stylesheet' />
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet' /> <link href='/themes/V3/5ePHB/style.css' rel='stylesheet' />
{renderNavItems()} {renderNavItems()}
<div className="content"> <div className='content'>
<SplitPane showDividerButtons={false}> <SplitPane showDividerButtons={false}>
<div className='form dataGroup'>{renderForm()}</div> <div className='form dataGroup'>{renderForm()}</div>
<div className='resultsContainer dataGroup'>
{renderSortBar()} <div className='resultsContainer dataGroup'>
{renderFoundBrews()} {renderSortBar()}
</div> {renderFoundBrews()}
</SplitPane> </div>
</SplitPane>
</div> </div>
</div> </div>
); );

View File

@@ -5,369 +5,373 @@
*:not(input) { user-select : none; } *:not(input) { user-select : none; }
.content .dataGroup { .content {
width : 100%;
height : 100%; height : 100%;
background : white; background : #2C3E50;
&.form .brewLookup { .dataGroup {
position : relative; width : 100%;
padding : 50px clamp(20px, 4vw, 50px); height : 100%;
background : white;
small { &.form .brewLookup {
font-size : 10pt; position : relative;
color : #555555; padding : 50px clamp(20px, 4vw, 50px);
a { color : #333333; } small {
} font-size : 10pt;
color : #555555;
code { a { color : #333333; }
padding-inline : 5px; }
font-family : monospace;
background : lightgrey;
border-radius : 5px;
}
h1, h2, h3, h4 { code {
font-family : 'CodeBold'; padding-inline : 5px;
letter-spacing : 2px; font-family : monospace;
} background : lightgrey;
border-radius : 5px;
}
legend { h1, h2, h3, h4 {
h3 { font-family : 'CodeBold';
margin-block : 30px 20px; letter-spacing : 2px;
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; text-align : center;
border-bottom : 2px solid; border-bottom : 2px solid;
} }
ul {
padding-inline : 30px 10px; .formContents {
li { position : relative;
margin-block : 5px; display : flex;
line-height : calc(1em + 5px); flex-direction : column;
list-style : disc;
label {
display : flex;
align-items : center;
margin : 10px 0;
} }
} select { margin : 0 10px; }
}
&::after { input {
position : absolute; margin : 0 10px;
top : 0;
right : 0;
left : 0;
display : block;
padding : 10px;
font-weight : 900;
color : white;
white-space : pre-wrap;
content : 'Error:\A At least one renderer should be enabled to make a search';
background : rgb(255, 60, 60);
opacity : 0;
transition : opacity 0.5s;
}
&:not(:has(input[type='checkbox']:checked))::after { opacity : 1; }
.formTitle { &:invalid { background : rgb(255, 188, 181); }
margin : 20px 0;
font-size : 30px;
color : black;
text-align : center;
border-bottom : 2px solid;
}
.formContents { &[type='checkbox'] {
position : relative; position : relative;
display : flex; display : inline-block;
flex-direction : column; 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;
label { &::before,&::after {
display : flex; position : absolute;
align-items : center; inset : 0;
margin : 10px 0; z-index : 5;
} padding-top : 2px;
select { margin : 0 10px; } text-align : center;
input {
margin : 0 10px;
&:invalid { background : rgb(255, 188, 181); }
&[type='checkbox'] {
position : relative;
display : inline-block;
width : 50px;
height : 30px;
font-family : 'WalterTurncoat';
font-size : 20px;
font-weight : 800;
color : white;
letter-spacing : 2px;
appearance : none;
background : red;
isolation : isolate;
border-radius : 5px;
&::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;
} }
&.firstPage { margin-right : -5px; } &::before {
display : block;
content : 'No';
}
&.lastPage { margin-left : -5px; } &::after {
display : none;
content : 'Yes';
}
&:checked {
background : green;
&::before { display : none; }
&::after { display : block; }
}
} }
} }
button { #searchButton {
width : max-content; 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 { @keyframes trailingDots {
@@ -384,7 +388,7 @@
// media query for when the page is smaller than 1079 px in width // media query for when the page is smaller than 1079 px in width
@media screen and (max-width : 1079px) { @media screen and (max-width : 1079px) {
.vaultPage { .vaultPage .content {
.dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; } .dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; }

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 777 B

1896
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"version": "3.16.0", "version": "3.16.0",
"engines": { "engines": {
"npm": "^10.2.x", "npm": "^10.2.x",
"node": "^20.18.x" "node": "^20.17.x"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -38,7 +38,6 @@
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace", "test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace", "test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
"test:route": "jest tests/routes/static-pages.test.js --verbose", "test:route": "jest tests/routes/static-pages.test.js --verbose",
"test:safehtml": "jest tests/html/safeHTML.test.js --verbose",
"phb": "node --experimental-require-module scripts/phb.js", "phb": "node --experimental-require-module scripts/phb.js",
"prod": "set NODE_ENV=production && npm run build", "prod": "set NODE_ENV=production && npm run build",
"postinstall": "npm run build", "postinstall": "npm run build",
@@ -87,10 +86,10 @@
] ]
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.26.0", "@babel/core": "^7.25.8",
"@babel/plugin-transform-runtime": "^7.25.9", "@babel/plugin-transform-runtime": "^7.25.7",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.25.8",
"@babel/preset-react": "^7.25.9", "@babel/preset-react": "^7.25.7",
"@googleapis/drive": "^8.14.0", "@googleapis/drive": "^8.14.0",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
@@ -116,28 +115,27 @@
"marked-smartypants-lite": "^1.0.2", "marked-smartypants-lite": "^1.0.2",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.7.3", "mongoose": "^8.7.1",
"nanoid": "3.3.4", "nanoid": "3.3.4",
"nconf": "^0.12.1", "nconf": "^0.12.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-frame-component": "^4.1.3", "react-frame-component": "^4.1.3",
"react-router-dom": "6.28.0", "react-router-dom": "6.26.2",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"superagent": "^10.1.1", "superagent": "^10.1.0",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git" "vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^3.1.1", "@stylistic/stylelint-plugin": "^3.1.1",
"eslint": "^9.14.0", "eslint": "^9.12.0",
"eslint-plugin-jest": "^28.9.0", "eslint-plugin-jest": "^28.8.3",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.1",
"globals": "^15.12.0", "globals": "^15.11.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^16.10.0", "stylelint": "^16.9.0",
"stylelint-config-recess-order": "^5.1.1", "stylelint-config-recess-order": "^5.1.1",
"stylelint-config-recommended": "^14.0.1", "stylelint-config-recommended": "^14.0.1",
"supertest": "^7.0.0" "supertest": "^7.0.0"

View File

@@ -5,10 +5,6 @@ const Moment = require('moment');
const templateFn = require('../client/template.js'); const templateFn = require('../client/template.js');
const zlib = require('zlib'); const zlib = require('zlib');
const HomebrewAPI = require('./homebrew.api.js');
const asyncHandler = require('express-async-handler');
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin'; process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3'; process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
@@ -70,8 +66,23 @@ router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
}); });
/* Searches for matching edit or share id, also attempts to partial match */ /* Searches for matching edit or share id, also attempts to partial match */
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{ router.get('/admin/lookup/:id', mw.adminOnly, async (req, res, next)=>{
return res.json(req.brew); HomebrewModel.findOne({
$or : [
{ editId: { $regex: req.params.id, $options: 'i' } },
{ shareId: { $regex: req.params.id, $options: 'i' } },
]
}).exec()
.then((brew)=>{
if(!brew) // No document found
return res.status(404).json({ error: 'Document not found' });
else
return res.json(brew);
})
.catch((err)=>{
console.error(err);
return res.status(500).json({ error: 'Internal Server Error' });
});
}); });
/* Find 50 brews that aren't compressed yet */ /* Find 50 brews that aren't compressed yet */
@@ -89,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 */ /* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{ router.put('/admin/compress/:id', (req, res)=>{
@@ -152,7 +144,6 @@ router.get('/admin/notification/all', async (req, res, next)=>{
try { try {
const notifications = await NotificationModel.getAll(); const notifications = await NotificationModel.getAll();
return res.json(notifications); return res.json(notifications);
} catch (error) { } catch (error) {
console.log('Error getting all notifications: ', error.message); console.log('Error getting all notifications: ', error.message);
return res.status(500).json({ message: error.message }); return res.status(500).json({ message: error.message });
@@ -160,6 +151,7 @@ router.get('/admin/notification/all', async (req, res, next)=>{
}); });
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{ router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
console.table(req.body);
try { try {
const notification = await NotificationModel.addNotification(req.body); const notification = await NotificationModel.addNotification(req.body);
return res.status(201).json(notification); return res.status(201).json(notification);

View File

@@ -87,18 +87,8 @@ const api = {
// Get relevant IDs for the brew // Get relevant IDs for the brew
const { id, googleId } = api.getId(req); const { id, googleId } = api.getId(req);
const accessMap = {
edit : { editId: id },
share : { shareId: id },
admin : {
$or : [
{ editId: id },
{ shareId: id },
] }
};
// Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine. // Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine.
let stub = await HomebrewModel.get(accessMap[accessType]) let stub = await HomebrewModel.get(accessType === 'edit' ? { editId: id } : { shareId: id })
.catch((err)=>{ .catch((err)=>{
if(googleId) { if(googleId) {
console.warn(`Unable to find document stub for ${accessType}Id ${id}`); console.warn(`Unable to find document stub for ${accessType}Id ${id}`);
@@ -305,8 +295,9 @@ const api = {
req.params.id = currentTheme.theme; req.params.id = currentTheme.theme;
req.params.renderer = currentTheme.renderer; req.params.renderer = currentTheme.renderer;
} else { }
//=== Static Themes ===// //=== Static Themes ===//
else {
const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client
const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`; const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`;
completeSnippets.push(localSnippets); completeSnippets.push(localSnippets);

View File

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

View File

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

View File

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

View File

@@ -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

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

View File

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