0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-23 23:03:01 +00:00

Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
be107939ca Bump react-frame-component from 4.1.3 to 5.2.7
Bumps [react-frame-component](https://github.com/ryanseddon/react-frame-component) from 4.1.3 to 5.2.7.
- [Release notes](https://github.com/ryanseddon/react-frame-component/releases)
- [Commits](https://github.com/ryanseddon/react-frame-component/compare/v4.1.3...v5.2.7)

---
updated-dependencies:
- dependency-name: react-frame-component
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-20 18:04:02 +00:00
214 changed files with 10795 additions and 23192 deletions

View File

@@ -10,7 +10,7 @@ orbs:
jobs:
build:
docker:
- image: cimg/node:20.18.0
- image: cimg/node:20.17.0
- image: mongo:4.4
working_directory: ~/homebrewery
@@ -64,6 +64,9 @@ jobs:
- run:
name: Test - Mustache Spans
command: npm run test:mustache-syntax
- run:
name: Test - Definition Lists
command: npm run test:definition-lists
- run:
name: Test - Hard Breaks
command: npm run test:hard-breaks

View File

@@ -5,15 +5,6 @@ updates:
schedule:
interval: daily
open-pull-requests-limit: 99
groups:
dev-dependencies:
dependency-type: "development"
patterns: ["*"]
update-types: ["patch", "minor"]
prod-dependencies:
dependency-type: "production"
patterns: ["*"]
update-types: ["patch", "minor"]
ignore:
- dependency-name: eslint
versions:

View File

@@ -47,7 +47,9 @@ Make an changes you need to `config/docker.json` then build the image. If it doe
"naturalcrit_url" : "local.naturalcrit.com:8010",
"secret" : "secret",
"web_port" : 8000,
"enable_v3" : true,
"mongodb_uri": "mongodb://172.17.0.2/homebrewery",
"enable_themes" : true,
}
```
@@ -88,13 +90,6 @@ docker run --name homebrewery-mongodb -d --restart unless-stopped -v mongodata:/
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v $(pwd)/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
```
**NOTE:** If you are running from the Windows command line, this will not work as `$(pwd)` is not valid syntax. Use this command instead:
```shell
# Make sure you run this in the homebrewery directory
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v %cd%/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
```
## Updating the Image
When Homebrewery code updates, your docker container will not automatically follow the changes. To do so you will need to rebuild your homebrewery image.
@@ -122,9 +117,3 @@ docker-compose build homebrewery
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v $(pwd)/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
```
**NOTE:** If you are running from the Windows command line, this will not work as `$(pwd)` is not valid syntax. Use this command instead:
```shell
# Make sure you run this in the homebrewery directory
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v %cd%/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
```

View File

@@ -75,9 +75,8 @@ it using the two commands:
1. `npm install`
1. `npm start`
When the Homebrewery server is started for the first time, it will modify the database to create the indexes required for better Homebrewery performance. This may take a few moments to complete for each index, dependent on how much content is in your local database - a brand new, empty database should be done in seconds.
On completion, you should be able to go to [http://localhost:8000](http://localhost:8000) in your browser and use The Homebrewery offline.
You should now be able to go to [http://localhost:8000](http://localhost:8000)
in your browser and use The Homebrewery offline.
If you had any issue at all, here are some links that may be useful:
- [Course](https://learn.mongodb.com/courses/m103-basic-cluster-administration) on cluster administration, useful for beginners
@@ -145,5 +144,3 @@ your contribution to the project, please join our [gitter chat][gitter-url].
[github-mark-duplicate-url]: https://docs.github.com/en/free-pro-team@latest/github/managing-your-work-on-github/about-duplicate-issues-and-pull-requests
[github-pr-docs-url]: https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request
[gitter-url]: https://gitter.im/naturalcrit/Lobby

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,20 @@
import './admin.less';
import React, { useEffect, useState } from 'react';
import BrewUtils from './brewUtils/brewUtils.jsx';
import NotificationUtils from './notificationUtils/notificationUtils.jsx';
const BrewUtils = require('./brewUtils/brewUtils.jsx');
const NotificationUtils = require('./notificationUtils/notificationUtils.jsx');
import AuthorUtils from './authorUtils/authorUtils.jsx';
import LockTools from './lockTools/lockTools.jsx';
const tabGroups = ['brew', 'notifications', 'authors', 'locks'];
const ADMIN_TAB = 'HB_adminPage_currentTab';
const tabGroups = ['brew', 'notifications', 'authors'];
const Admin = ()=>{
const [currentTab, setCurrentTab] = useState('');
const [currentTab, setCurrentTab] = useState('brew');
useEffect(()=>{
setCurrentTab(localStorage.getItem(ADMIN_TAB) || 'brew');
setCurrentTab(localStorage.getItem('hbAdminTab'));
}, []);
useEffect(()=>{
localStorage.setItem(ADMIN_TAB, currentTab);
localStorage.setItem('hbAdminTab', currentTab);
}, [currentTab]);
return (
@@ -43,7 +40,6 @@ const Admin = ()=>{
{currentTab === 'brew' && <BrewUtils />}
{currentTab === 'notifications' && <NotificationUtils />}
{currentTab === 'authors' && <AuthorUtils />}
{currentTab === 'locks' && <LockTools />}
</main>
</div>
);

View File

@@ -3,7 +3,6 @@
@import 'naturalcrit/styles/animations.less';
@import 'naturalcrit/styles/colors.less';
@import 'naturalcrit/styles/tooltip.less';
@import './themes/fonts/iconFonts/fontAwesome.less';
@import 'font-awesome/css/font-awesome.css';

View File

@@ -84,4 +84,4 @@ const authorLookup = ()=>{
);
};
export default authorLookup;
module.exports = authorLookup;

View File

@@ -20,7 +20,7 @@
}
button {
width : 50px;
width: 50px;
i { margin-right : 10px; }
}

View File

@@ -10,4 +10,4 @@ const authorUtils = ()=>{
);
};
export default authorUtils;
module.exports = authorUtils;

View File

@@ -1,8 +1,9 @@
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
const React = require('react');
const createClass = require('create-react-class');
const BrewCleanup = createReactClass({
const request = require('superagent');
const BrewCleanup = createClass({
displayName : 'BrewCleanup',
getDefaultProps(){
return {};
@@ -68,4 +69,4 @@ const BrewCleanup = createReactClass({
}
});
export default BrewCleanup;
module.exports = BrewCleanup;

View File

@@ -1,8 +1,8 @@
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
const React = require('react');
const createClass = require('create-react-class');
const request = require('superagent');
const BrewCompress = createReactClass({
const BrewCompress = createClass({
displayName : 'BrewCompress',
getDefaultProps(){
return {};
@@ -85,4 +85,4 @@ const BrewCompress = createReactClass({
}
});
export default BrewCompress;
module.exports = BrewCompress;

View File

@@ -1,11 +1,12 @@
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
import cx from 'classnames';
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
import Moment from 'moment';
const request = require('superagent');
const Moment = require('moment');
const BrewLookup = createReactClass({
const BrewLookup = createClass({
getDefaultProps() {
return {};
},
@@ -109,4 +110,4 @@ const BrewLookup = createReactClass({
}
});
export default BrewLookup;
module.exports = BrewLookup;

View File

@@ -1,14 +1,15 @@
import React from 'react';
import './brewUtils.less';
const React = require('react');
const createClass = require('create-react-class');
require('./brewUtils.less');
import BrewCleanup from './brewCleanup/brewCleanup.jsx';
import BrewLookup from './brewLookup/brewLookup.jsx';
import BrewCompress from './brewCompress/brewCompress.jsx';
import Stats from './stats/stats.jsx';
const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
const BrewLookup = require('./brewLookup/brewLookup.jsx');
const BrewCompress = require ('./brewCompress/brewCompress.jsx');
const Stats = require('./stats/stats.jsx');
const BrewUtils = ()=>{
return (
<>
const BrewUtils = createClass({
render : function(){
return <>
<Stats />
<hr />
<BrewLookup />
@@ -16,7 +17,8 @@ const BrewUtils = ()=>{
<BrewCleanup />
<hr />
<BrewCompress />
</>
);
};
export default BrewUtils;
</>;
}
});
module.exports = BrewUtils;

View File

@@ -1,8 +1,9 @@
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
const React = require('react');
const createClass = require('create-react-class');
const Stats = createReactClass({
const request = require('superagent');
const Stats = createClass({
displayName : 'Stats',
getDefaultProps(){
return {};
@@ -42,4 +43,4 @@ const Stats = createReactClass({
}
});
export default Stats;
module.exports = Stats;

View File

@@ -1,342 +0,0 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
import './lockTools.less';
import React from 'react';
import createReactClass from 'create-react-class';
import request from '../../homebrew/utils/request-middleware.js';
const LockTools = createReactClass({
displayName : 'LockTools',
getInitialState : function() {
return {
fetching : false,
reviewCount : 0
};
},
componentDidMount : function() {
this.updateReviewCount();
},
updateReviewCount : async function() {
const newCount = await request.get('/api/lock/count')
.then((res)=>{return res.body?.count || 'Unknown';});
if(newCount != this.state.reviewCount){
this.setState({
reviewCount : newCount
});
}
},
updateLockData : function(lock){
this.setState({
lock : lock
});
},
render : function() {
return <div className='lockTools'>
<h2>Lock Count</h2>
<p>Number of brews currently locked: {this.state.reviewCount}</p>
<button onClick={this.updateReviewCount}>REFRESH</button>
<hr />
<LockTable title='Locked Brews' text='Total Locked Brews' resultName='lockedDocuments' fetchURL='/api/locks' propertyNames={['shareId', 'title']} loadBrew={this.updateLockData} ></LockTable>
<hr />
<LockTable title='Brews Awaiting Review' text='Total Reviews Waiting' resultName='reviewDocuments' fetchURL='/api/lock/reviews' propertyNames={['shareId', 'title']} loadBrew={this.updateLockData} ></LockTable>
<hr />
<LockBrew key={this.state.lock?.key || 0} lock={this.state.lock}></LockBrew>
<hr />
<div style={{ columns: 2 }}>
<LockLookup title='Unlock Brew' fetchURL='/api/unlock' updateFn={this.updateReviewCount}></LockLookup>
<LockLookup title='Clear Review Request' fetchURL='/api/lock/review/remove'></LockLookup>
</div>
<hr />
</div>;
}
});
const LockBrew = createReactClass({
displayName : 'LockBrew',
getInitialState : function() {
// Default values
return {
brewId : this.props.lock?.shareId || '',
code : this.props.lock?.code || 455,
editMessage : this.props.lock?.editMessage || '',
shareMessage : this.props.lock?.shareMessage || 'This Brew has been locked.',
result : {},
overwrite : false,
};
},
handleChange : function(e, varName) {
const output = {};
output[varName] = e.target.value;
this.setState(output);
},
submit : function(e){
e.preventDefault();
if(!this.state.editMessage) return;
const newLock = {
overwrite : this.state.overwrite,
code : parseInt(this.state.code) || 100,
editMessage : this.state.editMessage,
shareMessage : this.state.shareMessage,
applied : new Date
};
request.post(`/api/lock/${this.state.brewId}`)
.send(newLock)
.set('Content-Type', 'application/json')
.then((response)=>{
this.setState({ result: response.body });
})
.catch((err)=>{
this.setState({ result: err.response.body });
});
},
renderInput : function (name) {
return <input type='text' name={name} value={this.state[name]} onChange={(e)=>this.handleChange(e, name)} autoComplete='off' required/>;
},
renderResult : function(){
return <>
<h3>Result:</h3>
<table>
<tbody>
{Object.keys(this.state.result).map((key, idx)=>{
return <tr key={`${idx}-row`}>
<td key={`${idx}-key`}>{key}</td>
<td key={`${idx}-value`}>{this.state.result[key].toString()}
</td>
</tr>;
})}
</tbody>
</table>
</>;
},
render : function() {
return <div className='lockBrew'>
<div className='lockForm'>
<h2>Lock Brew</h2>
<form onSubmit={this.submit}>
<label>
ID:
{this.renderInput('brewId')}
</label>
<br />
<label>
Error Code:
{this.renderInput('code')}
</label>
<br />
<label>
Private Message:
{this.renderInput('editMessage')}
</label>
<br />
<label>
Public Message:
{this.renderInput('shareMessage')}
</label>
<br />
<label className='checkbox'>
Overwrite
<input name='overwrite' className='checkbox' type='checkbox' value={this.state.overwrite} onClick={()=>{return this.setState((prevState)=>{return { overwrite: !prevState.overwrite };});}} />
</label>
<label>
<input type='submit' />
</label>
</form>
{this.state.result && this.renderResult()}
</div>
<div className='lockSuggestions'>
<h2>Suggestions</h2>
<div className='lockCodes'>
<h3>Codes</h3>
<ul>
<li>455 - Generic Lock</li>
<li>456 - Copyright issues</li>
<li>457 - Confidential Information Leakage</li>
<li>458 - Sensitive Personal Information</li>
<li>459 - Defamation or Libel</li>
<li>460 - Hate Speech or Discrimination</li>
<li>461 - Illegal Activities</li>
<li>462 - Malware or Phishing</li>
<li>463 - Plagiarism</li>
<li>465 - Misrepresentation</li>
<li>466 - Inappropriate Content</li>
</ul>
</div>
<div className='lockMessages'>
<h3>Messages</h3>
<ul>
<li><b>Private Message:</b> This is the private message that is ONLY displayed to the authors of the locked brew. This message MUST specify exactly what actions must be taken in order to have the brew unlocked.</li>
<li><b>Public Message:</b> This is the public message that is displayed to the EVERYONE that attempts to view the locked brew.</li>
</ul>
</div>
</div>
</div>;
}
});
const LockTable = createReactClass({
displayName : 'LockTable',
getDefaultProps : function() {
return {
title : '',
text : '',
fetchURL : '/api/locks',
resultName : '',
propertyNames : ['shareId'],
loadBrew : ()=>{}
};
},
getInitialState : function() {
return {
result : '',
error : '',
searching : false
};
},
lockKey : React.createRef(0),
clickFn : function (){
this.setState({ searching: true, error: null });
request.get(this.props.fetchURL)
.then((res)=>this.setState({ result: res.body }))
.catch((err)=>this.setState({ result: err.response.body }))
.finally(()=>{
this.setState({ searching: false });
});
},
updateBrewLockData : function (lockData){
this.lockKey.current++;
const brewData = {
key : this.lockKey.current,
shareId : lockData.shareId,
code : lockData.lock.code,
editMessage : lockData.lock.editMessage,
shareMessage : lockData.lock.shareMessage
};
this.props.loadBrew(brewData);
},
render : function () {
return <>
<div className='brewsAwaitingReview'>
<div className='brewBlock'>
<h2>{this.props.title}</h2>
<button onClick={this.clickFn}>
REFRESH
<i className={`fas ${!this.state.searching ? 'fa-search' : 'fa-spin fa-spinner'}`} />
</button>
</div>
{this.state.result[this.props.resultName] &&
<>
<p>{this.props.text}: {this.state.result[this.props.resultName].length}</p>
<table className='lockTable'>
<thead>
<tr>
{this.props.propertyNames.map((name, idx)=>{
return <th key={idx}>{name}</th>;
})}
<th>clip</th>
<th>load</th>
</tr>
</thead>
<tbody>
{this.state.result[this.props.resultName].map((result, resultIdx)=>{
return <tr className='row' key={`${resultIdx}-row`}>
{this.props.propertyNames.map((name, nameIdx)=>{
return <td key={`${resultIdx}-${nameIdx}`}>
{result[name].toString()}
</td>;
})}
<td className='icon' title='Copy ID to Clipboard' onClick={()=>{navigator.clipboard.writeText(result.shareId.toString());}}><i className='fa-regular fa-clipboard'></i></td>
<td className='icon' title='View Lock details' onClick={()=>{this.updateBrewLockData(result);}}><i className='fa-regular fa-circle-down'></i></td>
</tr>;
})}
</tbody>
</table>
</>
}
</div>
</>;
}
});
const LockLookup = createReactClass({
displayName : 'LockLookup',
getDefaultProps : function() {
return {
fetchURL : '/api/lookup'
};
},
getInitialState : function() {
return {
query : '',
result : '',
error : '',
searching : false
};
},
handleChange(e){
this.setState({ query: e.target.value });
},
clickFn(){
this.setState({ searching: true, error: null });
request.put(`${this.props.fetchURL}/${this.state.query}`)
.then((res)=>this.setState({ result: res.body }))
.catch((err)=>this.setState({ result: err.response.body }))
.finally(()=>{
this.setState({ searching: false });
});
},
renderResult : function(){
return <div className='lockLookup'>
<h3>Result:</h3>
<table>
<tbody>
{Object.keys(this.state.result).map((key, idx)=>{
return <tr key={`${idx}-row`}>
<td key={`${idx}-key`}>{key}</td>
<td key={`${idx}-value`}>{this.state.result[key].toString()}
</td>
</tr>;
})}
</tbody>
</table>
</div>;
},
render : function() {
return <div className='brewLookup'>
<h2>{this.props.title}</h2>
<input type='text' value={this.state.query} onChange={this.handleChange} placeholder='share id' />
<button onClick={this.clickFn}>
<i className={`fas ${!this.state.searching ? 'fa-search' : 'fa-spin fa-spinner'}`} />
</button>
{this.state.error
&& <div className='error'>{this.state.error.toString()}</div>
}
{this.state.result && this.renderResult()}
</div>;
}
});
export default LockTools;

View File

@@ -1,66 +0,0 @@
.lockTools {
.lockBrew {
columns : 2;
.lockForm {
break-inside : avoid;
label {
display : inline-block;
width : 100%;
line-height : 2.25em;
text-align : right;
input {
float : right;
width : 65%;
margin-left : 10px;
}
&.checkbox {
line-height: 1.5em;
input {
width : 1.5em;
height : 1.5em;
}
}
}
}
.lockSuggestions {
line-height : 1.2em;
break-inside : avoid;
columns : 2;
h2 { column-span : all; }
h3 { margin-top : 0px; }
b { font-weight : 600; }
.lockCodes { break-inside : avoid; }
}
}
.lockTable {
cursor : default;
break-inside : avoid;
.row:hover {
color : #000000;
background-color : #CCCCCC;
}
.icon {
cursor : pointer;
&:hover { text-shadow : 0px 0px 6px black; }
}
}
th, td {
padding : 4px 10px;
text-align : center;
}
table, td { border : 1px solid #333333; }
.brewLookup {
min-height : 175px;
break-inside : avoid;
h2 { margin-top : 0px; }
}
button i { padding-left : 5px; }
}

View File

@@ -1,6 +1,7 @@
import './notificationAdd.less';
import React, { useState, useRef } from 'react';
import request from 'superagent';
require('./notificationAdd.less');
const React = require('react');
const { useState, useRef } = require('react');
const request = require('superagent');
const NotificationAdd = ()=>{
const [notificationResult, setNotificationResult] = useState(null);
@@ -105,4 +106,4 @@ const NotificationAdd = ()=>{
);
};
export default NotificationAdd;
module.exports = NotificationAdd;

View File

@@ -18,20 +18,22 @@
margin-bottom : unset;
font-family : monospace;
&[type='date'] { width : 14ch; }
&[type="date"] {
width:14ch;
}
}
textarea {
width : 50ch;
min-height : 7em;
max-height : 20em;
padding : 10px;
resize : vertical;
padding : 10px;
}
}
button {
width : 200px;
width: 200px;
i { margin-right : 10px; }
}

View File

@@ -1,7 +1,9 @@
import './notificationLookup.less';
import React, { useState } from 'react';
import request from 'superagent';
import Moment from 'moment';
require('./notificationLookup.less');
const React = require('react');
const { useState } = require('react');
const request = require('superagent');
const Moment = require('moment');
const NotificationDetail = ({ notification, onDelete })=>(
<>
@@ -100,4 +102,4 @@ const NotificationLookup = ()=>{
);
};
export default NotificationLookup;
module.exports = NotificationLookup;

View File

@@ -1,6 +1,6 @@
.notificationLookup {
width : 450px;
height : fit-content;
height : fit-content;
.noNotification { margin-block : 20px; }
.notificationList {

View File

@@ -1,6 +1,7 @@
import React from 'react';
import NotificationLookup from './notificationLookup/notificationLookup.jsx';
import NotificationAdd from './notificationAdd/notificationAdd.jsx';
const React = require('react');
const NotificationLookup = require('./notificationLookup/notificationLookup.jsx');
const NotificationAdd = require('./notificationAdd/notificationAdd.jsx');
const NotificationUtils = ()=>{
return (
@@ -11,4 +12,4 @@ const NotificationUtils = ()=>{
);
};
export default NotificationUtils;
module.exports = NotificationUtils;

View File

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

View File

@@ -1,9 +1,9 @@
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import './combobox.less';
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
require('./combobox.less');
const Combobox = createReactClass({
const Combobox = createClass({
displayName : 'Combobox',
getDefaultProps : function() {
return {
@@ -126,4 +126,4 @@ const Combobox = createReactClass({
}
});
export default Combobox;
module.exports = Combobox;

View File

@@ -27,7 +27,7 @@
position : relative;
padding : 5px;
margin : 0 3px;
font-family : 'Open Sans';
font-family : "Open Sans";
font-size : 11px;
cursor : default;
&:hover {

View File

@@ -1,32 +1,30 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
import './brewRenderer.less';
import React, { useState, useRef, useMemo, useEffect } from 'react';
import _ from 'lodash';
require('./brewRenderer.less');
const React = require('react');
const { useState, useRef, useMemo, useEffect } = React;
const _ = require('lodash');
import MarkdownLegacy from 'markdownLegacy.js';
import Markdown from 'markdown.js';
import ErrorBar from './errorBar/errorBar.jsx';
import ToolBar from './toolBar/toolBar.jsx';
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
import Markdown from 'naturalcrit/markdown.js';
const ErrorBar = require('./errorBar/errorBar.jsx');
const ToolBar = require('./toolBar/toolBar.jsx');
//TODO: move to the brew renderer
import RenderWarnings from 'client/components/renderWarnings/renderWarnings.jsx';
import NotificationPopup from './notificationPopup/notificationPopup.jsx';
import Frame from 'react-frame-component';
import dedent from 'dedent';
import { printCurrentBrew } from '../../../shared/helpers.js';
const RenderWarnings = require('homebrewery/renderWarnings/renderWarnings.jsx');
const NotificationPopup = require('./notificationPopup/notificationPopup.jsx');
const Frame = require('react-frame-component').default;
const dedent = require('dedent-tabs').default;
const { printCurrentBrew } = require('../../../shared/helpers.js');
import HeaderNav from './headerNav/headerNav.jsx';
import { safeHTML } from './safeHTML.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m;
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?: *{[^\n{}]*})?$)/m;
const PAGE_HEIGHT = 1056;
const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
const INITIAL_CONTENT = dedent`
<!DOCTYPE html><html><head>
<link href="//use.fontawesome.com/releases/v6.5.1/css/all.css" rel="stylesheet" type="text/css" />
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
<link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
<base target=_blank>
@@ -40,7 +38,7 @@ const BrewPage = (props)=>{
index : 0,
...props
};
const pageRef = useRef(null);
const pageRef = useRef(null);
const cleanText = safeHTML(props.contents);
useEffect(()=>{
@@ -116,24 +114,16 @@ const BrewRenderer = (props)=>{
zoomLevel : 100,
spread : 'single',
startOnRight : true,
pageShadows : true,
rowGap : 5,
columnGap : 10,
pageShadows : true
});
//useEffect to store or gather toolbar state from storage
useEffect(()=>{
const toolbarState = JSON.parse(window.localStorage.getItem(TOOLBAR_STATE_KEY));
toolbarState && setDisplayOptions(toolbarState);
}, []);
const [headerState, setHeaderState] = useState(false);
const mainRef = useRef(null);
const pagesRef = useRef(null);
if(props.renderer == 'legacy') {
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
rawPages = props.text.split('\\page');
} else {
rawPages = props.text.split(PAGEBREAK_REGEX_V3);
}
@@ -190,26 +180,23 @@ const BrewRenderer = (props)=>{
let attributes = {};
if(props.renderer == 'legacy') {
pageText.replace(COLUMNBREAK_REGEX_LEGACY, '```\n````\n'); // Allow Legacy brews to use `\column(break)`
const html = MarkdownLegacy.render(pageText);
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
} else {
if(pageText.startsWith('\\page')) {
const firstLineTokens = Markdown.marked.lexer(pageText.split('\n', 1)[0])[0].tokens;
const injectedTags = firstLineTokens?.find((obj)=>obj.injectedTags !== undefined)?.injectedTags;
const injectedTags = firstLineTokens.find((obj)=>obj.injectedTags !== undefined)?.injectedTags;
if(injectedTags) {
styles = { ...styles, ...injectedTags.styles };
styles = _.mapKeys(styles, (v, k)=>k.startsWith('--') ? k : _.camelCase(k)); // Convert CSS to camelCase for React
styles = _.mapKeys(styles, (v, k) => k.startsWith('--') ? k : _.camelCase(k)); // Convert CSS to camelCase for React
classes = [classes, injectedTags.classes].join(' ').trim();
attributes = injectedTags.attributes;
}
pageText = pageText.includes('\n') ? pageText.substring(pageText.indexOf('\n') + 1) : ''; // Remove the \page line
}
// DO NOT REMOVE!!! REQUIRED FOR BACKWARDS COMPATIBILITY WITH NON-UPGRADABLE VERSIONS OF CHROME.
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);
return <BrewPage className={classes} index={index} key={index} contents={html} style={styles} attributes={attributes} onVisibilityChange={handlePageVisibilityChange} />;
@@ -285,7 +272,6 @@ const BrewRenderer = (props)=>{
const handleDisplayOptionsChange = (newDisplayOptions)=>{
setDisplayOptions(newDisplayOptions);
localStorage.setItem(TOOLBAR_STATE_KEY, JSON.stringify(newDisplayOptions));
};
const pagesStyle = {
@@ -294,6 +280,12 @@ const BrewRenderer = (props)=>{
rowGap : `${displayOptions.rowGap}px`
};
const styleObject = {};
if(global.config.deployment) {
styleObject.backgroundImage = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='40px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${global.config.deployment}</text></svg>")`;
}
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
renderedPages = useMemo(()=>renderPages(), [props.text, displayOptions]);
@@ -322,9 +314,10 @@ const BrewRenderer = (props)=>{
contentDidMount={frameDidMount}
onClick={()=>{emitClick();}}
>
<div className='brewRenderer'
<div className={`brewRenderer ${global.config.deployment && 'deployment'}`}
onKeyDown={handleControlKeys}
tabIndex={-1}
style={ styleObject }
>
{/* Apply CSS from Style tab and render pages from Markdown tab */}
@@ -344,4 +337,4 @@ const BrewRenderer = (props)=>{
);
};
export default BrewRenderer;
module.exports = BrewRenderer;

View File

@@ -1,38 +1,43 @@
@import (multiple, less) 'shared/naturalcrit/styles/reset.less';
.brewRenderer {
height : 100vh;
padding-top : 60px;
overflow-y : scroll;
will-change : transform;
&:has(.facing, .flow) { padding : 60px 30px; }
padding-top : 60px;
height : 100vh;
&:has(.facing, .flow) {
padding : 60px 30px;
}
&.deployment {
background-color: darkred;
}
:where(.pages) {
&.facing {
display : grid;
grid-template-rows : repeat(3, auto);
grid-template-columns : repeat(2, auto);
gap : 10px 10px;
justify-content : safe center;
display: grid;
grid-template-columns: repeat(2, auto);
grid-template-rows: repeat(3, auto);
gap: 10px 10px;
justify-content: safe center;
&.recto .page:first-child {
// sets first page on 'right' ('recto') of the preview, as if for a Cover page.
// todo: add a checkbox to toggle this setting
grid-column-start : 2;
grid-column-start: 2;
}
& :where(.page) {
margin-right : unset !important;
margin-left : unset !important;
margin-left: unset !important;
margin-right: unset !important;
}
}
&.flow {
display : flex;
flex-wrap : wrap;
gap : 10px;
justify-content : safe center;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: safe center;
& :where(.page) {
flex : 0 0 auto;
margin-right : unset !important;
margin-left : unset !important;
flex: 0 0 auto;
margin-left: unset !important;
margin-right: unset !important;
}
}
@@ -45,7 +50,9 @@
margin-left : auto;
box-shadow : 1px 4px 14px #000000;
}
*[id] { scroll-margin-top : 100px; }
*[id] {
scroll-margin-top:100px;
}
}
&::-webkit-scrollbar {
width : 20px;
@@ -67,18 +74,16 @@
@media print {
.toolBar { display : none; }
.brewRenderer {
height : 100%;
padding : unset;
overflow-y : unset;
&:has(.facing, .flow) {
padding : unset;
}
height : 100%;
padding-top : unset;
overflow-y : unset;
.pages {
margin : 0px;
zoom : 100% !important;
display : block;
margin : 0px;
zoom: 100% !important;
& > .page { box-shadow : unset; }
}
}
.headerNav { visibility : hidden; }
.headerNav {
visibility: hidden;
}
}

View File

@@ -1,5 +1,5 @@
import './errorBar.less';
import React from 'react';
require('./errorBar.less');
const React = require('react');
import Dialog from '../../../components/dialog.jsx';
@@ -50,4 +50,4 @@ const ErrorBar = (props)=>{
);
};
export default ErrorBar;
module.exports = ErrorBar;

View File

@@ -1,7 +1,7 @@
import './headerNav.less';
require('./headerNav.less');
import React from 'react';
import _ from 'lodash';
import * as React from 'react';
import * as _ from 'lodash';
const MAX_TEXT_LENGTH = 40;
@@ -25,7 +25,7 @@ const HeaderNav = React.forwardRef(({}, pagesRef)=>{
'.toc' : ()=>{ return 'Table of Contents'; },
};
const getHeaderContent = (el)=>el.querySelector('h1')?.textContent;
const getHeaderContent = el => el.querySelector('h1')?.textContent;
const topLevelPageSelector = Object.keys(topLevelPages).join(',');
@@ -52,23 +52,25 @@ const HeaderNav = React.forwardRef(({}, pagesRef)=>{
depth : 7, // All unmatched elements with IDs are set to the maximum depth (7)
text : el.textContent, // Use `textContent` because `innerText` is affected by rendering, e.g. 'content-visibility: auto'
link : el.id
};
}
if(el.classList.contains('page')) {
let text = `Page ${el.id.slice(1)}`; // Get the page # by trimming off the 'p' from the ID
const pageType = Object.keys(topLevelPages).find((pageType)=>el.querySelector(pageType));
if(pageType)
text += ` - ${topLevelPages[pageType](el, pageType)}`; // If a Top Level Page, add extra label
const pageType = Object.keys(topLevelPages).find(pageType => el.querySelector(pageType));
if (pageType)
text += ` - ${topLevelPages[pageType](el, pageType)}` // If a Top Level Page, add extra label
navEntry.depth = 0; // Pages are always at the least indented level
navEntry.text = text;
navEntry.className = 'pageLink';
} else if(el.localName.match(/^h[1-6]/)){ // Header elements H1 through H6
}
else if(el.localName.match(/^h[1-6]/)){ // Header elements H1 through H6
navEntry.depth = el.localName[1]; // Depth is set by the header level
}
navList.push(navEntry);
});
return _.map(navList, (navItem, index)=><HeaderNavItem {...navItem} key={index} />
return _.map(navList, (navItem, index)=>
<HeaderNavItem {...navItem} key={index} />
);
};

View File

@@ -1,31 +1,39 @@
.headerNav {
position : fixed;
top : 32px;
left : 0px;
max-width : 40vw;
max-height : calc(100vh - 32px);
padding : 5px 10px;
overflow-y : auto;
background-color : #CCCCCC;
border-radius : 5px;
&.active {
padding-bottom : 10px;
.navIcon { padding-bottom : 10px; }
position: fixed;
top: 32px;
left: 0px;
padding: 5px 10px;
background-color: #ccc;
border-radius: 5px;
max-height: calc(100vh - 32px);
max-width: 40vw;
overflow-y: auto;
&.active {
padding-bottom: 10px;
.navIcon {
padding-bottom: 10px;
}
}
.navIcon {
cursor: pointer;
}
.navIcon { cursor : pointer; }
li {
list-style-type : none;
list-style-type: none;
a {
display : inline-block;
width : 100%;
padding : 2px;
font-family : 'Open Sans';
font-size : 12px;
color : inherit;
text-decoration : none;
cursor : pointer;
&:hover { text-decoration : underline; }
&.pageLink { font-weight : 900; }
display: inline-block;
width: 100%;
font-family: 'Open Sans';
font-size: 12px;
padding: 2px;
color: inherit;
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
&.pageLink {
font-weight: 900;
}
@depths: 0,1,2,3,4,5,6,7;

View File

@@ -1,7 +1,7 @@
import './notificationPopup.less';
require('./notificationPopup.less');
import React, { useEffect, useState } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from 'markdown.js';
import Markdown from 'naturalcrit/markdown.js';
import Dialog from '../../../components/dialog.jsx';
@@ -62,4 +62,4 @@ const NotificationPopup = ()=>{
</Dialog>;
};
export default NotificationPopup;
module.exports = NotificationPopup;

View File

@@ -85,9 +85,4 @@
display : inline-block;
width : 100%;
}
.blank {
height : 1em;
margin-top : 0;
& + * { margin-top : 0; }
}
}

View File

@@ -1,15 +1,14 @@
/* eslint-disable max-lines */
import './toolBar.less';
import React, { useState, useEffect } from 'react';
import _ from 'lodash';
require('./toolBar.less');
const React = require('react');
const { useState, useEffect } = React;
const _ = require('lodash');
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
const MAX_ZOOM = 300;
const MIN_ZOOM = 10;
const TOOLBAR_VISIBILITY = 'HB_renderer_toolbarVisibility';
const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages, headerState, setHeaderState })=>{
const [pageNum, setPageNum] = useState(1);
@@ -21,12 +20,6 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
setPageNum(pageRange);
}, [visiblePages]);
useEffect(()=>{
const Visibility = localStorage.getItem(TOOLBAR_VISIBILITY);
if(Visibility) setToolsVisible(Visibility === 'true');
}, []);
const handleZoomButton = (zoom)=>{
handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
};
@@ -62,30 +55,15 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
// find widest page, in case pages are different widths, so that the zoom is adapted to not cut the widest page off screen.
const widestPage = _.maxBy([...pages], 'offsetWidth').offsetWidth;
if(displayOptions.spread === 'facing')
desiredZoom = (iframeWidth / ((widestPage * 2) + parseInt(displayOptions.columnGap))) * 100;
else
desiredZoom = (iframeWidth / (widestPage + 20)) * 100;
desiredZoom = (iframeWidth / widestPage) * 100;
} else if(mode == 'fit'){
// find the page with the largest single dim (height or width) so that zoom can be adapted to fit it.
let minDimRatio;
if(displayOptions.spread === 'single')
minDimRatio = [...pages].reduce(
(minRatio, page)=>Math.min(minRatio,
iframeWidth / page.offsetWidth,
iframeHeight / page.offsetHeight
),
Infinity
);
// find the page with the largest single dim (height or width) so that zoom can be adapted to fit it.
if(displayOptions.spread === 'facing')
minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth / 2), Infinity); // if 'facing' spread, fit two pages in view
else
minDimRatio = [...pages].reduce(
(minRatio, page)=>Math.min(minRatio,
iframeWidth / ((page.offsetWidth * 2) + parseInt(displayOptions.columnGap)),
iframeHeight / page.offsetHeight
),
Infinity
);
minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity);
desiredZoom = minDimRatio * 100;
}
@@ -99,10 +77,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
return (
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
<div className='toggleButton'>
<button title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{
setToolsVisible(!toolsVisible);
localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible);
}}><i className='fas fa-glasses' /></button>
<button title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{setToolsVisible(!toolsVisible);}}><i className='fas fa-glasses' /></button>
<button title={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button>
</div>
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
@@ -167,7 +142,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
id='single-spread'
className='tool'
title='Single Page'
onClick={()=>{handleOptionChange('spread', 'single');}}
onClick={()=>{handleOptionChange('spread', 'active');}}
aria-checked={displayOptions.spread === 'single'}
><i className='fac single-spread' /></button>
<button role='radio'
@@ -192,11 +167,11 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
<h1>Options</h1>
<label title='Modify the horizontal space between pages.'>
Column gap
<input type='range' min={0} max={200} defaultValue={displayOptions.columnGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('columnGap', evt.target.value)} />
<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={displayOptions.rowGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('rowGap', evt.target.value)} />
<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
@@ -258,4 +233,4 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
);
};
export default ToolBar;
module.exports = ToolBar;

View File

@@ -6,12 +6,12 @@
box-sizing : border-box;
display : flex;
flex-wrap : wrap;
gap : 8px 20px;
gap : 8px 30px;
align-items : center;
justify-content : center;
width : 100%;
height : auto;
padding : 2px 10px 2px 90px;
padding : 2px 0;
font-family : 'Open Sans', sans-serif;
font-size : 13px;
color : #CCCCCC;
@@ -153,10 +153,10 @@
align-items : center;
justify-content : center;
width : auto;
min-width : 40px;
min-width : 46px;
height : 100%;
&:hover { background-color : #444444; }
&:focus {outline : none; border : 1px solid #D3D3D3;}
&:focus { border : 1px solid #D3D3D3;outline : none;}
&:disabled {
color : #777777;
background-color : unset !important;
@@ -169,16 +169,12 @@
width : 92px;
overflow : hidden;
background-color : unset;
opacity : 0.7;
opacity : 0.5;
transition : all 0.3s ease;
& > *:not(.toggleButton) {
opacity : 0;
transition : all 0.2s ease;
}
.toggleButton button i {
filter: drop-shadow(0 0 2px black) drop-shadow(0 0 1px black);
}
}
}
@@ -186,6 +182,8 @@
position : absolute;
left : 0;
z-index : 5;
display : flex;
width : 32px;
min-width : unset;
height : 100%;
display : flex;
}

View File

@@ -1,19 +1,19 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
import './editor.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import dedent from 'dedent';
import Markdown from '../../../shared/markdown.js';
require('./editor.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const dedent = require('dedent-tabs').default;
import Markdown from '../../../shared/naturalcrit/markdown.js';
import CodeEditor from 'client/components/codeEditor/codeEditor.jsx';
import SnippetBar from './snippetbar/snippetbar.jsx';
import MetadataEditor from './metadataEditor/metadataEditor.jsx';
const CodeEditor = require('naturalcrit/codeEditor/codeEditor.jsx');
const SnippetBar = require('./snippetbar/snippetbar.jsx');
const MetadataEditor = require('./metadataEditor/metadataEditor.jsx');
const EDITOR_THEME_KEY = 'HB_editor_theme';
const EDITOR_THEME_KEY = 'HOMEBREWERY-EDITOR-THEME';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/;
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?: *{[^\n{}]*})?$)/m;
const SNIPPETBAR_HEIGHT = 25;
const DEFAULT_STYLE_TEXT = dedent`
/*=======--- Example CSS styling ---=======*/
/* Any CSS here will apply to your document! */
@@ -22,16 +22,9 @@ const DEFAULT_STYLE_TEXT = dedent`
color: black;
}`;
const DEFAULT_SNIPPET_TEXT = dedent`
\snippet example snippet
The text between \`\snippet title\` lines will become a snippet of name \`title\` as this example provides.
This snippet is accessible in the brew tab, and will be inherited if the brew is used as a theme.
`;
let isJumping = false;
const Editor = createReactClass({
const Editor = createClass({
displayName : 'Editor',
getDefaultProps : function() {
return {
@@ -40,8 +33,10 @@ const Editor = createReactClass({
style : ''
},
onBrewChange : ()=>{},
reportError : ()=>{},
onTextChange : ()=>{},
onStyleChange : ()=>{},
onMetaChange : ()=>{},
reportError : ()=>{},
onCursorPageChange : ()=>{},
onViewPageChange : ()=>{},
@@ -56,9 +51,8 @@ const Editor = createReactClass({
},
getInitialState : function() {
return {
editorTheme : this.props.editorTheme,
view : 'text', //'text', 'style', 'meta', 'snippet'
snippetBarHeight : 26,
editorTheme : this.props.editorTheme,
view : 'text' //'text', 'style', 'meta'
};
},
@@ -68,16 +62,17 @@ const Editor = createReactClass({
isText : function() {return this.state.view == 'text';},
isStyle : function() {return this.state.view == 'style';},
isMeta : function() {return this.state.view == 'meta';},
isSnip : function() {return this.state.view == 'snippet';},
componentDidMount : function() {
this.updateEditorSize();
this.highlightCustomMarkdown();
window.addEventListener('resize', this.updateEditorSize);
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys);
document.addEventListener('keydown', this.handleControlKeys);
this.codeEditor.current.codeMirror?.on('cursorActivity', (cm)=>{this.updateCurrentCursorPage(cm.getCursor());});
this.codeEditor.current.codeMirror?.on('scroll', _.throttle(()=>{this.updateCurrentViewPage(this.codeEditor.current.getTopVisibleLine());}, 200));
this.codeEditor.current.codeMirror.on('cursorActivity', (cm)=>{this.updateCurrentCursorPage(cm.getCursor());});
this.codeEditor.current.codeMirror.on('scroll', _.throttle(()=>{this.updateCurrentViewPage(this.codeEditor.current.getTopVisibleLine());}, 200));
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
if(editorTheme) {
@@ -85,15 +80,10 @@ const Editor = createReactClass({
editorTheme : editorTheme
});
}
const snippetBar = document.querySelector('.editor > .snippetBar');
if (!snippetBar) return;
},
this.resizeObserver = new ResizeObserver(entries => {
const height = document.querySelector('.editor > .snippetBar').offsetHeight;
this.setState({ snippetBarHeight: height });
});
this.resizeObserver.observe(snippetBar);
componentWillUnmount : function() {
window.removeEventListener('resize', this.updateEditorSize);
},
componentDidUpdate : function(prevProps, prevState, snapshot) {
@@ -116,10 +106,6 @@ const Editor = createReactClass({
}
},
componentWillUnmount() {
if (this.resizeObserver) this.resizeObserver.disconnect();
},
handleControlKeys : function(e){
if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return;
const LEFTARROW_KEY = 37;
@@ -132,6 +118,14 @@ const Editor = createReactClass({
}
},
updateEditorSize : function() {
if(this.codeEditor.current) {
let paneHeight = this.editor.current.parentNode.clientHeight;
paneHeight -= SNIPPETBAR_HEIGHT;
this.codeEditor.current.codeMirror.setSize(null, paneHeight);
}
},
updateCurrentCursorPage : function(cursor) {
const lines = this.props.brew.text.split('\n').slice(1, cursor.line + 1);
const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
@@ -152,25 +146,25 @@ const Editor = createReactClass({
handleViewChange : function(newView){
this.props.setMoveArrows(newView === 'text');
this.setState({
view : newView
}, ()=>{
this.codeEditor.current?.codeMirror?.focus();
});
this.codeEditor.current?.codeMirror.focus();
this.updateEditorSize();
}); //TODO: not sure if updateeditorsize needed
},
highlightCustomMarkdown : function(){
if(!this.codeEditor.current?.codeMirror) return;
if((this.state.view === 'text') ||(this.state.view === 'snippet')) {
if(!this.codeEditor.current) return;
if(this.state.view === 'text') {
const codeMirror = this.codeEditor.current.codeMirror;
codeMirror?.operation(()=>{ // Batch CodeMirror styling
codeMirror.operation(()=>{ // Batch CodeMirror styling
const foldLines = [];
//reset custom text styles
const customHighlights = codeMirror?.getAllMarks().filter((mark)=>{
const customHighlights = codeMirror.getAllMarks().filter((mark)=>{
// Record details of folded sections
if(mark.__isFold) {
const fold = mark.find();
@@ -181,20 +175,14 @@ const Editor = createReactClass({
for (let i=customHighlights.length - 1;i>=0;i--) customHighlights[i].clear();
let userSnippetCount = 1; // start snippet count from snippet 1
let editorPageCount = 1; // start page count from page 1
const whichSource = this.state.view === 'text' ? this.props.brew.text : this.props.brew.snippets;
_.forEach(whichSource?.split('\n'), (line, lineNumber)=>{
const tabHighlight = this.state.view === 'text' ? 'pageLine' : 'snippetLine';
const textOrSnip = this.state.view === 'text';
_.forEach(this.props.brew.text.split('\n'), (line, lineNumber)=>{
//reset custom line styles
codeMirror?.removeLineClass(lineNumber, 'background', 'pageLine');
codeMirror?.removeLineClass(lineNumber, 'background', 'snippetLine');
codeMirror?.removeLineClass(lineNumber, 'text');
codeMirror?.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash');
codeMirror.removeLineClass(lineNumber, 'background', 'pageLine');
codeMirror.removeLineClass(lineNumber, 'text');
codeMirror.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash');
// Don't process lines inside folded text
// If the current lineNumber is inside any folded marks, skip line styling
@@ -203,26 +191,24 @@ const Editor = createReactClass({
// Styling for \page breaks
if((this.props.renderer == 'legacy' && line.includes('\\page')) ||
(this.props.renderer == 'V3' && line.match(textOrSnip ? PAGEBREAK_REGEX_V3 : SNIPPETBREAK_REGEX_V3))) {
(this.props.renderer == 'V3' && line.match(PAGEBREAK_REGEX_V3))) {
if((lineNumber > 0) && (textOrSnip)) // Since \page is optional on first line of document,
if(lineNumber > 0) // Since \page is optional on first line of document,
editorPageCount += 1; // don't use it to increment page count; stay at 1
else if(this.state.view !== 'text') userSnippetCount += 1;
// add back the original class 'background' but also add the new class '.pageline'
codeMirror?.addLineClass(lineNumber, 'background', tabHighlight);
codeMirror.addLineClass(lineNumber, 'background', 'pageLine');
const pageCountElement = Object.assign(document.createElement('span'), {
className : 'editor-page-count',
textContent : textOrSnip ? editorPageCount : userSnippetCount
textContent : editorPageCount
});
codeMirror?.setBookmark({ line: lineNumber, ch: line.length }, pageCountElement);
codeMirror.setBookmark({ line: lineNumber, ch: line.length }, pageCountElement);
};
// New CodeMirror styling for V3 renderer
if(this.props.renderer === 'V3') {
if(line.match(/^\\column(?:break)?$/)){
codeMirror?.addLineClass(lineNumber, 'text', 'columnSplit');
// New Codemirror styling for V3 renderer
if(this.props.renderer == 'V3') {
if(line.match(/^\\column$/)){
codeMirror.addLineClass(lineNumber, 'text', 'columnSplit');
}
// definition lists
@@ -231,14 +217,14 @@ const Editor = createReactClass({
const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error.
let match;
while ((match = regex.exec(line)) != null){
codeMirror?.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' });
codeMirror?.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
codeMirror?.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
codeMirror.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' });
codeMirror.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
codeMirror.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
const ddIndex = match.indices[2][0];
const colons = /::/g;
const colonMatches = colons.exec(match[2]);
if(colonMatches !== null){
codeMirror?.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' });
codeMirror.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' });
}
}
}
@@ -255,7 +241,7 @@ const Editor = createReactClass({
const match = subRegex.exec(line) || superRegex.exec(line);
if(match) {
isSuper = !subRegex.lastIndex;
codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
codeMirror.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
}
startIndex = line.indexOf('^', Math.max(startIndex + 1, subRegex.lastIndex, superRegex.lastIndex));
}
@@ -266,7 +252,7 @@ const Editor = createReactClass({
const regex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gm;
let match;
while ((match = regex.exec(line)) != null) {
codeMirror?.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'injection' });
codeMirror.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'injection' });
}
}
// Highlight inline spans {{content}}
@@ -284,7 +270,7 @@ const Editor = createReactClass({
blockCount = 0;
continue;
}
codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: 'inline-block' });
codeMirror.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: 'inline-block' });
}
} else if(line.trimLeft().startsWith('{{') || line.trimLeft().startsWith('}}')){
// Highlight block divs {{\n Content \n}}
@@ -293,7 +279,7 @@ const Editor = createReactClass({
const match = line.match(/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/);
if(match)
endCh = match.index+match[0].length;
codeMirror?.markText({ line: lineNumber, ch: 0 }, { line: lineNumber, ch: endCh }, { className: 'block' });
codeMirror.markText({ line: lineNumber, ch: 0 }, { line: lineNumber, ch: endCh }, { className: 'block' });
}
// Emojis
@@ -314,11 +300,11 @@ const Editor = createReactClass({
const endPos = { line: lineNumber, ch: match.index + match[0].length };
// Iterate over conflicting marks and clear them
const marks = codeMirror?.findMarks(startPos, endPos);
const marks = codeMirror.findMarks(startPos, endPos);
marks.forEach(function(marker) {
if(!marker.__isFold) marker.clear();
});
codeMirror?.markText(startPos, endPos, { className: 'emoji' });
codeMirror.markText(startPos, endPos, { className: 'emoji' });
}
startIndex = line.indexOf(':', Math.max(startIndex + 1, emojiRegex.lastIndex));
}
@@ -337,10 +323,10 @@ const Editor = createReactClass({
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
const currentPos = brewRenderer.scrollTop;
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
let scrollingTimeout;
const checkIfScrollComplete = ()=>{ // Prevent interrupting a scroll in progress if user clicks multiple times
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
const checkIfScrollComplete = ()=>{
let scrollingTimeout;
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
scrollingTimeout = setTimeout(()=>{
isJumping = false;
brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
@@ -378,51 +364,51 @@ const Editor = createReactClass({
const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit);
const targetLine = textString.match('\n') ? textString.split('\n').length - 1 : -1;
let currentY = this.codeEditor.current.codeMirror?.getScrollInfo().top;
let targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true);
let currentY = this.codeEditor.current.codeMirror.getScrollInfo().top;
let targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
let scrollingTimeout;
const checkIfScrollComplete = ()=>{ // Prevent interrupting a scroll in progress if user clicks multiple times
const checkIfScrollComplete = ()=>{
let scrollingTimeout;
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
scrollingTimeout = setTimeout(()=>{
isJumping = false;
this.codeEditor.current.codeMirror?.off('scroll', checkIfScrollComplete);
this.codeEditor.current.codeMirror.off('scroll', checkIfScrollComplete);
}, 150); // If 150 ms pass without a scroll event, assume scrolling is done
};
isJumping = true;
checkIfScrollComplete();
if (this.codeEditor.current?.codeMirror) {
this.codeEditor.current.codeMirror?.on('scroll', checkIfScrollComplete);
}
this.codeEditor.current.codeMirror.on('scroll', checkIfScrollComplete);
if(smooth) {
//Scroll 1/10 of the way every 10ms until 1px off.
const incrementalScroll = setInterval(()=>{
currentY += (targetY - currentY) / 10;
this.codeEditor.current.codeMirror?.scrollTo(null, currentY);
this.codeEditor.current.codeMirror.scrollTo(null, currentY);
// Update target: target height is not accurate until within +-10 lines of the visible window
if(Math.abs(targetY - currentY > 100))
targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true);
targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
// End when close enough
if(Math.abs(targetY - currentY) < 1) {
this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.codeMirror.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
this.codeEditor.current.codeMirror.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
clearInterval(incrementalScroll);
}
}, 10);
} else {
this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.codeMirror.scrollTo(null, targetY); // Scroll any remaining difference
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
this.codeEditor.current.codeMirror.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
}
},
//Called when there are changes to the editor's dimensions
update : function(){},
update : function(){
this.codeEditor.current?.updateSize();
},
updateEditorTheme : function(newTheme){
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
@@ -444,10 +430,9 @@ const Editor = createReactClass({
language='gfm'
view={this.state.view}
value={this.props.brew.text}
onChange={this.props.onBrewChange('text')}
onChange={this.props.onTextChange}
editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent}
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} />
rerenderParent={this.rerenderParent} />
</>;
}
if(this.isStyle()){
@@ -457,11 +442,10 @@ const Editor = createReactClass({
language='css'
view={this.state.view}
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
onChange={this.props.onBrewChange('style')}
onChange={this.props.onStyleChange}
enableFolding={true}
editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent}
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} />
rerenderParent={this.rerenderParent} />
</>;
}
if(this.isMeta()){
@@ -473,26 +457,11 @@ const Editor = createReactClass({
<MetadataEditor
metadata={this.props.brew}
themeBundle={this.props.themeBundle}
onChange={this.props.onBrewChange('metadata')}
onChange={this.props.onMetaChange}
reportError={this.props.reportError}
userThemes={this.props.userThemes}/>
</>;
}
if(this.isSnip()){
if(!this.props.brew.snippets) { this.props.brew.snippets = DEFAULT_SNIPPET_TEXT; }
return <>
<CodeEditor key='codeEditor'
ref={this.codeEditor}
language='gfm'
view={this.state.view}
value={this.props.brew.snippets}
onChange={this.props.onBrewChange('snippets')}
enableFolding={true}
editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent}
style={{ height: `calc(100% -${this.state.snippetBarHeight}px)` }} />
</>;
}
},
redo : function(){
@@ -533,7 +502,7 @@ const Editor = createReactClass({
historySize={this.historySize()}
currentEditorTheme={this.state.editorTheme}
updateEditorTheme={this.updateEditorTheme}
themeBundle={this.props.themeBundle}
snippetBundle={this.props.snippetBundle}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}}
updateBrew={this.props.updateBrew}
/>
@@ -544,4 +513,4 @@ const Editor = createReactClass({
}
});
export default Editor;
module.exports = Editor;

View File

@@ -1,13 +1,12 @@
@import 'themes/codeMirror/customEditorStyles.less';
.editor {
position : relative;
width : 100%;
height : 100%;
container : editor / inline-size;
position : relative;
width : 100%;
container: editor / inline-size;
.codeEditor {
height : calc(100% - 25px);
.CodeMirror { height : 100%; }
.pageLine, .snippetLine {
height : 100%;
.pageLine {
background : #33333328;
border-top : #333399 solid 1px;
}
@@ -15,10 +14,6 @@
float : right;
color : grey;
}
.editor-snippet-count {
float : right;
color : grey;
}
.columnSplit {
font-style : italic;
color : grey;
@@ -50,26 +45,26 @@
color : green;
}
.emoji:not(.cm-comment) {
padding-bottom : 1px;
margin-left : 2px;
font-weight : bold;
color : #360034;
outline : solid 2px #FF96FC;
outline-offset : -2px;
background : #FFC8FF;
background : #ffc8ff;
border-radius : 6px;
font-weight : bold;
padding-bottom : 1px;
outline-offset : -2px;
outline : solid 2px #ff96fc;
}
.superscript:not(.cm-comment) {
font-size : 0.9em;
font-weight : bold;
vertical-align : super;
color : goldenrod;
vertical-align : super;
font-size : 0.9em;
}
.subscript:not(.cm-comment) {
font-size : 0.9em;
font-weight : bold;
vertical-align : sub;
color : rgb(123, 123, 15);
vertical-align : sub;
font-size : 0.9em;
}
.dl-highlight {
&.dl-colon-highlight {
@@ -108,4 +103,4 @@
span { padding : 2px 5px; }
}
}
}

View File

@@ -1,19 +1,20 @@
/* eslint-disable max-lines */
import './metadataEditor.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
require('./metadataEditor.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
import request from '../../utils/request-middleware.js';
import Combobox from 'client/components/combobox.jsx';
import TagInput from '../tagInput/tagInput.jsx';
const Nav = require('naturalcrit/nav/nav.jsx');
const Combobox = require('client/components/combobox.jsx');
const TagInput = require('../tagInput/tagInput.jsx');
import Themes from 'themes/themes.json';
import validations from './validations.js';
const Themes = require('themes/themes.json');
const validations = require('./validations.js');
const SYSTEMS = ['5e', '4e', '3.5e', 'Pathfinder'];
import homebreweryThumbnail from '../../thumbnail.png';
const homebreweryThumbnail = require('../../thumbnail.png');
const callIfExists = (val, fn, ...args)=>{
if(val[fn]) {
@@ -21,7 +22,7 @@ const callIfExists = (val, fn, ...args)=>{
}
};
const MetadataEditor = createReactClass({
const MetadataEditor = createClass({
displayName : 'MetadataEditor',
getDefaultProps : function() {
return {
@@ -47,7 +48,7 @@ const MetadataEditor = createReactClass({
getInitialState : function(){
return {
showThumbnail : true
showThumbnail : true
};
},
@@ -67,7 +68,7 @@ const MetadataEditor = createReactClass({
const inputRules = validations[name] ?? [];
const validationErr = inputRules.map((rule)=>rule(e.target.value)).filter(Boolean);
const debouncedReportValidity = _.debounce((target, errMessage)=>{
const debouncedReportValidity = _.debounce((target, errMessage) => {
callIfExists(target, 'setCustomValidity', errMessage);
callIfExists(target, 'reportValidity');
}, 300); // 300ms debounce delay, adjust as needed
@@ -86,7 +87,7 @@ const MetadataEditor = createReactClass({
return `- ${err}`;
}).join('\n');
debouncedReportValidity(e.target, errMessage);
return false;
}
@@ -109,7 +110,6 @@ const MetadataEditor = createReactClass({
}
this.props.onChange(this.props.metadata, 'renderer');
},
handlePublish : function(val){
this.props.onChange({
...this.props.metadata,
@@ -207,6 +207,8 @@ const MetadataEditor = createReactClass({
},
renderThemeDropdown : function(){
if(!global.enable_themes) return;
const mergedThemes = _.merge(Themes, this.props.userThemes);
const listThemes = (renderer)=>{
@@ -305,6 +307,8 @@ const MetadataEditor = createReactClass({
},
renderRenderOptions : function(){
if(!global.enable_v3) return;
return <div className='field systems'>
<label>Renderer</label>
<div className='value'>
@@ -411,4 +415,4 @@ const MetadataEditor = createReactClass({
}
});
export default MetadataEditor;
module.exports = MetadataEditor;

View File

@@ -1,8 +1,8 @@
@import 'naturalcrit/styles/colors.less';
.userThemeName {
padding-right : 10px;
padding-left : 10px;
padding-left: 10px;
padding-right: 10px;
}
.metadataEditor {
@@ -12,20 +12,20 @@
height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this.
padding : 25px;
overflow-y : auto;
font-size : 13px;
background-color : #999999;
font-size : 13px;
h1 {
margin : 0 0 40px;
font-weight : bold;
text-transform : uppercase;
margin: 0 0 40px;
font-weight: bold;
text-transform: uppercase;
}
h2 {
margin : 20px 0;
font-weight : bold;
color : #555555;
border-bottom : 2px solid gray;
margin : 20px 0;
font-weight : bold;
border-bottom: 2px solid gray;
color: #555;
}
& > div { margin-bottom : 10px; }
@@ -54,10 +54,10 @@
min-width : 200px;
& > label {
width : 80px;
font-size : 0.9em;
font-weight : 800;
line-height : 1.8em;
text-transform : uppercase;
font-size: .9em;
}
& > .value {
flex : 1 1 auto;
@@ -74,7 +74,7 @@
border : 1px solid gray;
&:focus { outline : 1px solid #444444; }
}
&.thumbnail, &.themes {
&.thumbnail, &.themes{
label { line-height : 2.0em; }
.value {
overflow : hidden;
@@ -90,14 +90,14 @@
}
}
&.themes {
&.themes{
.value {
overflow : visible;
text-overflow : auto;
}
button {
padding-right : 5px;
padding-left : 5px;
padding-left: 5px;
padding-right: 5px;
}
}
@@ -136,8 +136,8 @@
margin-right : 15px;
font-size : 0.9em;
font-weight : 800;
vertical-align : middle;
white-space : nowrap;
vertical-align : middle;
cursor : pointer;
user-select : none;
}
@@ -164,7 +164,9 @@
.colorButton(@red);
}
}
.authors.field .value { line-height : 1.5em; }
.authors.field .value {
line-height : 1.5em;
}
.themes.field {
& .dropdown-container {
@@ -172,7 +174,9 @@
z-index : 100;
background-color : white;
}
& .dropdown-options { overflow-y : visible; }
& .dropdown-options {
overflow-y : visible;
}
.disabled {
font-style : italic;
color : dimgray;

View File

@@ -1,4 +1,4 @@
export default {
module.exports = {
title : [
(value)=>{
return value?.length > 100 ? 'Max title length of 100 characters' : null;
@@ -18,7 +18,7 @@ export default {
try {
Boolean(new URL(value));
return null;
} catch {
} catch (e) {
return 'Must be a valid URL';
}
}
@@ -28,18 +28,18 @@ export default {
return new RegExp(/^([a-zA-Z]{2,3})(-[a-zA-Z]{4})?(-(?:[0-9]{3}|[a-zA-Z]{2}))?$/).test(value) === false && (value.length > 0) ? 'Invalid language code.' : null;
}
],
theme : [
(value)=>{
theme: [
(value) => {
const URL = global.config.baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); //Escape any regex characters
const shareIDPattern = '[a-zA-Z0-9-_]{12}';
const shareURLRegex = new RegExp(`^${URL}\\/share\\/${shareIDPattern}$`);
const shareIDRegex = new RegExp(`^${shareIDPattern}$`);
if(value?.length === 0) return null;
if(shareURLRegex.test(value)) return null;
if(shareIDRegex.test(value)) return null;
if (value?.length === 0) return null;
if (shareURLRegex.test(value)) return null;
if (shareIDRegex.test(value)) return null;
return 'Must be a valid Share URL or a 12-character ID.';
}
}
]
};

View File

@@ -1,36 +1,28 @@
/*eslint max-lines: ["warn", {"max": 350, "skipBlankLines": true, "skipComments": true}]*/
import './snippetbar.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import cx from 'classnames';
require('./snippetbar.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const cx = require('classnames');
import { loadHistory } from '../../utils/versionHistory.js';
import { brewSnippetsToJSON } from '../../../../shared/helpers.js';
import Legacy5ePHB from 'themes/Legacy/5ePHB/snippets.js';
import V3_5ePHB from 'themes/V3/5ePHB/snippets.js';
import V3_5eDMG from 'themes/V3/5eDMG/snippets.js';
import V3_Journal from 'themes/V3/Journal/snippets.js';
import V3_Blank from 'themes/V3/Blank/snippets.js';
//Import all themes
const ThemeSnippets = {};
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
ThemeSnippets['V3_5ePHB'] = require('themes/V3/5ePHB/snippets.js');
ThemeSnippets['V3_5eDMG'] = require('themes/V3/5eDMG/snippets.js');
ThemeSnippets['V3_Journal'] = require('themes/V3/Journal/snippets.js');
ThemeSnippets['V3_Blank'] = require('themes/V3/Blank/snippets.js');
const ThemeSnippets = {
Legacy_5ePHB : Legacy5ePHB,
V3_5ePHB : V3_5ePHB,
V3_5eDMG : V3_5eDMG,
V3_Journal : V3_Journal,
V3_Blank : V3_Blank,
};
import EditorThemes from 'build/homebrew/codeMirror/editorThemes.json';
const EditorThemes = require('build/homebrew/codeMirror/editorThemes.json');
const execute = function(val, props){
if(_.isFunction(val)) return val(props);
return val;
};
const Snippetbar = createReactClass({
const Snippetbar = createClass({
displayName : 'SnippetBar',
getDefaultProps : function() {
return {
@@ -48,7 +40,7 @@ const Snippetbar = createReactClass({
unfoldCode : ()=>{},
updateEditorTheme : ()=>{},
cursorPos : {},
themeBundle : [],
snippetBundle : [],
updateBrew : ()=>{}
};
},
@@ -72,10 +64,7 @@ const Snippetbar = createReactClass({
},
componentDidUpdate : async function(prevProps, prevState) {
if(prevProps.renderer != this.props.renderer ||
prevProps.theme != this.props.theme ||
prevProps.themeBundle != this.props.themeBundle ||
prevProps.brew.snippets != this.props.brew.snippets) {
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
this.setState({
snippets : this.compileSnippets()
});
@@ -108,7 +97,7 @@ const Snippetbar = createReactClass({
if(key == 'snippets') {
const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme
return result.filter((snip)=>snip.gen || snip.subsnippets);
};
}
},
compileSnippets : function() {
@@ -116,21 +105,15 @@ const Snippetbar = createReactClass({
let oldSnippets = _.keyBy(compiledSnippets, 'groupName');
if(this.props.themeBundle.snippets) {
for (let snippets of this.props.themeBundle.snippets) {
if(typeof(snippets) == 'string') // load staticThemes as needed; they were sent as just a file name
snippets = ThemeSnippets[snippets];
for (let snippets of this.props.snippetBundle) {
if(typeof(snippets) == 'string') // load staticThemes as needed; they were sent as just a file name
snippets = ThemeSnippets[snippets];
const newSnippets = _.keyBy(_.cloneDeep(snippets), 'groupName');
compiledSnippets = _.values(_.mergeWith(oldSnippets, newSnippets, this.mergeCustomizer));
const newSnippets = _.keyBy(_.cloneDeep(snippets), 'groupName');
compiledSnippets = _.values(_.mergeWith(oldSnippets, newSnippets, this.mergeCustomizer));
oldSnippets = _.keyBy(compiledSnippets, 'groupName');
}
oldSnippets = _.keyBy(compiledSnippets, 'groupName');
}
const userSnippetsasJSON = brewSnippetsToJSON(this.props.brew.title || 'New Document', this.props.brew.snippets, this.props.themeBundle.snippets);
compiledSnippets.push(userSnippetsasJSON);
return compiledSnippets;
},
@@ -224,60 +207,59 @@ const Snippetbar = createReactClass({
renderEditorButtons : function(){
if(!this.props.showEditButtons) return;
return (
<div className='editors'>
{this.props.view !== 'meta' && <><div className='historyTools'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={this.toggleHistoryMenu} >
<i className='fas fa-clock-rotate-left' />
{ this.state.showHistory && this.renderHistoryItems() }
</div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
onClick={this.props.undo} >
<i className='fas fa-undo' />
</div>
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
onClick={this.props.redo} >
<i className='fas fa-redo' />
</div>
<div className='editors'>
{this.props.view !== 'meta' && <><div className='historyTools'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={this.toggleHistoryMenu} >
<i className='fas fa-clock-rotate-left' />
{ this.state.showHistory && this.renderHistoryItems() }
</div>
<div className='codeTools'>
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()}
</div>
</div></>}
<div className='tabs'>
<div className={cx('text', { selected: this.props.view === 'text' })}
onClick={()=>this.props.onViewChange('text')}>
<i className='fa fa-beer' />
</div>
<div className={cx('style', { selected: this.props.view === 'style' })}
onClick={()=>this.props.onViewChange('style')}>
<i className='fa fa-paint-brush' />
</div>
<div className={cx('snippet', { selected: this.props.view === 'snippet' })}
onClick={()=>this.props.onViewChange('snippet')}>
<i className='fas fa-th-list' />
</div>
<div className={cx('meta', { selected: this.props.view === 'meta' })}
onClick={()=>this.props.onViewChange('meta')}>
<i className='fas fa-info-circle' />
</div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
onClick={this.props.undo} >
<i className='fas fa-undo' />
</div>
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
onClick={this.props.redo} >
<i className='fas fa-redo' />
</div>
</div>
);
<div className='codeTools'>
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
onClick={this.props.foldCode} >
<i className='fas fa-compress-alt' />
</div>
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
onClick={this.props.unfoldCode} >
<i className='fas fa-expand-alt' />
</div>
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
onClick={this.toggleThemeSelector} >
<i className='fas fa-palette' />
{this.state.themeSelector && this.renderThemeSelector()}
</div>
</div></>}
<div className='tabs'>
<div className={cx('text', { selected: this.props.view === 'text' })}
onClick={()=>this.props.onViewChange('text')}>
<i className='fa fa-beer' />
</div>
<div className={cx('style', { selected: this.props.view === 'style' })}
onClick={()=>this.props.onViewChange('style')}>
<i className='fa fa-paint-brush' />
</div>
<div className={cx('meta', { selected: this.props.view === 'meta' })}
onClick={()=>this.props.onViewChange('meta')}>
<i className='fas fa-info-circle' />
</div>
</div>
</div>
)
},
render : function(){
@@ -288,9 +270,14 @@ const Snippetbar = createReactClass({
}
});
export default Snippetbar;
module.exports = Snippetbar;
const SnippetGroup = createReactClass({
const SnippetGroup = createClass({
displayName : 'SnippetGroup',
getDefaultProps : function() {
return {
@@ -323,8 +310,7 @@ const SnippetGroup = createReactClass({
},
render : function(){
const snippetGroup = `snippetGroup snippetBarButton ${this.props.snippets.length === 0 ? 'disabledSnippets' : ''}`;
return <div className={snippetGroup}>
return <div className='snippetGroup snippetBarButton'>
<div className='text'>
<i className={this.props.icon} />
<span className='groupName'>{this.props.groupName}</span>

View File

@@ -14,15 +14,15 @@
.snippets {
display : flex;
justify-content : flex-start;
min-width : 499.35px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
min-width : 327.58px;
}
.editors {
display : flex;
justify-content : flex-end;
min-width : 250px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
min-width : 225px;
&:only-child {min-width : unset; margin-left : auto;}
&:only-child { margin-left : auto;min-width:unset;}
>div {
display : flex;
@@ -39,7 +39,9 @@
text-align : center;
cursor : pointer;
&.editorTool:not(.active) { cursor : not-allowed; }
&.editorTool:not(.active) {
cursor:not-allowed;
}
&:hover,&.selected { background-color : #999999; }
&.text {
@@ -51,9 +53,6 @@
&.meta {
.tooltipLeft('Properties');
}
&.snippet {
.tooltipLeft('Snippets');
}
&.undo {
.tooltipLeft('Undo');
font-size : 0.75em;
@@ -93,7 +92,7 @@
&.editorTheme {
.tooltipLeft('Editor Themes');
font-size : 0.75em;
color : inherit;
color : black;
&.active {
position : relative;
background-color : #999999;
@@ -152,9 +151,9 @@
position : absolute;
top : 100%;
z-index : 1000;
visibility : hidden;
padding : 0px;
margin-left : -5px;
visibility : hidden;
background-color : #DDDDDD;
.snippet {
position : relative;
@@ -229,15 +228,8 @@
}
}
}
.disabledSnippets {
color: grey;
cursor: not-allowed;
&:hover { background-color: #DDDDDD;}
}
}
@container editor (width < 750px) {
@container editor (width < 553px) {
.snippetBar {
.editors {
flex : 1;

View File

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

View File

@@ -1,83 +1,95 @@
//╔===--------------- Polyfills --------------===╗//
import 'core-js/es/string/to-well-formed.js';
//╚===--------------- ---------------===╝//
import 'core-js/es/string/to-well-formed.js'; //Polyfill for older browsers
import './homebrew.less';
import React from 'react';
import { StaticRouter as Router, Route, Routes, useParams, useSearchParams } from 'react-router';
require('./homebrew.less');
const React = require('react');
const createClass = require('create-react-class');
const { StaticRouter:Router } = require('react-router');
const { Route, Routes, useParams, useSearchParams } = require('react-router');
import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js';
const HomePage = require('./pages/homePage/homePage.jsx');
const EditPage = require('./pages/editPage/editPage.jsx');
const UserPage = require('./pages/userPage/userPage.jsx');
const SharePage = require('./pages/sharePage/sharePage.jsx');
const NewPage = require('./pages/newPage/newPage.jsx');
const ErrorPage = require('./pages/errorPage/errorPage.jsx');
const VaultPage = require('./pages/vaultPage/vaultPage.jsx');
const AccountPage = require('./pages/accountPage/accountPage.jsx');
import HomePage from './pages/homePage/homePage.jsx';
import EditPage from './pages/editPage/editPage.jsx';
import UserPage from './pages/userPage/userPage.jsx';
import SharePage from './pages/sharePage/sharePage.jsx';
import NewPage from './pages/newPage/newPage.jsx';
import ErrorPage from './pages/errorPage/errorPage.jsx';
import VaultPage from './pages/vaultPage/vaultPage.jsx';
import AccountPage from './pages/accountPage/accountPage.jsx';
const WithRoute = ({ el: Element, ...rest })=>{
const WithRoute = (props)=>{
const params = useParams();
const [searchParams] = useSearchParams();
const queryParams = Object.fromEntries(searchParams?.entries() || []);
return <Element {...rest} {...params} query={queryParams} />;
};
const Homebrew = (props)=>{
const {
url = '',
version = '0.0.0',
account = null,
config,
brew = {
title : '',
text : '',
shareId : null,
editId : null,
createdAt : null,
updatedAt : null,
lang : ''
},
userThemes,
brews
} = props;
global.account = account;
global.version = version;
global.config = config;
const backgroundObject = ()=>{
if(global.config.deployment || (config.local && config.development)){
const bgText = global.config.deployment || 'Local';
return {
backgroundImage : `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='100px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${bgText}</text></svg>")`
};
}
return null;
const queryParams = {};
for (const [key, value] of searchParams?.entries() || []) {
queryParams[key] = value;
}
const Element = props.el;
const allProps = {
...props,
...params,
query : queryParams,
el : undefined
};
updateLocalStorage();
return (
<Router location={url}>
<div className={`homebrew${(config.deployment || config.local) ? ' deployment' : ''}`} style={backgroundObject()}>
<Routes>
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} />} />
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={brew} userThemes={userThemes}/>} />
<Route path='/new' element={<WithRoute el={NewPage} userThemes={userThemes}/> } />
<Route path='/user/:username' element={<WithRoute el={UserPage} brews={brews} />} />
<Route path='/vault' element={<WithRoute el={VaultPage}/>}/>
<Route path='/changelog' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
<Route path='/faq' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
<Route path='/migrate' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
<Route path='/account' element={<WithRoute el={AccountPage} brew={brew} accountDetails={brew.accountDetails} />} />
<Route path='/legacy' element={<WithRoute el={HomePage} brew={brew} />} />
<Route path='/error' element={<WithRoute el={ErrorPage} brew={brew} />} />
<Route path='/' element={<WithRoute el={HomePage} brew={brew} />} />
<Route path='/*' element={<WithRoute el={HomePage} brew={brew} />} />
</Routes>
</div>
</Router>
);
return <Element {...allProps} />;
};
const Homebrew = createClass({
displayName : 'Homebrewery',
getDefaultProps : function() {
return {
url : '',
welcomeText : '',
changelog : '',
version : '0.0.0',
account : null,
enable_v3 : false,
brew : {
title : '',
text : '',
shareId : null,
editId : null,
createdAt : null,
updatedAt : null,
lang : ''
}
};
},
getInitialState : function() {
global.account = this.props.account;
global.version = this.props.version;
global.enable_v3 = this.props.enable_v3;
global.enable_themes = this.props.enable_themes;
global.config = this.props.config;
return {};
},
render : function (){
return (
<Router location={this.props.url}>
<div className='homebrew'>
<Routes>
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={this.props.brew} />} />
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={this.props.brew} userThemes={this.props.userThemes}/>} />
<Route path='/new' element={<WithRoute el={NewPage} userThemes={this.props.userThemes}/> } />
<Route path='/user/:username' element={<WithRoute el={UserPage} brews={this.props.brews} />} />
<Route path='/vault' element={<WithRoute el={VaultPage}/>}/>
<Route path='/changelog' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/faq' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/migrate' element={<WithRoute el={SharePage} brew={this.props.brew} disableMeta={true} />} />
<Route path='/account' element={<WithRoute el={AccountPage} brew={this.props.brew} accountDetails={this.props.brew.accountDetails} />} />
<Route path='/legacy' element={<WithRoute el={HomePage} brew={this.props.brew} />} />
<Route path='/error' element={<WithRoute el={ErrorPage} brew={this.props.brew} />} />
<Route path='/' element={<WithRoute el={HomePage} brew={this.props.brew} />} />
<Route path='/*' element={<WithRoute el={HomePage} brew={this.props.brew} />} />
</Routes>
</div>
</Router>
);
}
});
module.exports = Homebrew;

View File

@@ -1,34 +1,36 @@
@import 'naturalcrit/styles/core.less';
.homebrew {
.homebrew{
height : 100%;
background-color:@steel;
&.deployment { background-color : darkred; }
.sitePage {
.sitePage{
display : flex;
flex-direction : column;
height : 100%;
background-color : @steel;
flex-direction : column;
overflow-y : hidden;
.content {
.content{
position : relative;
height : calc(~"100% - 29px"); //Navbar height
flex : auto;
height : calc(~'100% - 29px'); //Navbar height
overflow-y : hidden;
}
&.listPage .content {
overflow-y : scroll;
&::-webkit-scrollbar {
width : 20px;
&:horizontal {
width : auto;
height : 20px;
width: 20px;
&:horizontal{
height: 20px;
width:auto;
}
&-thumb {
background : linear-gradient(90deg, #D3C1AF 15px, #00000000 15px);
&:horizontal { background : linear-gradient(0deg, #D3C1AF 15px, #00000000 15px); }
background: linear-gradient(90deg, #d3c1af 15px, #00000000 15px);
&:horizontal{
background: linear-gradient(0deg, #d3c1af 15px, #00000000 15px);
}
}
&-corner {
visibility: hidden;
}
&-corner { visibility : hidden; }
}
}
}

View File

@@ -1,9 +1,9 @@
import React from 'react';
import createReactClass from 'create-react-class';
import request from 'superagent';
import Nav from 'client/homebrew/navbar/nav.jsx';
const React = require('react');
const createClass = require('create-react-class');
const Nav = require('naturalcrit/nav/nav.jsx');
const request = require('superagent');
const Account = createReactClass({
const Account = createClass({
displayName : 'AccountNavItem',
getInitialState : function() {
return {
@@ -70,7 +70,7 @@ const Account = createReactClass({
{global.account.username}
</Nav.item>
<Nav.item
href={`/user/${encodeURIComponent(global.account.username)}`}
href={`/user/${encodeURI(global.account.username)}`}
color='yellow'
icon='fas fa-beer'
>
@@ -111,4 +111,4 @@ const Account = createReactClass({
}
});
export default Account;
module.exports = Account;

View File

@@ -1,147 +1,144 @@
import './error-navitem.less';
import React from 'react';
import Nav from 'client/homebrew/navbar/nav.jsx';
require('./error-navitem.less');
const React = require('react');
const Nav = require('naturalcrit/nav/nav.jsx');
const createClass = require('create-react-class');
const ErrorNavItem = ({ error = '', clearError })=>{
const response = error.response;
const errorCode = error.code;
const status = response?.status;
const HBErrorCode = response?.body?.HBErrorCode;
const message = response?.body?.message;
const ErrorNavItem = createClass({
getDefaultProps : function() {
return {
error : '',
parent : null
};
},
render : function() {
const clearError = ()=>{
const state = {
error : null
};
if(this.props.parent.state.isSaving) {
state.isSaving = false;
}
this.props.parent.setState(state);
};
let errMsg = '';
try {
errMsg += `${error.toString()}\n\n`;
errMsg += `\`\`\`\n${error.stack}\n`;
errMsg += `${JSON.stringify(response?.error, null, ' ')}\n\`\`\``;
console.log(errMsg);
} catch {}
const error = this.props.error;
const response = error.response;
const status = response.status;
const HBErrorCode = response.body?.HBErrorCode;
const message = response.body?.message;
let errMsg = '';
try {
errMsg += `${error.toString()}\n\n`;
errMsg += `\`\`\`\n${error.stack}\n`;
errMsg += `${JSON.stringify(response.error, null, ' ')}\n\`\`\``;
console.log(errMsg);
} catch (e){}
if(status === 409) {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
{message ?? 'Conflict: please refresh to get latest changes'}
</div>
</Nav.item>;
}
if(status === 412) {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
{message ?? 'Your client is out of date. Please save your changes elsewhere and refresh.'}
</div>
</Nav.item>;
}
if(HBErrorCode === '04') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
You are no longer signed in as an author of
this brew! Were you signed out from a different
window? Visit our log in page, then try again!
<br></br>
<a target='_blank' rel='noopener noreferrer'
href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
<div className='confirm'>
Sign In
</div>
</a>
<div className='deny'>
Not Now
if(status === 409) {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
{message ?? 'Conflict: please refresh to get latest changes'}
</div>
</div>
</Nav.item>;
}
</Nav.item>;
}
if(response?.body?.errors?.[0].reason == 'storageQuotaExceeded') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Can't save because your Google Drive seems to be full!
</div>
</Nav.item>;
}
if(response?.req.url.match(/^\/api.*Google.*$/m)){
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Looks like your Google credentials have
expired! Visit our log in page to sign out
and sign back in with Google,
then try saving again!
<br></br>
<a target='_blank' rel='noopener noreferrer'
href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
<div className='confirm'>
Sign In
</div>
</a>
<div className='deny'>
Not Now
if(status === 412) {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
{message ?? 'Your client is out of date. Please save your changes elsewhere and refresh.'}
</div>
</div>
</Nav.item>;
}
</Nav.item>;
}
if(HBErrorCode === '04') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
You are no longer signed in as an author of
this brew! Were you signed out from a different
window? Visit our log in page, then try again!
<br></br>
<a target='_blank' rel='noopener noreferrer'
href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
<div className='confirm'>
Sign In
</div>
</a>
<div className='deny'>
Not Now
</div>
</div>
</Nav.item>;
}
if(response.body?.errors?.[0].reason == 'storageQuotaExceeded') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Can't save because your Google Drive seems to be full!
</div>
</Nav.item>;
}
if(response.req.url.match(/^\/api.*Google.*$/m)){
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Looks like your Google credentials have
expired! Visit our log in page to sign out
and sign back in with Google,
then try saving again!
<br></br>
<a target='_blank' rel='noopener noreferrer'
href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
<div className='confirm'>
Sign In
</div>
</a>
<div className='deny'>
Not Now
</div>
</div>
</Nav.item>;
}
if(HBErrorCode === '09') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Looks like there was a problem retreiving
the theme, or a theme that it inherits,
for this brew. Verify that brew <a className='lowercase' target='_blank' rel='noopener noreferrer' href={`/share/${response.body.brewId}`}>
{response.body.brewId}</a> still exists!
</div>
</Nav.item>;
}
if(HBErrorCode === '10') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Looks like the brew you have selected
as a theme is not tagged for use as a
theme. Verify that
brew <a className='lowercase' target='_blank' rel='noopener noreferrer' href={`/share/${response.body.brewId}`}>
{response.body.brewId}</a> has the <span className='lowercase'>meta:theme</span> tag!
</div>
</Nav.item>;
}
if(HBErrorCode === '09') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Looks like there was a problem retreiving
the theme, or a theme that it inherits,
for this brew. Verify that brew <a className='lowercase' target='_blank' rel='noopener noreferrer' href={`/share/${response.body.brewId}`}>
{response.body.brewId}</a> still exists!
<div className='errorContainer'>
Looks like there was a problem saving. <br />
Report the issue <a target='_blank' rel='noopener noreferrer' href={`https://github.com/naturalcrit/homebrewery/issues/new?template=save_issue.yml&error-code=${encodeURIComponent(errMsg)}`}>
here
</a>.
</div>
</Nav.item>;
}
});
if(HBErrorCode === '10') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Looks like the brew you have selected
as a theme is not tagged for use as a
theme. Verify that
brew <a className='lowercase' target='_blank' rel='noopener noreferrer' href={`/share/${response.body.brewId}`}>
{response.body.brewId}</a> has the <span className='lowercase'>meta:theme</span> tag!
</div>
</Nav.item>;
}
if(HBErrorCode === '13') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Server has lost connection to the database.
</div>
</Nav.item>;
}
if(errorCode === 'ECONNABORTED') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
The request to the server was interrupted or timed out.
This can happen due to a network issue, or if
trying to save a particularly large brew.
Please check your internet connection and try again.
</div>
</Nav.item>;
}
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer'>
Looks like there was a problem saving. <br />
Report the issue <a target='_blank' rel='noopener noreferrer' href={`https://github.com/naturalcrit/homebrewery/issues/new?template=save_issue.yml&error-code=${encodeURIComponent(errMsg)}`}>
here
</a>.
</div>
</Nav.item>;
};
export default ErrorNavItem;
module.exports = ErrorNavItem;

View File

@@ -1,70 +1,78 @@
.navItem.error {
position : relative;
background-color : @red;
position : relative;
background-color : @red;
}
.errorContainer {
position : absolute;
top : 100%;
left : 50%;
z-index : 1000;
width : 140px;
padding : 3px;
font-size : 10px;
font-weight : 800;
color : white;
text-align : center;
text-transform : uppercase;
background-color : #333333;
border : 3px solid #444444;
border-radius : 5px;
transform : translate(-50% + 3px, 10px);
animation-name : glideDown;
animation-duration : 0.4s;
.lowercase { text-transform : none; }
a { color : @teal; }
&::before {
position : absolute;
top : -23px;
left : 53px;
width : 0px;
height : 0px;
content : '';
border-top : 10px solid transparent;
border-right : 10px solid transparent;
border-bottom : 10px solid #444444;
border-left : 10px solid transparent;
}
&::after {
position : absolute;
top : -19px;
left : 53px;
width : 0px;
height : 0px;
content : '';
border-top : 10px solid transparent;
border-right : 10px solid transparent;
border-bottom : 10px solid #333333;
border-left : 10px solid transparent;
}
.deny {
display : inline-block;
width : 48%;
padding : 5px;
margin : 1px;
background-color : #333333;
border-left : 1px solid #666666;
.animate(background-color);
&:hover { background-color : red; }
}
.confirm {
display : inline-block;
width : 48%;
padding : 5px;
margin : 1px;
color : white;
background-color : #333333;
.animate(background-color);
&:hover { background-color : teal; }
.errorContainer{
animation-name: glideDown;
animation-duration: 0.4s;
position : absolute;
top : 100%;
left : 50%;
z-index : 1000;
width : 140px;
padding : 3px;
color : white;
background-color : #333;
border : 3px solid #444;
border-radius : 5px;
transform : translate(-50% + 3px, 10px);
text-align : center;
font-size : 10px;
font-weight : 800;
text-transform : uppercase;
.lowercase {
text-transform : none;
}
a{
color : @teal;
}
&:before {
content: "";
width: 0px;
height: 0px;
position: absolute;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid transparent;
border-bottom: 10px solid #444;
left: 53px;
top: -23px;
}
&:after {
content: "";
width: 0px;
height: 0px;
position: absolute;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid transparent;
border-bottom: 10px solid #333;
left: 53px;
top: -19px;
}
.deny {
width : 48%;
margin : 1px;
padding : 5px;
background-color : #333;
display : inline-block;
border-left : 1px solid #666;
.animate(background-color);
&:hover{
background-color : red;
}
}
.confirm {
width : 48%;
margin : 1px;
padding : 5px;
background-color : #333;
display : inline-block;
color : white;
.animate(background-color);
&:hover{
background-color : teal;
}
}
}

View File

@@ -1,9 +1,9 @@
import React from 'react';
import dedent from 'dedent';
const React = require('react');
const dedent = require('dedent-tabs').default;
import Nav from 'client/homebrew/navbar/nav.jsx';
const Nav = require('naturalcrit/nav/nav.jsx');
export default function(props){
module.exports = function(props){
return <Nav.dropdown>
<Nav.item color='grey' icon='fas fa-question-circle'>
need help?

View File

@@ -1,11 +1,11 @@
import React from 'react';
import createReactClass from 'create-react-class';
import Moment from 'moment';
const React = require('react');
const createClass = require('create-react-class');
const Moment = require('moment');
import Nav from 'client/homebrew/navbar/nav.jsx';
const Nav = require('naturalcrit/nav/nav.jsx');
const MetadataNav = createReactClass({
const MetadataNav = createClass({
displayName : 'MetadataNav',
getDefaultProps : function() {
return {
@@ -32,7 +32,7 @@ const MetadataNav = createReactClass({
return <>
{this.props.brew.authors.map((author, idx, arr)=>{
const spacer = arr.length - 1 == idx ? <></> : <span>, </span>;
return <span key={idx}><a className='userPageLink' href={`/user/${encodeURIComponent(author)}`}>{author}</a>{spacer}</span>;
return <span key={idx}><a className='userPageLink' href={`/user/${author}`}>{author}</a>{spacer}</span>;
})}
</>;
},
@@ -86,4 +86,4 @@ const MetadataNav = createReactClass({
});
export default MetadataNav;
module.exports = MetadataNav;

View File

@@ -1,11 +1,11 @@
import './navbar.less';
import React from 'react';
import createReactClass from 'create-react-class';
require('./navbar.less');
const React = require('react');
const createClass = require('create-react-class');
import Nav from 'client/homebrew/navbar/nav.jsx';
import PatreonNavItem from './patreon.navitem.jsx';
const Nav = require('naturalcrit/nav/nav.jsx');
const PatreonNavItem = require('./patreon.navitem.jsx');
const Navbar = createReactClass({
const Navbar = createClass({
displayName : 'Navbar',
getInitialState : function() {
return {
@@ -49,4 +49,4 @@ const Navbar = createReactClass({
}
});
export default Navbar;
module.exports = Navbar;

View File

@@ -24,11 +24,11 @@
}
.homebrew nav {
position : relative;
z-index : 2;
display : flex;
justify-content : space-between;
background-color : #333333;
position : relative;
z-index : 2;
display : flex;
justify-content : space-between;
.navSection {
display : flex;
@@ -37,10 +37,7 @@
&:has(.brewTitle) {
flex-grow : 1;
min-width : 300px;
}
>.brewTitle {
cursor:auto;
min-width : 300px;
}
}
// "NaturalCrit" logo
@@ -85,8 +82,8 @@
font-weight : 800;
line-height : 13px;
color : white;
text-transform : uppercase;
text-decoration : none;
text-transform : uppercase;
cursor : pointer;
background-color : #333333;
i {
@@ -109,11 +106,11 @@
display : block;
width : 100%;
overflow : hidden;
text-overflow : ellipsis;
font-size : 12px;
font-weight : 800;
color : white;
text-align : center;
text-overflow : ellipsis;
text-transform : initial;
white-space : nowrap;
background-color : transparent;
@@ -173,16 +170,16 @@
h4 {
box-sizing : border-box;
display : block;
flex-grow : 1;
flex-basis : 20%;
flex-grow : 1;
min-width : 76px;
padding : 5px 0;
color : #BBBBBB;
text-align : center;
}
p {
flex-grow : 1;
flex-basis : 80%;
flex-grow : 1;
padding : 5px 0;
font-family : 'Open Sans', sans-serif;
font-size : 10px;
@@ -218,10 +215,10 @@
z-index : 10000;
box-sizing : border-box;
display : block;
visibility : hidden;
width : 100%;
padding : 13px 5px;
text-align : center;
visibility : hidden;
background-color : #333333;
}
}

View File

@@ -1,103 +1,64 @@
import React from 'react';
import _ from 'lodash';
import Nav from 'client/homebrew/navbar/nav.jsx';
import { splitTextStyleAndMetadata } from '../../../shared/helpers.js';
const React = require('react');
const _ = require('lodash');
const Nav = require('naturalcrit/nav/nav.jsx');
const { splitTextStyleAndMetadata } = require('../../../shared/helpers.js'); // Importing the function from helpers.js
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const METAKEY = 'HB_newPage_meta';
const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style';
const METAKEY = 'homebrewery-new-meta';
const NewBrew = ()=>{
const handleFileChange = (e)=>{
const file = e.target.files[0];
if(!file) return;
if(!confirmLocalStorageChange()) return;
const reader = new FileReader();
reader.onload = (e)=>{
const fileContent = e.target.result;
const newBrew = { text: fileContent, style: '' };
if(fileContent.startsWith('```metadata')) {
splitTextStyleAndMetadata(newBrew);
localStorage.setItem(BREWKEY, newBrew.text);
localStorage.setItem(STYLEKEY, newBrew.style);
localStorage.setItem(METAKEY, JSON.stringify(
_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])
));
window.location.href = '/new';
return;
}
const type = file.name.split('.').pop().toLowerCase();
alert(`This file is invalid: ${!type ? 'Missing file extension' :`.${type} files are not supported`}. Only .txt files exported from the Homebrewery are allowed.`);
console.log(file);
};
reader.readAsText(file);
if(file) {
const reader = new FileReader();
reader.onload = (e)=>{
const fileContent = e.target.result;
const newBrew = {
text : fileContent,
style : ''
};
if(fileContent.startsWith('```metadata')) {
splitTextStyleAndMetadata(newBrew); // Modify newBrew directly
localStorage.setItem(BREWKEY, newBrew.text);
localStorage.setItem(STYLEKEY, newBrew.style);
localStorage.setItem(METAKEY, JSON.stringify(_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])));
window.location.href = '/new';
} else {
alert('This file is invalid, please, enter a valid file');
}
};
reader.readAsText(file);
}
};
const confirmLocalStorageChange = ()=>{
const currentText = localStorage.getItem(BREWKEY);
const currentStyle = localStorage.getItem(STYLEKEY);
const currentMeta = localStorage.getItem(METAKEY);
// TRUE if no data in any local storage key
// TRUE if data in any local storage key AND approval given
// FALSE if data in any local storage key AND approval declined
return (!(currentText || currentStyle || currentMeta) || confirm(
`You have made changes in the new brew space. If you continue, that information will be PERMANENTLY LOST.\nAre you sure you wish to continue?`
));
};
const clearLocalStorage = ()=>{
if(!confirmLocalStorageChange()) return;
localStorage.removeItem(BREWKEY);
localStorage.removeItem(STYLEKEY);
localStorage.removeItem(METAKEY);
window.location.href = '/new';
return;
};
return (
<Nav.dropdown>
<Nav.item
className='new'
color='purple'
icon='fa-solid fa-plus-square'>
new
new
</Nav.item>
<Nav.item
className='new'
className='fromBlank'
href='/new'
newTab={true}
color='purple'
icon='fa-solid fa-file'>
resume draft
</Nav.item>
<Nav.item
className='fromBlank'
newTab={true}
color='yellow'
icon='fa-solid fa-file-circle-plus'
onClick={()=>{ clearLocalStorage(); }}>
from blank
from blank
</Nav.item>
<Nav.item
className='fromFile'
color='green'
color='purple'
icon='fa-solid fa-upload'
onClick={()=>{ document.getElementById('uploadTxt').click(); }}>
<input id='uploadTxt' className='newFromLocal' type='file' onChange={handleFileChange} style={{ display: 'none' }} />
from file
from file
</Nav.item>
</Nav.dropdown>
);
};
export default NewBrew;
module.exports = NewBrew;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import Nav from 'client/homebrew/navbar/nav.jsx';
const React = require('react');
const Nav = require('naturalcrit/nav/nav.jsx');
export default function(props){
module.exports = function(props){
return <Nav.item
className='patreon'
newTab={true}

View File

@@ -1,8 +1,8 @@
import React from 'react';
import Nav from 'client/homebrew/navbar/nav.jsx';
import { printCurrentBrew } from '../../../shared/helpers.js';
const React = require('react');
const Nav = require('naturalcrit/nav/nav.jsx');
const { printCurrentBrew } = require('../../../shared/helpers.js');
export default function(){
module.exports = function(){
return <Nav.item onClick={printCurrentBrew} color='purple' icon='far fa-file-pdf'>
get PDF
</Nav.item>;

View File

@@ -1,15 +1,15 @@
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import Moment from 'moment';
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const Moment = require('moment');
import Nav from 'client/homebrew/navbar/nav.jsx';
const Nav = require('naturalcrit/nav/nav.jsx');
const EDIT_KEY = 'HB_nav_recentlyEdited';
const VIEW_KEY = 'HB_nav_recentlyViewed';
const EDIT_KEY = 'homebrewery-recently-edited';
const VIEW_KEY = 'homebrewery-recently-viewed';
const RecentItems = createReactClass({
const RecentItems = createClass({
DisplayName : 'RecentItems',
getDefaultProps : function() {
return {
@@ -175,7 +175,7 @@ const RecentItems = createReactClass({
});
export default {
module.exports = {
edited : (props)=>{
return <RecentItems

View File

@@ -1,35 +0,0 @@
import React from 'react';
import dedent from 'dedent';
import Nav from 'client/homebrew/navbar/nav.jsx';
const getShareId = (brew)=>(
brew.googleId && !brew.stubbed
? brew.googleId + brew.shareId
: brew.shareId
);
const getRedditLink = (brew)=>{
const text = dedent`
Hey guys! I've been working on this homebrew. I'd love your feedback. Check it out.
**[Homebrewery Link](${global.config.baseUrl}/share/${getShareId(brew)})**`;
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(brew.title.toWellFormed())}&text=${encodeURIComponent(text)}`;
};
export default ({ brew })=>(
<Nav.dropdown>
<Nav.item color='teal' icon='fas fa-share-alt'>
share
</Nav.item>
<Nav.item color='blue' href={`/share/${getShareId(brew)}`}>
view
</Nav.item>
<Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}`);}}>
copy url
</Nav.item>
<Nav.item color='blue' href={getRedditLink(brew)} newTab rel='noopener noreferrer'>
post to reddit
</Nav.item>
</Nav.dropdown>
);

View File

@@ -1,8 +1,8 @@
import React from 'react';
const React = require('react');
import Nav from 'client/homebrew/navbar/nav.jsx';
const Nav = require('naturalcrit/nav/nav.jsx');
export default function (props) {
module.exports = function (props) {
return (
<Nav.item
color='purple'

View File

@@ -1,7 +1,7 @@
import React from 'react';
import moment from 'moment';
import UIPage from '../basePages/uiPage/uiPage.jsx';
import NaturalCritIcon from 'client/components/svg/naturalcrit-d20.svg.jsx';
const React = require('react');
const moment = require('moment');
const UIPage = require('../basePages/uiPage/uiPage.jsx');
const NaturalCritIcon = require('naturalcrit/svg/naturalcrit.svg.jsx');
let SAVEKEY = '';
@@ -13,7 +13,7 @@ const AccountPage = (props)=>{
// initialize save location from local storage based on user id
React.useEffect(()=>{
if(!saveLocation && accountDetails.username) {
SAVEKEY = `HB_editor_defaultSave_${accountDetails.username}`;
SAVEKEY = `HOMEBREWERY-DEFAULT-SAVE-LOCATION-${accountDetails.username}`;
// if no SAVEKEY in local storage, default save location to Google Drive if user has Google account.
let saveLocation = window.localStorage.getItem(SAVEKEY);
saveLocation = saveLocation ?? (accountDetails.googleId ? 'GOOGLE-DRIVE' : 'HOMEBREWERY');
@@ -79,4 +79,4 @@ const AccountPage = (props)=>{
</UIPage>);
};
export default AccountPage;
module.exports = AccountPage;

View File

@@ -1,11 +1,12 @@
import './brewItem.less';
import React, { useCallback } from 'react';
import moment from 'moment';
require('./brewItem.less');
const React = require('react');
const { useCallback } = React;
const moment = require('moment');
import request from '../../../../utils/request-middleware.js';
import googleDriveIcon from '../../../../googleDrive.svg';
import homebreweryIcon from '../../../../thumbnail.svg';
import dedent from 'dedent';
const googleDriveIcon = require('../../../../googleDrive.svg');
const homebreweryIcon = require('../../../../thumbnail.png');
const dedent = require('dedent-tabs').default;
const BrewItem = ({
brew = {
@@ -29,11 +30,11 @@ const BrewItem = ({
}
request.delete(`/api/${brew.googleId ?? ''}${brew.editId}`).send().end((err, res)=>{
if(err) reportError(err); else window.location.reload();
});
if (err) reportError(err); else window.location.reload();
});
}, [brew, reportError]);
const updateFilter = useCallback((type, term)=>updateListFilter(type, term), [updateListFilter]);
const updateFilter = useCallback((type, term)=> updateListFilter(type, term), [updateListFilter]);
const renderDeleteBrewLink = ()=>{
if(!brew.editId) return null;
@@ -142,7 +143,7 @@ const BrewItem = ({
<span title="Username contained an email address; hidden to protect user's privacy">
{author}
</span>
) : (<a href={`/user/${encodeURIComponent(author)}`}>{author}</a>)}
) : (<a href={`/user/${author}`}>{author}</a>)}
{index < brew.authors.length - 1 && ', '}
</React.Fragment>
))}
@@ -175,4 +176,4 @@ const BrewItem = ({
);
};
export default BrewItem;
module.exports = BrewItem;

View File

@@ -1,129 +1,148 @@
.brewItem {
.brewItem{
position : relative;
box-sizing : border-box;
display : inline-block;
vertical-align : top;
box-sizing : border-box;
box-sizing : border-box;
overflow : hidden;
width : 48%;
min-height : 105px;
padding : 5px 15px 2px 6px;
padding-right : 15px;
margin-right : 15px;
margin-bottom : 15px;
overflow : hidden;
vertical-align : top;
background-color : #CAB2802E;
border : 1px solid #C9AD6A;
padding : 5px 15px 2px 6px;
padding-right : 15px;
border : 1px solid #c9ad6a;
border-radius : 5px;
box-shadow : 0px 4px 5px 0px #333333;
break-inside : avoid;
-webkit-column-break-inside : avoid;
page-break-inside : avoid;
.thumbnail {
position : absolute;
top : 0;
right : 0;
z-index : -1;
width : 150px;
height : 100%;
background-repeat : no-repeat;
background-position : right top;
background-size : contain;
opacity : 50%;
-webkit-mask-image : linear-gradient(80deg, #00000000 20%, #005500 40%);
mask-image : linear-gradient(80deg, #00000000 20%, #005500 40%);
break-inside : avoid;
box-shadow : 0px 4px 5px 0px #333;
background-color : #cab2802e;
.thumbnail {
position: absolute;
width: 150px;
height: 100%;
top: 0;
right: 0;
z-index: -1;
background-size: contain;
background-repeat: no-repeat;
background-position: right top;
mask-image: linear-gradient(80deg, #0000 20%, #050 40%);
-webkit-mask-image: linear-gradient(80deg, #0000 20%, #050 40%);
opacity: 50%;
}
.text {
min-height : 54px;
h4 {
h4{
margin-bottom : 5px;
font-size : 2.2em;
}
}
.info {
position : initial;
bottom : 2px;
font-family : "ScalySansRemake";
.info{
position: initial;
bottom: 2px;
font-family : ScalySansRemake;
font-size : 1.2em;
& > span {
&>span{
margin-right : 12px;
line-height : 1.5em;
a { color : inherit; }
a {
color:inherit;
}
}
}
.brewTags span {
display : inline-block;
padding : 2px;
margin : 2px;
font-weight : bold;
white-space : nowrap;
cursor : pointer;
background-color : #C8AC6E3B;
border : 1px solid #C8AC6E;
border-color : currentColor;
border-radius : 4px;
&::before {
margin-right : 3px;
font-family : 'Font Awesome 6 Free';
font-size : 12px;
background-color: #c8ac6e3b;
margin: 2px;
padding: 2px;
border: 1px solid #c8ac6e;
border-radius: 4px;
white-space: nowrap;
display: inline-block;
font-weight: bold;
border-color: currentColor;
cursor : pointer;
&:before {
font-family: 'Font Awesome 5 Free';
font-size: 12px;
margin-right: 3px;
}
&.type {
color : #008000;
background-color : #0080003B;
&::before { content : '\f0ad'; }
background-color: #0080003b;
color: #008000;
&:before{
content: '\f0ad';
}
}
&.group {
color : #000000;
background-color : #5050503B;
&::before { content : '\f500'; }
background-color: #5050503b;
color: #000000;
&:before{
content: '\f500';
}
}
&.meta {
color : #000080;
background-color : #0000803B;
&::before { content : '\f05a'; }
background-color: #0000803b;
color: #000080;
&:before{
content: '\f05a';
}
}
&.system {
color : #800000;
background-color : #8000003B;
&::before { content : '\f518'; }
background-color: #8000003b;
color: #800000;
&:before{
content: '\f518';
}
}
}
&:hover {
.links { opacity : 1; }
&:hover{
.links{
opacity : 1;
}
}
&:nth-child(2n + 1) { margin-right : 0px; }
.links {
&:nth-child(2n + 1){
margin-right : 0px;
}
.links{
.animate(opacity);
position : absolute;
top : 0px;
right : 0px;
width : 2em;
height : 100%;
text-align : center;
background-color : fade(black, 60%);
width : 2em;
opacity : 0;
a {
background-color : fade(black, 60%);
text-align : center;
a{
.animate(opacity);
display : block;
margin : 8px 0px;
opacity : 0.6;
font-size : 1.3em;
color : white;
text-decoration : unset;
opacity : 0.6;
&:hover { opacity : 1; }
i { cursor : pointer; }
&:hover{
opacity : 1;
}
i{
cursor : pointer;
}
}
}
.googleDriveIcon {
height : 18px;
padding : 0px;
margin : -5px;
height : 18px;
}
.homebreweryIcon {
position : relative;
padding : 0px;
top : 5px;
left : -7.5px;
height : 18px;
mix-blend-mode : darken;
height : 24px;
position : relative;
top : 5px;
left : -5px;
}
}

View File

@@ -1,20 +1,18 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
import './listPage.less';
import React from 'react';
import createReactClass from 'create-react-class';
import _ from 'lodash';
import moment from 'moment';
require('./listPage.less');
const React = require('react');
const createClass = require('create-react-class');
const _ = require('lodash');
const moment = require('moment');
import BrewItem from './brewItem/brewItem.jsx';
const BrewItem = require('./brewItem/brewItem.jsx');
const USERPAGE_SORT_DIR = 'HB_listPage_sortDir';
const USERPAGE_SORT_TYPE = 'HB_listPage_sortType';
const USERPAGE_GROUP_VISIBILITY_PREFIX = 'HB_listPage_visibility_group';
const USERPAGE_KEY_PREFIX = 'HOMEBREWERY-LISTPAGE';
const DEFAULT_SORT_TYPE = 'alpha';
const DEFAULT_SORT_DIR = 'asc';
const ListPage = createReactClass({
const ListPage = createClass({
displayName : 'ListPage',
getDefaultProps : function() {
return {
@@ -52,12 +50,12 @@ const ListPage = createReactClass({
// LOAD FROM LOCAL STORAGE
if(typeof window !== 'undefined') {
const newSortType = (this.state.sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE));
const newSortDir = (this.state.sortDir ?? (localStorage.getItem(USERPAGE_SORT_DIR) || DEFAULT_SORT_DIR));
const newSortType = (this.state.sortType ?? (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-SORTTYPE`) || DEFAULT_SORT_TYPE));
const newSortDir = (this.state.sortDir ?? (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-SORTDIR`) || DEFAULT_SORT_DIR));
this.updateUrl(this.state.filterString, newSortType, newSortDir);
const brewCollection = this.props.brewCollection.map((brewGroup)=>{
brewGroup.visible = (localStorage.getItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`) ?? 'true')=='true';
brewGroup.visible = (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-VISIBILITY-${brewGroup.class}`) ?? 'true')=='true';
return brewGroup;
});
@@ -75,10 +73,10 @@ const ListPage = createReactClass({
saveToLocalStorage : function() {
this.state.brewCollection.map((brewGroup)=>{
localStorage.setItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`, `${brewGroup.visible}`);
localStorage.setItem(`${USERPAGE_KEY_PREFIX}-VISIBILITY-${brewGroup.class}`, `${brewGroup.visible}`);
});
localStorage.setItem(USERPAGE_SORT_TYPE, this.state.sortType);
localStorage.setItem(USERPAGE_SORT_DIR, this.state.sortDir);
localStorage.setItem(`${USERPAGE_KEY_PREFIX}-SORTTYPE`, this.state.sortType);
localStorage.setItem(`${USERPAGE_KEY_PREFIX}-SORTDIR`, this.state.sortDir);
},
renderBrews : function(brews){
@@ -279,4 +277,4 @@ const ListPage = createReactClass({
}
});
export default ListPage;
module.exports = ListPage;

View File

@@ -1,5 +1,5 @@
.noColumns() {
.noColumns(){
column-count : auto;
column-fill : auto;
column-gap : normal;
@@ -13,151 +13,177 @@
height : auto;
min-height : 279.4mm;
margin : 20px auto;
contain : unset;
contain : unset;
}
.listPage {
.content {
.listPage{
.content{
z-index : 1;
.page {
.page{
.noColumns() !important; //Needed to override PHB Theme since this is on a lower @layer
&::after { display : none; }
.noBrews {
&::after{
display : none;
}
.noBrews{
margin : 10px 0px;
font-size : 1.3em;
font-style : italic;
}
.brewCollection {
h1:hover { cursor : pointer; }
.active::before, .inactive::before {
padding-right : 0.5em;
font-family : 'Font Awesome 6 Free';
font-size : 0.6cm;
font-weight : 900;
h1:hover{
cursor: pointer;
}
.active::before, .inactive::before {
font-family: 'Font Awesome 5 Free';
font-weight: 900;
font-size: 0.6cm;
padding-right: 0.5em;
}
.active {
color: var(--HB_Color_HeaderText);
}
.active::before {
content: '\f107';
}
.inactive {
color: #707070;
}
.inactive::before {
content: '\f105';
}
.active { color : var(--HB_Color_HeaderText); }
.active::before { content : '\f107'; }
.inactive { color : #707070; }
.inactive::before { content : '\f105'; }
}
}
}
.sort-container {
position : sticky;
top : 0;
left : 0;
z-index : 1;
display : flex;
flex-wrap : wrap;
row-gap : 5px;
column-gap : 15px;
align-items : baseline;
justify-content : center;
width : 100%;
height : 30px;
font-family : 'Open Sans', sans-serif;
color : white;
text-align : center;
background-color : #555555;
border-top : 1px solid #666666;
border-bottom : 1px solid #666666;
h6 {
font-family : 'Open Sans', sans-serif;
position : sticky;
top : 0;
left : 0;
width : 100%;
height : 30px;
background-color : #555;
border-top : 1px solid #666;
border-bottom : 1px solid #666;
color : white;
text-align : center;
z-index : 1;
display : flex;
justify-content : center;
align-items : baseline;
column-gap : 15px;
row-gap : 5px;
flex-wrap : wrap;
h6{
text-transform : uppercase;
font-family : 'Open Sans', sans-serif;
font-size : 11px;
font-weight : bold;
text-transform : uppercase;
}
.sort-option {
display : flex;
align-items : center;
height : 100%;
padding : 0 8px;
color : #CCCCCC;
display: flex;
align-items: center;
padding: 0 8px;
color: #ccc;
height: 100%;
&:hover { background-color : #444444; }
&:hover{
background-color : #444;
}
&.active {
font-weight : bold;
color : #DDDDDD;
background-color : #333333;
font-weight: bold;
color: #ddd;
background-color: #333;
button {
height : 100%;
font-weight : 800;
color : white;
& + .sortDir { padding-left : 5px; }
button {
color: white;
font-weight: 800;
height: 100%;
& + .sortDir {
padding-left: 5px;
}
}
}
}
.filter-option {
margin-left : 20px;
font-size : 11px;
margin-left: 20px;
background-color : transparent !important;
i { padding-right : 5px; }
}
button {
padding : 0;
font-family : 'Open Sans', sans-serif;
font-size : 11px;
font-weight : normal;
color : #CCCCCC;
text-transform : uppercase;
background-color : transparent;
i{
padding-right : 5px;
}
}
button{
background-color : transparent;
font-family : 'Open Sans', sans-serif;
text-transform : uppercase;
font-weight : normal;
font-size : 11px;
color : #ccc;
padding : 0;
}
}
.tags-container {
display : flex;
flex-wrap : wrap;
row-gap : 5px;
column-gap : 15px;
align-items : center;
justify-content : center;
height : 30px;
background-color : #555;
border-top : 1px solid #666;
border-bottom : 1px solid #666;
color : white;
background-color : #555555;
border-top : 1px solid #666666;
border-bottom : 1px solid #666666;
display : flex;
justify-content : center;
align-items : center;
column-gap : 15px;
row-gap : 5px;
flex-wrap : wrap;
span {
padding : 3px;
font-family : 'Open Sans', sans-serif;
font-size : 11px;
font-weight : bold;
color : #DFDFDF;
cursor : pointer;
border : 1px solid;
border-radius : 3px;
&::before {
margin-right : 3px;
font-family : 'Font Awesome 6 Free';
font-size : 12px;
padding : 3px;
cursor : pointer;
color: #dfdfdf;
&:before {
font-family: 'Font Awesome 5 Free';
font-size: 12px;
margin-right: 3px;
}
&::after {
margin-left : 3px;
font-family : 'Font Awesome 6 Free';
font-size : 12px;
content : '\f00d';
&:after {
content: '\f00d';
font-family: 'Font Awesome 5 Free';
font-size: 12px;
margin-left: 3px;
}
&.type {
background-color : #008000;
border-color : #00A000;
&::before { content : '\f0ad'; }
background-color: #008000;
border-color: #00a000;
&:before{
content: '\f0ad';
}
}
&.group {
background-color : #505050;
border-color : #000000;
&::before { content : '\f500'; }
background-color: #505050;
border-color: #000000;
&:before{
content: '\f500';
}
}
&.meta {
background-color : #000080;
border-color : #0000A0;
&::before { content : '\f05a'; }
background-color: #000080;
border-color: #0000a0;
&:before{
content: '\f05a';
}
}
&.system {
background-color : #800000;
border-color : #A00000;
&::before { content : '\f518'; }
background-color: #800000;
border-color: #a00000;
&:before{
content: '\f518';
}
}
}
}

View File

@@ -1,17 +1,16 @@
import './uiPage.less';
import React from 'react';
import createReactClass from 'create-react-class';
require('./uiPage.less');
const React = require('react');
const createClass = require('create-react-class');
import Nav from 'client/homebrew/navbar/nav.jsx';
import Navbar from 'client/homebrew/navbar/navbar.jsx';
import NewBrewItem from 'client/homebrew/navbar/newbrew.navitem.jsx';
import HelpNavItem from 'client/homebrew/navbar/help.navitem.jsx';
import RecentNavItems from 'client/homebrew/navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from 'client/homebrew/navbar/account.navitem.jsx';
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../../navbar/navbar.jsx');
const NewBrewItem = require('../../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../../navbar/help.navitem.jsx');
const RecentNavItem = require('../../../navbar/recent.navitem.jsx').both;
const Account = require('../../../navbar/account.navitem.jsx');
const UIPage = createReactClass({
const UIPage = createClass({
displayName : 'UIPage',
render : function(){
@@ -36,4 +35,4 @@ const UIPage = createReactClass({
}
});
export default UIPage;
module.exports = UIPage;

View File

@@ -1,7 +1,7 @@
.homebrew {
.uiPage.sitePage {
.content {
width : ~'min(90vw, 1000px)';
width : ~"min(90vw, 1000px)";
padding : 2% 4%;
margin-top : 25px;
margin-right : auto;
@@ -17,20 +17,19 @@
border : 2px solid black;
border-radius : 5px;
button {
width : 125px;
margin-right : 5px;
color : black;
background-color : transparent;
border : 1px solid black;
border-radius : 5px;
width : 125px;
color : black;
margin-right : 5px;
&.active {
color : white;
background-color : #00000077;
&::before {
margin-right : 5px;
font-family : 'Font Awesome 6 Free';
font-weight : 900;
content : '\f00c';
background-color: #0007;
color: white;
&:before {
content: '\f00c';
font-family: 'FONT AWESOME 5 FREE';
margin-right: 5px;
}
}
}
@@ -60,11 +59,6 @@
padding-left : 1.25em;
list-style : square;
}
.blank {
height : 1em;
margin-top : 0;
& + * { margin-top : 0; }
}
}
}
}

View File

@@ -1,419 +1,475 @@
/* eslint-disable max-lines */
import './editPage.less';
require('./editPage.less');
const React = require('react');
const _ = require('lodash');
const createClass = require('create-react-class');
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from 'markdown.js';
import _ from 'lodash';
import request from '../../utils/request-middleware.js';
const { Meta } = require('vitreum/headtags');
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
import SplitPane from 'client/components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const Account = require('../../navbar/account.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const VaultNavItem = require('../../navbar/vault.navitem.jsx');
import Nav from 'client/homebrew/navbar/nav.jsx';
import Navbar from 'client/homebrew/navbar/navbar.jsx';
import NewBrewItem from 'client/homebrew/navbar/newbrew.navitem.jsx';
import AccountNavItem from 'client/homebrew/navbar/account.navitem.jsx';
import ErrorNavItem from 'client/homebrew/navbar/error-navitem.jsx';
import HelpNavItem from 'client/homebrew/navbar/help.navitem.jsx';
import VaultNavItem from 'client/homebrew/navbar/vault.navitem.jsx';
import PrintNavItem from 'client/homebrew/navbar/print.navitem.jsx';
import RecentNavItems from 'client/homebrew/navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
// Page specific imports
import { Meta } from 'vitreum/headtags';
import { md5 } from 'hash-wasm';
import { gzipSync, strToU8 } from 'fflate';
import { makePatches, stringifyPatches } from '@sanity/diff-match-patch';
const LockNotification = require('./lockNotification/lockNotification.jsx');
import Markdown from 'naturalcrit/markdown.js';
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
import ShareNavItem from 'client/homebrew/navbar/share.navitem.jsx';
import LockNotification from './lockNotification/lockNotification.jsx';
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
import googleDriveIcon from '../../googleDrive.svg';
const googleDriveIcon = require('../../googleDrive.svg');
const SAVE_TIMEOUT = 10000;
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
const EditPage = createClass({
displayName : 'EditPage',
getDefaultProps : function() {
return {
brew : DEFAULT_BREW_LOAD
};
},
const AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const SNIPKEY = 'HB_newPage_snippets';
const METAKEY = 'HB_newPage_meta';
getInitialState : function() {
return {
brew : this.props.brew,
isSaving : false,
isPending : false,
alertTrashedGoogleBrew : this.props.brew.trashed,
alertLoginToTransfer : false,
saveGoogle : this.props.brew.googleId ? true : false,
confirmGoogleTransfer : false,
error : null,
htmlErrors : Markdown.validate(this.props.brew.text),
url : '',
autoSave : true,
autoSaveWarning : false,
unsavedTime : new Date(),
currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
displayLockMessage : this.props.brew.lock || false,
themeBundle : {}
};
},
const useLocalStorage = false;
const neverSaved = false;
editor : React.createRef(null),
savedBrew : null,
const EditPage = (props)=>{
props = {
brew : DEFAULT_BREW_LOAD,
...props
};
componentDidMount : function(){
this.setState({
url : window.location.href
});
const [currentBrew , setCurrentBrew ] = useState(props.brew);
const [isSaving , setIsSaving ] = useState(false);
const [lastSavedTime , setLastSavedTime ] = useState(new Date());
const [saveGoogle , setSaveGoogle ] = useState(!!props.brew.googleId);
const [error , setError ] = useState(null);
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle ] = useState({});
const [unsavedChanges , setUnsavedChanges ] = useState(false);
const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed);
const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false);
const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false);
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true);
const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true);
this.savedBrew = JSON.parse(JSON.stringify(this.props.brew)); //Deep copy
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const saveTimeout = useRef(null);
const warnUnsavedTimeout = useRef(null);
const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
this.setState({ autoSave: JSON.parse(localStorage.getItem('AUTOSAVE_ON')) ?? true }, ()=>{
if(this.state.autoSave){
this.trySave();
} else {
this.setState({ autoSaveWarning: true });
}
});
useEffect(()=>{
const autoSavePref = JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
setAutoSaveEnabled(autoSavePref);
setWarnUnsavedChanges(!autoSavePref);
setHTMLErrors(Markdown.validate(currentBrew.text));
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
if(e.keyCode === 83) trySaveRef.current(true);
if(e.keyCode === 80) printCurrentBrew();
if([83, 80].includes(e.keyCode)) {
e.stopPropagation();
e.preventDefault();
window.onbeforeunload = ()=>{
if(this.state.isSaving || this.state.isPending){
return 'You have unsaved changes!';
}
};
document.addEventListener('keydown', handleControlKeys);
window.onbeforeunload = ()=>{
if(unsavedChangesRef.current)
return 'You have unsaved changes!';
};
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
window.onBeforeUnload = null;
};
}, []);
this.setState((prevState)=>({
htmlErrors : Markdown.validate(prevState.brew.text)
}));
useEffect(()=>{
trySaveRef.current = trySave;
unsavedChangesRef.current = unsavedChanges;
});
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange);
document.addEventListener('keydown', this.handleControlKeys);
},
componentWillUnmount : function() {
window.onbeforeunload = function(){};
document.removeEventListener('keydown', this.handleControlKeys);
},
if(autoSaveEnabled) trySave(false, hasChange);
}, [currentBrew]);
useEffect(()=>{
trySave(true);
}, [saveGoogle]);
const handleSplitMove = ()=>{
editorRef.current?.update();
};
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
if(subfield == 'renderer' || subfield == 'theme')
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
//If there are HTML errors, run the validator on every change to give quick feedback
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
setHTMLErrors(Markdown.validate(value));
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
if(useLocalStorage) {
if(field == 'text') localStorage.setItem(BREWKEY, value);
if(field == 'style') localStorage.setItem(STYLEKEY, value);
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
renderer : value.renderer,
theme : value.theme,
lang : value.lang
}));
handleControlKeys : function(e){
if(!(e.ctrlKey || e.metaKey)) return;
const S_KEY = 83;
const P_KEY = 80;
if(e.keyCode == S_KEY) this.trySave(true);
if(e.keyCode == P_KEY) printCurrentBrew();
if(e.keyCode == P_KEY || e.keyCode == S_KEY){
e.stopPropagation();
e.preventDefault();
}
};
},
const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({
...prevBrew,
style : newData.style,
text : newData.text,
snippets : newData.snippets
}));
handleSplitMove : function(){
this.editor.current.update();
},
const resetWarnUnsavedTimer = ()=>{
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
clearTimeout(warnUnsavedTimeout.current);
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
};
handleEditorViewPageChange : function(pageNumber){
this.setState({ currentEditorViewPageNum: pageNumber });
},
const handleGoogleClick = ()=>{
handleEditorCursorPageChange : function(pageNumber){
this.setState({ currentEditorCursorPageNum: pageNumber });
},
handleBrewRendererPageChange : function(pageNumber){
this.setState({ currentBrewRendererPageNum: pageNumber });
},
handleTextChange : function(text){
//If there are errors, run the validator on every change to give quick feedback
let htmlErrors = this.state.htmlErrors;
if(htmlErrors.length) htmlErrors = Markdown.validate(text);
this.setState((prevState)=>({
brew : { ...prevState.brew, text: text },
isPending : true,
htmlErrors : htmlErrors,
}), ()=>{if(this.state.autoSave) this.trySave();});
},
handleStyleChange : function(style){
this.setState((prevState)=>({
brew : { ...prevState.brew, style: style },
isPending : true
}), ()=>{if(this.state.autoSave) this.trySave();});
},
handleMetaChange : function(metadata, field=undefined){
if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
fetchThemeBundle(this, metadata.renderer, metadata.theme);
this.setState((prevState)=>({
brew : {
...prevState.brew,
...metadata
},
isPending : true,
}), ()=>{if(this.state.autoSave) this.trySave();});
},
hasChanges : function(){
return !_.isEqual(this.state.brew, this.savedBrew);
},
updateBrew : function(newData){
this.setState((prevState)=>({
brew : {
...prevState.brew,
style : newData.style,
text : newData.text
}
}));
},
trySave : function(immediate=false){
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
if(this.hasChanges()){
this.debounceSave();
} else {
this.debounceSave.cancel();
}
if(immediate) this.debounceSave.flush();
},
handleGoogleClick : function(){
if(!global.account?.googleId) {
setAlertLoginToTransfer(true);
this.setState({
alertLoginToTransfer : true
});
return;
}
this.setState((prevState)=>({
confirmGoogleTransfer : !prevState.confirmGoogleTransfer
}));
this.setState({
error : null,
isSaving : false
});
},
setConfirmGoogleTransfer((prev)=>!prev);
setError(null);
};
closeAlerts : function(event){
event.stopPropagation(); //Only handle click once so alert doesn't reopen
this.setState({
alertTrashedGoogleBrew : false,
alertLoginToTransfer : false,
confirmGoogleTransfer : false
});
},
const closeAlerts = (e)=>{
e.stopPropagation(); //Only handle click once so alert doesn't reopen
setAlertTrashedGoogleBrew(false);
setAlertLoginToTransfer(false);
setConfirmGoogleTransfer(false);
};
toggleGoogleStorage : function(){
this.setState((prevState)=>({
saveGoogle : !prevState.saveGoogle,
isSaving : false,
error : null
}), ()=>this.save());
},
const toggleGoogleStorage = ()=>{
setSaveGoogle((prev)=>!prev);
setError(null);
};
save : async function(){
if(this.debounceSave && this.debounceSave.cancel) this.debounceSave.cancel();
const trySave = (immediate = false, hasChanges = true)=>{
clearTimeout(saveTimeout.current);
if(isSaving) return;
if(!hasChanges && !immediate) return;
const newTimeout = immediate ? 0 : SAVE_TIMEOUT;
this.setState((prevState)=>({
isSaving : true,
error : null,
htmlErrors : Markdown.validate(prevState.brew.text)
}));
saveTimeout.current = setTimeout(async ()=>{
setIsSaving(true);
setError(null);
await save(currentBrew, saveGoogle)
.catch((err)=>{
setError(err);
});
setIsSaving(false);
setLastSavedTime(new Date());
if(!autoSaveEnabled) resetWarnUnsavedTimer();
}, newTimeout);
};
const save = async (brew, saveToGoogle)=>{
setHTMLErrors(Markdown.validate(brew.text));
await updateHistory(brew).catch(console.error);
await updateHistory(this.state.brew).catch(console.error);
await versionHistoryGarbageCollection().catch(console.error);
//Prepare content to send to server
const brewToSave = {
...brew,
text : brew.text.normalize('NFC'),
pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1,
patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))),
hash : await md5(lastSavedBrew.current.text.normalize('NFC')),
textBin : undefined,
version : lastSavedBrew.current.version
};
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
const compressedBrew = gzipSync(strToU8(JSON.stringify(brewToSave)));
const transfer = saveToGoogle === _.isNil(brew.googleId);
const params = transfer ? `?${saveToGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : '';
const brew = this.state.brew;
brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`;
const res = await request
.put(`/api/update/${brewToSave.editId}${params}`)
.set('Content-Encoding', 'gzip')
.set('Content-Type', 'application/json')
.send(compressedBrew)
.put(`/api/update/${brew.editId}${params}`)
.send(brew)
.catch((err)=>{
console.error('Error Updating Local Brew');
setError(err);
console.log('Error Updating Local Brew');
this.setState({ error: err });
});
if(!res) return;
const updatedFields = {
googleId : res.body.googleId ?? null,
editId : res.body.editId,
shareId : res.body.shareId,
version : res.body.version
};
this.savedBrew = res.body;
history.replaceState(null, null, `/edit/${this.savedBrew.editId}`);
lastSavedBrew.current = {
...brew,
...updatedFields
};
setCurrentBrew((prevBrew)=>({
...prevBrew,
...updatedFields
this.setState((prevState)=>({
brew : { ...prevState.brew,
googleId : this.savedBrew.googleId ? this.savedBrew.googleId : null,
editId : this.savedBrew.editId,
shareId : this.savedBrew.shareId,
version : this.savedBrew.version
},
isPending : false,
isSaving : false,
unsavedTime : new Date()
}));
},
history.replaceState(null, null, `/edit/${res.body.editId}`);
};
renderGoogleDriveIcon : function(){
return <Nav.item className='googleDriveStorage' onClick={this.handleGoogleClick}>
<img src={googleDriveIcon} className={this.state.saveGoogle ? '' : 'inactive'} alt='Google Drive icon'/>
const renderGoogleDriveIcon = ()=>(
<Nav.item className='googleDriveStorage' onClick={handleGoogleClick}>
<img src={googleDriveIcon} className={saveGoogle ? '' : 'inactive'} alt='Google Drive icon' />
{confirmGoogleTransfer && (
<div className='errorContainer' onClick={closeAlerts}>
{saveGoogle
? 'Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?'
: 'Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?'}
{this.state.confirmGoogleTransfer &&
<div className='errorContainer' onClick={this.closeAlerts}>
{ this.state.saveGoogle
? `Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?`
: `Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?`
}
<br />
<div className='confirm' onClick={toggleGoogleStorage}> Yes </div>
<div className='deny'> No </div>
<div className='confirm' onClick={this.toggleGoogleStorage}>
Yes
</div>
<div className='deny'>
No
</div>
</div>
)}
}
{alertLoginToTransfer && (
<div className='errorContainer' onClick={closeAlerts}>
You must be signed in to a Google account to transfer between the homebrewery and Google Drive!
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
<div className='confirm'> Sign In </div>
{this.state.alertLoginToTransfer &&
<div className='errorContainer' onClick={this.closeAlerts}>
You must be signed in to a Google account to transfer
between the homebrewery and Google Drive!
<a target='_blank' rel='noopener noreferrer'
href={`https://www.naturalcrit.com/login?redirect=${this.state.url}`}>
<div className='confirm'>
Sign In
</div>
</a>
<div className='deny'> Not Now </div>
<div className='deny'>
Not Now
</div>
</div>
)}
}
{alertTrashedGoogleBrew && (
<div className='errorContainer' onClick={closeAlerts}>
This brew is currently in your Trash folder on Google Drive!<br />
If you want to keep it, make sure to move it before it is deleted permanently!<br />
<div className='confirm'> OK </div>
{this.state.alertTrashedGoogleBrew &&
<div className='errorContainer' onClick={this.closeAlerts}>
This brew is currently in your Trash folder on Google Drive!<br />If you want to keep it, make sure to move it before it is deleted permanently!<br />
<div className='confirm'>
OK
</div>
</div>
)}
</Nav.item>
);
}
</Nav.item>;
},
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
if(unsavedChanges && warnUnsavedChanges) {
resetWarnUnsavedTimer();
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
const text = elapsedTime === 0
? 'Autosave is OFF.'
: `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
renderSaveButton : function(){
if(this.state.autoSaveWarning && this.hasChanges()){
this.setAutosaveWarning();
const elapsedTime = Math.round((new Date() - this.state.unsavedTime) / 1000 / 60);
const text = elapsedTime == 0 ? 'Autosave is OFF.' : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>{text}</div>
Reminder...
<div className='errorContainer'>
{text}
</div>
</Nav.item>;
}
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={()=>trySave(true)} color='blue' icon='fas fa-save'>save now</Nav.item>;
if(this.state.isSaving){
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
}
if(this.state.isPending && this.hasChanges()){
return <Nav.item className='save' onClick={this.save} color='blue' icon='fas fa-save'>Save Now</Nav.item>;
}
if(!this.state.isPending && !this.state.isSaving && this.state.autoSave){
return <Nav.item className='save saved'>auto-saved.</Nav.item>;
}
if(!this.state.isPending && !this.state.isSaving){
return <Nav.item className='save saved'>saved.</Nav.item>;
}
},
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
handleAutoSave : function(){
if(this.warningTimer) clearTimeout(this.warningTimer);
this.setState((prevState)=>({
autoSave : !prevState.autoSave,
autoSaveWarning : prevState.autoSave
}), ()=>{
localStorage.setItem('AUTOSAVE_ON', JSON.stringify(this.state.autoSave));
});
},
// #5 - No unsaved changes, and has never been saved, hide the button
if(neverSaved)
return <Nav.item className='save neverSaved'>save now</Nav.item>;
setAutosaveWarning : function(){
setTimeout(()=>this.setState({ autoSaveWarning: false }), 4000); // 4 seconds to display
this.warningTimer = setTimeout(()=>{this.setState({ autoSaveWarning: true });}, 900000); // 15 minutes between warnings
this.warningTimer;
},
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
errorReported : function(error) {
this.setState({
error
});
},
const toggleAutoSave = ()=>{
clearTimeout(warnUnsavedTimeout.current);
clearTimeout(saveTimeout.current);
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
setAutoSaveEnabled(!autoSaveEnabled);
setWarnUnsavedChanges(autoSaveEnabled);
};
renderAutoSaveButton : function(){
return <Nav.item onClick={this.handleAutoSave}>
Autosave <i className={this.state.autoSave ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
</Nav.item>;
},
const renderAutoSaveButton = ()=>(
<Nav.item onClick={toggleAutoSave}>
Autosave <i className={autoSaveEnabled ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
</Nav.item>
);
processShareId : function() {
return this.state.brew.googleId && !this.state.brew.stubbed ?
this.state.brew.googleId + this.state.brew.shareId :
this.state.brew.shareId;
},
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
getRedditLink : function(){
const shareLink = this.processShareId();
const systems = this.props.brew.systems.length > 0 ? ` [${this.props.brew.systems.join(' - ')}]` : '';
const title = `${this.props.brew.title} ${systems}`;
const text = `Hey guys! I've been working on this homebrew. I'd love your feedback. Check it out.
**[Homebrewery Link](${global.config.baseUrl}/share/${shareLink})**`;
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title.toWellFormed())}&text=${encodeURIComponent(text)}`;
},
renderNavbar : function(){
const shareLink = this.processShareId();
const renderNavbar = ()=>{
return <Navbar>
<Nav.section>
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
<Nav.item className='brewTitle'>{this.state.brew.title}</Nav.item>
</Nav.section>
<Nav.section>
{renderGoogleDriveIcon()}
{error
? <ErrorNavItem error={error} clearError={clearError} />
: <Nav.dropdown className='save-menu'>
{renderSaveButton()}
{renderAutoSaveButton()}
</Nav.dropdown>}
<NewBrewItem />
{this.renderGoogleDriveIcon()}
{this.state.error ?
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
<Nav.dropdown className='save-menu'>
{this.renderSaveButton()}
{this.renderAutoSaveButton()}
</Nav.dropdown>
}
<NewBrew />
<HelpNavItem/>
<Nav.dropdown>
<Nav.item color='teal' icon='fas fa-share-alt'>
share
</Nav.item>
<Nav.item color='blue' href={`/share/${shareLink}`}>
view
</Nav.item>
<Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${shareLink}`);}}>
copy url
</Nav.item>
<Nav.item color='blue' href={this.getRedditLink()} newTab={true} rel='noopener noreferrer'>
post to reddit
</Nav.item>
</Nav.dropdown>
<PrintNavItem />
<HelpNavItem />
<VaultNavItem />
<ShareNavItem brew={currentBrew} />
<RecentNavItem brew={currentBrew} storageKey='edit' />
<AccountNavItem/>
<RecentNavItem brew={this.state.brew} storageKey='edit' />
<Account />
</Nav.section>
</Navbar>;
};
},
return (
<div className='editPage sitePage'>
render : function(){
return <div className='editPage sitePage'>
<Meta name='robots' content='noindex, nofollow' />
{this.renderNavbar()}
{renderNavbar()}
{currentBrew.lock && <LockNotification shareId={currentBrew.shareId} message={currentBrew.lock.editMessage} reviewRequested={currentBrew.lock.reviewRequested}/>}
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
<div className='content'>
<SplitPane onDragFinish={handleSplitMove}>
<SplitPane onDragFinish={this.handleSplitMove}>
<Editor
ref={editorRef}
brew={currentBrew}
onBrewChange={handleBrewChange}
reportError={setError}
renderer={currentBrew.renderer}
userThemes={props.userThemes}
themeBundle={themeBundle}
updateBrew={updateBrew}
onCursorPageChange={setCurrentEditorCursorPageNum}
onViewPageChange={setCurrentEditorViewPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
ref={this.editor}
brew={this.state.brew}
onTextChange={this.handleTextChange}
onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange}
reportError={this.errorReported}
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
themeBundle={this.state.themeBundle}
snippetBundle={this.state.themeBundle.snippets}
updateBrew={this.updateBrew}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
<BrewRenderer
text={currentBrew.text}
style={currentBrew.style}
renderer={currentBrew.renderer}
theme={currentBrew.theme}
themeBundle={themeBundle}
errors={HTMLErrors}
lang={currentBrew.lang}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
allowPrint={true}
/>
</SplitPane>
</div>
</div>
);
};
</div>;
}
});
export default EditPage;
module.exports = EditPage;

View File

@@ -1,25 +1,29 @@
@keyframes glideDown {
0% {
opacity : 0;transform : translate(-50% + 3px, 0px);}
100% {
opacity : 1;transform : translate(-50% + 3px, 10px);}
0% {transform : translate(-50% + 3px, 0px);
opacity : 0;}
100% {transform : translate(-50% + 3px, 10px);
opacity : 1;}
}
.editPage {
.navItem.save {
position : relative;
.editPage{
.navItem.save{
width : 106px;
text-align : center;
&.saved {
color : #666666;
position : relative;
&.saved{
cursor : initial;
color : #666;
}
}
.googleDriveStorage { position : relative; }
.googleDriveStorage img {
height : 18px;
.googleDriveStorage {
position : relative;
}
.googleDriveStorage img{
height : 18px;
padding : 0px;
margin : -5px;
&.inactive { filter : grayscale(1); }
&.inactive {
filter: grayscale(1);
}
}
}

View File

@@ -1,30 +1,17 @@
import './lockNotification.less';
import * as React from 'react';
import request from '../../../utils/request-middleware.js';
require('./lockNotification.less');
const React = require('react');
import Dialog from '../../../../components/dialog.jsx';
function LockNotification(props) {
props = {
shareId : 0,
disableLock : ()=>{},
lock : {},
message : 'Unable to retrieve Lock Message',
reviewRequested : false,
shareId : 0,
disableLock : ()=>{},
message : '',
...props
};
const [reviewState, setReviewState] = React.useState(props.reviewRequested);
const removeLock = async ()=>{
await request.put(`/api/lock/review/request/${props.shareId}`)
.then(()=>{
setReviewState(true);
});
};
const renderReviewButton = function(){
if(reviewState){ return <button className='inactive'>REVIEW REQUESTED</button>; };
return <button onClick={removeLock}>REQUEST LOCK REMOVAL</button>;
const removeLock = ()=>{
alert(`Not yet implemented - ID ${props.shareId}`);
};
return <Dialog className='lockNotification' blocking closeText='CONTINUE TO EDITOR' >
@@ -32,12 +19,12 @@ function LockNotification(props) {
<p>This brew been locked by the Administrators. It will not be accessible by any method other than the Editor until the lock is removed.</p>
<hr />
<h3>LOCK REASON</h3>
<p>{props.message}</p>
<p>{props.message || 'Unable to retrieve Lock Message'}</p>
<hr />
<p>Once you have resolved this issue, click REQUEST LOCK REMOVAL to notify the Administrators for review.</p>
<p>Click CONTINUE TO EDITOR to temporarily hide this notification; it will reappear the next time the page is reloaded.</p>
{renderReviewButton()}
<button onClick={removeLock}>REQUEST LOCK REMOVAL</button>
</Dialog>;
};
export default LockNotification;
module.exports = LockNotification;

View File

@@ -11,12 +11,10 @@
&::backdrop { background-color : #000000AA; }
button {
padding : 2px 15px;
margin : 10px;
color : white;
background-color : #333333;
&.inactive,
&:hover { background-color : #777777; }
}

View File

@@ -1,8 +1,8 @@
import './errorPage.less';
import React from 'react';
import UIPage from '../basePages/uiPage/uiPage.jsx';
import Markdown from '../../../../shared/markdown.js';
import ErrorIndex from './errors/errorIndex.js';
require('./errorPage.less');
const React = require('react');
const UIPage = require('../basePages/uiPage/uiPage.jsx');
import Markdown from '../../../../shared/naturalcrit/markdown.js';
const ErrorIndex = require('./errors/errorIndex.js');
const ErrorPage = ({ brew })=>{
// Retrieving the error text based on the brew's error code from ErrorIndex
@@ -22,4 +22,4 @@ const ErrorPage = ({ brew })=>{
);
};
export default ErrorPage;
module.exports = ErrorPage;

View File

@@ -1,4 +1,4 @@
import dedent from 'dedent';
const dedent = require('dedent-tabs').default;
const loginUrl = 'https://www.naturalcrit.com/login';
@@ -96,7 +96,7 @@ const errorIndex = (props)=>{
**Brew Title:** ${escape(props.brew.brewTitle) || 'Unable to show title'}
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${encodeURIComponent(author)})`;}).join(', ') || 'Unable to list authors'}
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${author})`;}).join(', ') || 'Unable to list authors'}
[Click here to be redirected to the brew's share page.](/share/${props.brew.shareId})`,
@@ -111,7 +111,7 @@ const errorIndex = (props)=>{
**Brew Title:** ${escape(props.brew.brewTitle) || 'Unable to show title'}
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${encodeURIComponent(author)})`;}).join(', ') || 'Unable to list authors'}
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${author})`;}).join(', ') || 'Unable to list authors'}
[Click here to be redirected to the brew's share page.](/share/${props.brew.shareId})`,
@@ -167,7 +167,7 @@ const errorIndex = (props)=>{
**Requested access:** ${props.brew.accessType}
**Brew ID:** ${props.brew.brewId}`,
// Theme Not Valid
'10' : dedent`
## The selected theme is not tagged as a theme.
@@ -176,32 +176,6 @@ const errorIndex = (props)=>{
If the selected brew is your document, you may designate it as a theme by adding the \`theme:meta\` tag.`,
// ID validation error
'11' : dedent`
## No Homebrewery document could be found.
The server could not locate the Homebrewery document. The Brew ID failed the validation check.
:
**Brew ID:** ${props.brew.brewId}`,
// Google ID validation error
'12' : dedent`
## No Google document could be found.
The server could not locate the Google document. The Google ID failed the validation check.
:
**Brew ID:** ${props.brew.brewId}`,
// Database Connection Lost
'13' : dedent`
## Database connection has been lost.
The server could not communicate with the database.`,
//account page when account is not defined
'50' : dedent`
## You are not signed in
@@ -220,47 +194,13 @@ const errorIndex = (props)=>{
**Brew ID:** ${props.brew.brewId}
**Brew Title:** ${escape(props.brew.brewTitle)}
**Brew Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${encodeURIComponent(author)})`;}).join(', ') || 'Unable to list authors'}`,
**Brew Title:** ${escape(props.brew.brewTitle)}`,
// ####### Admin page error #######
'52' : dedent`
## Access Denied
You need to provide correct administrator credentials to access this page.`,
// ####### Lock Errors
'60' : dedent`Lock Error: General`,
'61' : dedent`Lock Get Error: Unable to get lock count`,
'62' : dedent`Lock Set Error: Cannot lock`,
'63' : dedent`Lock Set Error: Brew not found`,
'64' : dedent`Lock Set Error: Already locked`,
'65' : dedent`Lock Remove Error: Cannot unlock`,
'66' : dedent`Lock Remove Error: Brew not found`,
'67' : dedent`Lock Remove Error: Not locked`,
'68' : dedent`Lock Get Review Error: Cannot get review requests`,
'69' : dedent`Lock Set Review Error: Cannot set review request`,
'70' : dedent`Lock Set Review Error: Brew not found`,
'71' : dedent`Lock Set Review Error: Review already requested`,
'72' : dedent`Lock Remove Review Error: Cannot clear review request`,
'73' : dedent`Lock Remove Review Error: Brew not found`,
// ####### Other Errors
'90' : dedent` An unexpected error occurred while looking for these brews.
Try again in a few minutes.`,
@@ -268,4 +208,4 @@ const errorIndex = (props)=>{
};
};
export default errorIndex;
module.exports = errorIndex;

View File

@@ -1,235 +1,141 @@
/* eslint-disable max-lines */
import './homePage.less';
require('./homePage.less');
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
import request from '../../utils/request-middleware.js';
const { Meta } = require('vitreum/headtags');
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from 'markdown.js';
import _ from 'lodash';
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const NewBrewItem = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx');
const VaultNavItem = require('../../navbar/vault.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const AccountNavItem = require('../../navbar/account.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const { fetchThemeBundle } = require('../../../../shared/helpers.js');
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
import SplitPane from 'client/components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
import Nav from 'client/homebrew/navbar/nav.jsx';
import Navbar from 'client/homebrew/navbar/navbar.jsx';
import NewBrewItem from 'client/homebrew/navbar/newbrew.navitem.jsx';
import AccountNavItem from 'client/homebrew/navbar/account.navitem.jsx';
import ErrorNavItem from 'client/homebrew/navbar/error-navitem.jsx';
import HelpNavItem from 'client/homebrew/navbar/help.navitem.jsx';
import VaultNavItem from 'client/homebrew/navbar/vault.navitem.jsx';
import PrintNavItem from 'client/homebrew/navbar/print.navitem.jsx';
import RecentNavItems from 'client/homebrew/navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
// Page specific imports
import { Meta } from 'vitreum/headtags';
const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style';
const SNIPKEY = 'homebrewery-new-snippets';
const METAKEY = 'homebrewery-new-meta';
const useLocalStorage = false;
const neverSaved = true;
const HomePage =(props)=>{
props = {
brew : DEFAULT_BREW,
ver : '0.0.0',
...props
};
const [currentBrew , setCurrentBrew] = useState(props.brew);
const [error , setError] = useState(undefined);
const [HTMLErrors , setHTMLErrors] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle] = useState({});
const [unsavedChanges , setUnsavedChanges] = useState(false);
const [isSaving , setIsSaving] = useState(false);
const [autoSaveEnabled , setAutoSaveEnable] = useState(false);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
const unsavedChangesRef = useRef(unsavedChanges);
useEffect(()=>{
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
if(e.keyCode === 83) trySaveRef.current(true);
if(e.keyCode === 80) printCurrentBrew();
if([83, 80].includes(e.keyCode)) {
e.stopPropagation();
e.preventDefault();
}
const HomePage = createClass({
displayName : 'HomePage',
getDefaultProps : function() {
return {
brew : DEFAULT_BREW,
ver : '0.0.0'
};
document.addEventListener('keydown', handleControlKeys);
window.onbeforeunload = ()=>{
if(unsavedChangesRef.current)
return 'You have unsaved changes!';
},
getInitialState : function() {
return {
brew : this.props.brew,
welcomeText : this.props.brew.text,
error : undefined,
currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
themeBundle : {}
};
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
window.onbeforeunload = null;
};
}, []);
},
useEffect(()=>{
unsavedChangesRef.current = unsavedChanges;
}, [unsavedChanges]);
editor : React.createRef(null),
const save = ()=>{
componentDidMount : function() {
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
},
handleSave : function(){
request.post('/api')
.send(currentBrew)
.send(this.state.brew)
.end((err, res)=>{
if(err) {
setError(err);
this.setState({ error: err });
return;
}
const saved = res.body;
window.location = `/edit/${saved.editId}`;
const brew = res.body;
window.location = `/edit/${brew.editId}`;
});
};
},
handleSplitMove : function(){
this.editor.current.update();
},
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange);
handleEditorViewPageChange : function(pageNumber){
this.setState({ currentEditorViewPageNum: pageNumber });
},
if(autoSaveEnabled) trySave(false, hasChange);
}, [currentBrew]);
handleEditorCursorPageChange : function(pageNumber){
this.setState({ currentEditorCursorPageNum: pageNumber });
},
const handleSplitMove = ()=>{
editorRef.current.update();
};
handleBrewRendererPageChange : function(pageNumber){
this.setState({ currentBrewRendererPageNum: pageNumber });
},
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
if(subfield == 'renderer' || subfield == 'theme')
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
//If there are HTML errors, run the validator on every change to give quick feedback
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
setHTMLErrors(Markdown.validate(value));
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
if(useLocalStorage) {
if(field == 'text') localStorage.setItem(BREWKEY, value);
if(field == 'style') localStorage.setItem(STYLEKEY, value);
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
renderer : value.renderer,
theme : value.theme,
lang : value.lang
}));
}
};
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
// if(unsavedChanges && warnUnsavedChanges) {
// resetWarnUnsavedTimer();
// const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
// const text = elapsedTime === 0
// ? 'Autosave is OFF.'
// : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
// return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
// Reminder...
// <div className='errorContainer'>{text}</div>
// </Nav.item>;
// }
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={save} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - No unsaved changes, and has never been saved, hide the button
if(neverSaved)
return <Nav.item className='save neverSaved'>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const renderNavbar = ()=>{
return <Navbar ver={props.ver}>
handleTextChange : function(text){
this.setState((prevState)=>({
brew : { ...prevState.brew, text: text },
}));
},
renderNavbar : function(){
return <Navbar ver={this.props.ver}>
<Nav.section>
{error
? <ErrorNavItem error={error} clearError={clearError} />
: renderSaveButton()}
{this.state.error ?
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
null
}
<NewBrewItem />
<PrintNavItem />
<HelpNavItem />
<VaultNavItem />
<RecentNavItem />
<AccountNavItem />
</Nav.section>
</Navbar>;
};
},
return (
<div className='homePage sitePage'>
render : function(){
return <div className='homePage sitePage'>
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
{renderNavbar()}
<div className='content'>
<SplitPane onDragFinish={handleSplitMove}>
<Editor
ref={editorRef}
brew={currentBrew}
onBrewChange={handleBrewChange}
renderer={currentBrew.renderer}
showEditButtons={false}
themeBundle={themeBundle}
onCursorPageChange={setCurrentEditorCursorPageNum}
onViewPageChange={setCurrentEditorViewPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
/>
<BrewRenderer
text={currentBrew.text}
style={currentBrew.style}
renderer={currentBrew.renderer}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
themeBundle={themeBundle}
/>
</SplitPane>
{this.renderNavbar()}
<div className="content">
<SplitPane onDragFinish={this.handleSplitMove}>
<Editor
ref={this.editor}
brew={this.state.brew}
onTextChange={this.handleTextChange}
renderer={this.state.brew.renderer}
showEditButtons={false}
snippetBundle={this.state.themeBundle.snippets}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
themeBundle={this.state.themeBundle}
/>
</SplitPane>
</div>
<div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={save}>
<div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}>
Save current <i className='fas fa-save' />
</div>
<a href='/new' className='floatingNewButton'>
Create your own <i className='fas fa-magic' />
</a>
</div>
);
};
</div>;
}
});
export default HomePage;
module.exports = HomePage;

View File

@@ -1,48 +1,50 @@
.homePage {
.homePage{
position : relative;
a.floatingNewButton {
a.floatingNewButton{
.animate(background-color);
position : absolute;
display : block;
right : 70px;
bottom : 50px;
z-index : 100;
z-index : 5001;
display : block;
padding : 1em;
background-color : @orange;
font-size : 1.5em;
color : white;
text-decoration : none;
background-color : @orange;
box-shadow : 3px 3px 15px black;
&:hover { background-color : darken(@orange, 20%); }
&:hover{
background-color : darken(@orange, 20%);
}
}
.floatingSaveButton {
.floatingSaveButton{
.animateAll();
position : absolute;
display : block;
right : 200px;
bottom : 70px;
z-index : 100;
z-index : 5000;
display : block;
padding : 0.8em;
cursor : pointer;
background-color : @blue;
font-size : 0.8em;
color : white;
text-decoration : none;
cursor : pointer;
background-color : @blue;
box-shadow : 3px 3px 15px black;
&:hover { background-color : darken(@blue, 20%); }
&.show { right : 350px; }
&:hover{
background-color : darken(@blue, 20%);
}
&.show{
right : 350px;
}
}
.navItem.save {
background-color : @orange;
transition:all 0.2s;
&:hover { background-color : @green; }
&.neverSaved {
translate:-100%;
opacity: 0;
background-color :#333;
cursor:auto;
.navItem.save{
background-color: @orange;
&:hover{
background-color: @green;
}
}
}

View File

@@ -1,280 +1,264 @@
/* eslint-disable max-lines */
import './newPage.less';
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./newPage.less');
const React = require('react');
const createClass = require('create-react-class');
import request from '../../utils/request-middleware.js';
// Common imports
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from 'markdown.js';
import _ from 'lodash';
import Markdown from 'naturalcrit/markdown.js';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
const Nav = require('naturalcrit/nav/nav.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const AccountNavItem = require('../../navbar/account.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const HelpNavItem = require('../../navbar/help.navitem.jsx');
import SplitPane from 'client/components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
import Nav from 'client/homebrew/navbar/nav.jsx';
import Navbar from 'client/homebrew/navbar/navbar.jsx';
import NewBrewItem from 'client/homebrew/navbar/newbrew.navitem.jsx';
import AccountNavItem from 'client/homebrew/navbar/account.navitem.jsx';
import ErrorNavItem from 'client/homebrew/navbar/error-navitem.jsx';
import HelpNavItem from 'client/homebrew/navbar/help.navitem.jsx';
import VaultNavItem from 'client/homebrew/navbar/vault.navitem.jsx';
import PrintNavItem from 'client/homebrew/navbar/print.navitem.jsx';
import RecentNavItems from 'client/homebrew/navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
// Page specific imports
import { Meta } from 'vitreum/headtags';
const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style';
const METAKEY = 'homebrewery-new-meta';
let SAVEKEY;
const BREWKEY = 'HB_newPage_content';
const STYLEKEY = 'HB_newPage_style';
const METAKEY = 'HB_newPage_metadata';
const SNIPKEY = 'HB_newPage_snippets';
const SAVEKEYPREFIX = 'HB_editor_defaultSave_';
const useLocalStorage = true;
const neverSaved = true;
const NewPage = (props)=>{
props = {
brew : DEFAULT_BREW,
...props
};
const [currentBrew , setCurrentBrew ] = useState(props.brew);
const [isSaving , setIsSaving ] = useState(false);
const [saveGoogle , setSaveGoogle ] = useState(global.account?.googleId ? true : false);
const [error , setError ] = useState(null);
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1);
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [themeBundle , setThemeBundle ] = useState({});
const [unsavedChanges , setUnsavedChanges ] = useState(false);
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(false);
const editorRef = useRef(null);
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
// const saveTimeout = useRef(null);
// const warnUnsavedTimeout = useRef(null);
const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
useEffect(()=>{
loadBrew();
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
if(e.keyCode === 83) trySaveRef.current(true);
if(e.keyCode === 80) printCurrentBrew();
if([83, 80].includes(e.keyCode)) {
e.stopPropagation();
e.preventDefault();
}
const NewPage = createClass({
displayName : 'NewPage',
getDefaultProps : function() {
return {
brew : DEFAULT_BREW
};
},
document.addEventListener('keydown', handleControlKeys);
getInitialState : function() {
const brew = this.props.brew;
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
return {
brew : brew,
isSaving : false,
saveGoogle : (global.account && global.account.googleId ? true : false),
error : null,
htmlErrors : Markdown.validate(brew.text),
currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
themeBundle : {}
};
}, []);
},
const loadBrew = ()=>{
const brew = { ...currentBrew };
if(!brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser
editor : React.createRef(null),
componentDidMount : function() {
document.addEventListener('keydown', this.handleControlKeys);
const brew = this.state.brew;
if(!this.props.brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser
const brewStorage = localStorage.getItem(BREWKEY);
const styleStorage = localStorage.getItem(STYLEKEY);
const metaStorage = JSON.parse(localStorage.getItem(METAKEY));
const metaStorage = JSON.parse(localStorage.getItem(METAKEY));
brew.text = brewStorage ?? brew.text;
brew.style = styleStorage ?? brew.style;
brew.text = brewStorage ?? brew.text;
brew.style = styleStorage ?? brew.style;
// brew.title = metaStorage?.title || this.state.brew.title;
// brew.description = metaStorage?.description || this.state.brew.description;
brew.renderer = metaStorage?.renderer ?? brew.renderer;
brew.theme = metaStorage?.theme ?? brew.theme;
brew.lang = metaStorage?.lang ?? brew.lang;
}
const SAVEKEY = `${SAVEKEYPREFIX}${global.account?.username}`;
SAVEKEY = `HOMEBREWERY-DEFAULT-SAVE-LOCATION-${global.account?.username || ''}`;
const saveStorage = localStorage.getItem(SAVEKEY) || 'HOMEBREWERY';
setCurrentBrew(brew);
lastSavedBrew.current = brew;
setSaveGoogle(saveStorage == 'GOOGLE-DRIVE' && saveGoogle);
this.setState({
brew : brew,
saveGoogle : (saveStorage == 'GOOGLE-DRIVE' && this.state.saveGoogle)
});
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
localStorage.setItem(BREWKEY, brew.text);
if(brew.style)
localStorage.setItem(STYLEKEY, brew.style);
localStorage.setItem(METAKEY, JSON.stringify({ renderer: brew.renderer, theme: brew.theme, lang: brew.lang }));
if(window.location.pathname !== '/new')
localStorage.setItem(METAKEY, JSON.stringify({ 'renderer': brew.renderer, 'theme': brew.theme, 'lang': brew.lang }));
if(window.location.pathname != '/new') {
window.history.replaceState({}, window.location.title, '/new/');
};
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange);
if(autoSaveEnabled) trySave(false, hasChange);
}, [currentBrew]);
useEffect(()=>{
trySaveRef.current = trySave;
unsavedChangesRef.current = unsavedChanges;
});
const handleSplitMove = ()=>{
editorRef.current.update();
};
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
if(subfield == 'renderer' || subfield == 'theme')
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
//If there are HTML errors, run the validator on every change to give quick feedback
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
setHTMLErrors(Markdown.validate(value));
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
if(useLocalStorage) {
if(field == 'text') localStorage.setItem(BREWKEY, value);
if(field == 'style') localStorage.setItem(STYLEKEY, value);
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
renderer : value.renderer,
theme : value.theme,
lang : value.lang
}));
}
};
},
componentWillUnmount : function() {
document.removeEventListener('keydown', this.handleControlKeys);
},
const trySave = async ()=>{
setIsSaving(true);
handleControlKeys : function(e){
if(!(e.ctrlKey || e.metaKey)) return;
const S_KEY = 83;
const P_KEY = 80;
if(e.keyCode == S_KEY) this.save();
if(e.keyCode == P_KEY) printCurrentBrew();
if(e.keyCode == P_KEY || e.keyCode == S_KEY){
e.stopPropagation();
e.preventDefault();
}
},
const updatedBrew = { ...currentBrew };
splitTextStyleAndMetadata(updatedBrew);
handleSplitMove : function(){
this.editor.current.update();
},
const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^\\page$/gm;
updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1;
handleEditorViewPageChange : function(pageNumber){
this.setState({ currentEditorViewPageNum: pageNumber });
},
handleEditorCursorPageChange : function(pageNumber){
this.setState({ currentEditorCursorPageNum: pageNumber });
},
handleBrewRendererPageChange : function(pageNumber){
this.setState({ currentBrewRendererPageNum: pageNumber });
},
handleTextChange : function(text){
//If there are errors, run the validator on every change to give quick feedback
let htmlErrors = this.state.htmlErrors;
if(htmlErrors.length) htmlErrors = Markdown.validate(text);
this.setState((prevState)=>({
brew : { ...prevState.brew, text: text },
htmlErrors : htmlErrors,
}));
localStorage.setItem(BREWKEY, text);
},
handleStyleChange : function(style){
this.setState((prevState)=>({
brew : { ...prevState.brew, style: style },
}));
localStorage.setItem(STYLEKEY, style);
},
handleMetaChange : function(metadata, field=undefined){
if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed
fetchThemeBundle(this, metadata.renderer, metadata.theme);
this.setState((prevState)=>({
brew : { ...prevState.brew, ...metadata },
}), ()=>{
localStorage.setItem(METAKEY, JSON.stringify({
// 'title' : this.state.brew.title,
// 'description' : this.state.brew.description,
'renderer' : this.state.brew.renderer,
'theme' : this.state.brew.theme,
'lang' : this.state.brew.lang
}));
});
;
},
save : async function(){
this.setState({
isSaving : true
});
let brew = this.state.brew;
// Split out CSS to Style if CSS codefence exists
if(brew.text.startsWith('```css') && brew.text.indexOf('```\n\n') > 0) {
const index = brew.text.indexOf('```\n\n');
brew.style = `${brew.style ? `${brew.style}\n` : ''}${brew.text.slice(7, index - 1)}`;
brew.text = brew.text.slice(index + 5);
}
brew.pageCount=((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
const res = await request
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(updatedBrew)
.post(`/api${this.state.saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(brew)
.catch((err)=>{
setIsSaving(false);
setError(err);
this.setState({ isSaving: false, error: err });
});
setIsSaving(false);
if(!res) return;
const savedBrew = res.body;
brew = res.body;
localStorage.removeItem(BREWKEY);
localStorage.removeItem(STYLEKEY);
localStorage.removeItem(METAKEY);
window.location = `/edit/${savedBrew.editId}`;
};
window.location = `/edit/${brew.editId}`;
},
const renderSaveButton = ()=>{
// #1 - Currently saving, show SAVING
if(isSaving)
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
renderSaveButton : function(){
if(this.state.isSaving){
return <Nav.item icon='fas fa-spinner fa-spin' className='save'>
save...
</Nav.item>;
} else {
return <Nav.item icon='fas fa-save' className='save' onClick={this.save}>
save
</Nav.item>;
}
},
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
// if(unsavedChanges && warnUnsavedChanges) {
// resetWarnUnsavedTimer();
// const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
// const text = elapsedTime === 0
// ? 'Autosave is OFF.'
// : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
renderNavbar : function(){
return <Navbar>
// return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
// Reminder...
// <div className='errorContainer'>{text}</div>
// </Nav.item>;
// }
// #3 - Unsaved changes exist, click to save, show SAVE NOW
if(unsavedChanges)
return <Nav.item className='save' onClick={trySave} color='blue' icon='fas fa-save'>save now</Nav.item>;
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
if(autoSaveEnabled)
return <Nav.item className='save saved'>auto-saved</Nav.item>;
// #5 - No unsaved changes, and has never been saved, hide the button
if(neverSaved)
return <Nav.item className='save neverSaved'>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>;
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const renderNavbar = ()=>(
<Navbar>
<Nav.section>
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
<Nav.item className='brewTitle'>{this.state.brew.title}</Nav.item>
</Nav.section>
<Nav.section>
{error
? <ErrorNavItem error={error} clearError={clearError} />
: renderSaveButton()}
<NewBrewItem />
{this.state.error ?
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
this.renderSaveButton()
}
<PrintNavItem />
<HelpNavItem />
<VaultNavItem />
<RecentNavItem />
<AccountNavItem />
</Nav.section>
</Navbar>
);
</Navbar>;
},
return (
<div className='newPage sitePage'>
{renderNavbar()}
<div className='content'>
<SplitPane onDragFinish={handleSplitMove}>
<Editor
ref={editorRef}
brew={currentBrew}
onBrewChange={handleBrewChange}
renderer={currentBrew.renderer}
userThemes={props.userThemes}
themeBundle={themeBundle}
onCursorPageChange={setCurrentEditorCursorPageNum}
onViewPageChange={setCurrentEditorViewPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
/>
<BrewRenderer
text={currentBrew.text}
style={currentBrew.style}
renderer={currentBrew.renderer}
theme={currentBrew.theme}
themeBundle={themeBundle}
errors={HTMLErrors}
lang={currentBrew.lang}
onPageChange={setCurrentBrewRendererPageNum}
currentEditorViewPageNum={currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum}
allowPrint={true}
/>
</SplitPane>
render : function(){
return <div className='newPage sitePage'>
{this.renderNavbar()}
<div className="content">
<SplitPane onDragFinish={this.handleSplitMove}>
<Editor
ref={this.editor}
brew={this.state.brew}
onTextChange={this.handleTextChange}
onStyleChange={this.handleStyleChange}
onMetaChange={this.handleMetaChange}
renderer={this.state.brew.renderer}
userThemes={this.props.userThemes}
themeBundle={this.state.themeBundle}
snippetBundle={this.state.themeBundle.snippets}
onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/>
<BrewRenderer
text={this.state.brew.text}
style={this.state.brew.style}
renderer={this.state.brew.renderer}
theme={this.state.brew.theme}
themeBundle={this.state.themeBundle}
errors={this.state.htmlErrors}
lang={this.state.brew.lang}
onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
allowPrint={true}
/>
</SplitPane>
</div>
</div>
);
};
</div>;
}
});
export default NewPage;
module.exports = NewPage;

View File

@@ -1,15 +1,8 @@
.newPage {
.navItem.save {
background-color : @orange;
transition:all 0.2s;
&:hover { background-color : @green; }
&.neverSaved {
translate:-100%;
opacity: 0;
background-color :#333;
cursor:auto;
.newPage{
.navItem.save{
background-color: @orange;
&:hover{
background-color: @green;
}
}
}

View File

@@ -1,27 +1,31 @@
import './sharePage.less';
import React, { useState, useEffect, useCallback } from 'react';
import { Meta } from 'vitreum/headtags';
require('./sharePage.less');
const React = require('react');
const { useState, useEffect, useCallback } = React;
const { Meta } = require('vitreum/headtags');
import Nav from 'client/homebrew/navbar/nav.jsx';
import Navbar from 'client/homebrew/navbar/navbar.jsx';
import MetadataNav from 'client/homebrew/navbar/metadata.navitem.jsx';
import PrintNavItem from 'client/homebrew/navbar/print.navitem.jsx';
import RecentNavItems from 'client/homebrew/navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from 'client/homebrew/navbar/account.navitem.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const MetadataNav = require('../../navbar/metadata.navitem.jsx');
const PrintNavItem = require('../../navbar/print.navitem.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.navitem.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '../../../../shared/helpers.js';
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
const SharePage = (props)=>{
const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props;
const [themeBundle, setThemeBundle] = useState({});
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
const [state, setState] = useState({
themeBundle : {},
currentBrewRendererPageNum : 1,
});
const handleBrewRendererPageChange = useCallback((pageNumber)=>{
setCurrentBrewRendererPageNum(pageNumber);
setState((prevState)=>({
currentBrewRendererPageNum : pageNumber,
...prevState }));
}, []);
const handleControlKeys = (e)=>{
@@ -36,7 +40,11 @@ const SharePage = (props)=>{
useEffect(()=>{
document.addEventListener('keydown', handleControlKeys);
fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme);
fetchThemeBundle(
{ setState },
brew.renderer,
brew.theme
);
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
@@ -106,9 +114,9 @@ const SharePage = (props)=>{
lang={brew.lang}
renderer={brew.renderer}
theme={brew.theme}
themeBundle={themeBundle}
themeBundle={state.themeBundle}
onPageChange={handleBrewRendererPageChange}
currentBrewRendererPageNum={currentBrewRendererPageNum}
currentBrewRendererPageNum={state.currentBrewRendererPageNum}
allowPrint={true}
/>
</div>
@@ -116,4 +124,4 @@ const SharePage = (props)=>{
);
};
export default SharePage;
module.exports = SharePage;

View File

@@ -1,7 +1,9 @@
.sharePage {
.sharePage{
nav .navSection.titleSection {
flex-grow : 1;
justify-content : center;
flex-grow: 1;
justify-content: center;
}
.content{
overflow-y : hidden;
}
.content { overflow-y : hidden; }
}

View File

@@ -1,17 +1,17 @@
import React, { useState } from 'react';
import _ from 'lodash';
const React = require('react');
const { useState } = React;
const _ = require('lodash');
import ListPage from '../basePages/listPage/listPage.jsx';
const ListPage = require('../basePages/listPage/listPage.jsx');
import Nav from 'client/homebrew/navbar/nav.jsx';
import Navbar from 'client/homebrew/navbar/navbar.jsx';
import RecentNavItems from 'client/homebrew/navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from 'client/homebrew/navbar/account.navitem.jsx';
import NewBrew from 'client/homebrew/navbar/newbrew.navitem.jsx';
import HelpNavItem from 'client/homebrew/navbar/help.navitem.jsx';
import ErrorNavItem from 'client/homebrew/navbar/error-navitem.jsx';
import VaultNavitem from 'client/homebrew/navbar/vault.navitem.jsx';
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.navitem.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx');
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
const VaultNavitem = require('../../navbar/vault.navitem.jsx');
const UserPage = (props)=>{
props = {
@@ -39,14 +39,10 @@ const UserPage = (props)=>{
}] : [])
];
const clearError = ()=>{
setError(null);
};
const navItems = (
<Navbar>
<Nav.section>
{error && (<ErrorNavItem error={error} clearError={clearError}></ErrorNavItem>)}
{error && (<ErrorNavItem error={error} parent={null}></ErrorNavItem>)}
<NewBrew />
<HelpNavItem />
<VaultNavitem />
@@ -61,4 +57,4 @@ const UserPage = (props)=>{
);
};
export default UserPage;
module.exports = UserPage;

View File

@@ -1,18 +1,19 @@
/*eslint max-lines: ["warn", {"max": 400, "skipBlankLines": true, "skipComments": true}]*/
/*eslint max-params:["warn", { max: 10 }], */
import './vaultPage.less';
import React, { useState, useEffect, useRef } from 'react';
require('./vaultPage.less');
import Nav from 'client/homebrew/navbar/nav.jsx';
import Navbar from 'client/homebrew/navbar/navbar.jsx';
import RecentNavItems from 'client/homebrew/navbar/recent.navitem.jsx';
const { both: RecentNavItem } = RecentNavItems;
import Account from 'client/homebrew/navbar/account.navitem.jsx';
import NewBrew from 'client/homebrew/navbar/newbrew.navitem.jsx';
import HelpNavItem from 'client/homebrew/navbar/help.navitem.jsx';
import BrewItem from '../basePages/listPage/brewItem/brewItem.jsx';
import SplitPane from 'client/components/splitPane/splitPane.jsx';
import ErrorIndex from '../errorPage/errors/errorIndex.js';
const React = require('react');
const { useState, useEffect, useRef } = React;
const Nav = require('naturalcrit/nav/nav.jsx');
const Navbar = require('../../navbar/navbar.jsx');
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
const Account = require('../../navbar/account.navitem.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx');
const BrewItem = require('../basePages/listPage/brewItem/brewItem.jsx');
const SplitPane = require('../../../../shared/naturalcrit/splitPane/splitPane.jsx');
const ErrorIndex = require('../errorPage/errors/errorIndex.js');
import request from '../../utils/request-middleware.js';
@@ -98,14 +99,14 @@ const VaultPage = (props)=>{
setSearching(true);
setError(null);
const title = titleRef.current.value || '';
const author = authorRef.current.value || '';
const count = countRef.current.value || 20;
const v3 = v3Ref.current.checked != false;
const legacy = legacyRef.current.checked != false;
const title = titleRef.current.value || '';
const author = authorRef.current.value || '';
const count = countRef.current.value || 10;
const v3 = v3Ref.current.checked != false;
const legacy = legacyRef.current.checked != false;
const sortOption = sort || 'title';
const dirOption = dir || 'asc';
const pageProp = page || 1;
const dirOption = dir || 'asc';
const pageProp = page || 1;
setSort(sortOption);
setdir(dirOption);
@@ -246,7 +247,7 @@ const VaultPage = (props)=>{
</li>
<li>
Some common words like "a", "after", "through", "itself", "here", etc.,
are ignored in searches. The full list can be found&nbsp;
are ignored in searches. The full list can be found &nbsp;
<a href='https://github.com/mongodb/mongo/blob/0e3b3ca8480ddddf5d0105d11a94bd4698335312/src/mongo/db/fts/stop_words_english.txt'>
here
</a>
@@ -285,10 +286,9 @@ const VaultPage = (props)=>{
};
const renderPaginationControls = ()=>{
if(!totalBrews || totalBrews < 10) return null;
if(!totalBrews) return null;
const countInt = parseInt(countRef.current.value || 20);
const countInt = parseInt(props.query.count || 20);
const totalPages = Math.ceil(totalBrews / countInt);
let startPage, endPage;
@@ -355,7 +355,7 @@ const VaultPage = (props)=>{
};
const renderFoundBrews = ()=>{
if(searching && !brewCollection) {
if(searching) {
return (
<div className='foundBrews searching'>
<h3 className='searchAnim'>Searching</h3>
@@ -395,7 +395,6 @@ const VaultPage = (props)=>{
{`Brews found: `}
<span>{totalBrews}</span>
</span>
{brewCollection.length > 10 && renderPaginationControls()}
{brewCollection.map((brew, index)=>{
return (
<BrewItem
@@ -416,17 +415,17 @@ const VaultPage = (props)=>{
<link href='/themes/V3/Blank/style.css' rel='stylesheet' />
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet' />
{renderNavItems()}
<div className='content'>
<SplitPane showDividerButtons={false}>
<div className='form dataGroup'>{renderForm()}</div>
<div className='resultsContainer dataGroup'>
{renderSortBar()}
{renderFoundBrews()}
</div>
</SplitPane>
<div className="content">
<SplitPane showDividerButtons={false}>
<div className='form dataGroup'>{renderForm()}</div>
<div className='resultsContainer dataGroup'>
{renderSortBar()}
{renderFoundBrews()}
</div>
</SplitPane>
</div>
</div>
);
};
export default VaultPage;
module.exports = VaultPage;

View File

@@ -1,16 +1,14 @@
.vaultPage {
height : 100%;
overflow-y : hidden;
background-color : #2C3E50;
*:not(input) { user-select : none; }
.form {
background:white;
}
:where(.content .dataGroup) {
.content .dataGroup {
width : 100%;
height : 100%;
background : white;
&.form .brewLookup {
position : relative;
@@ -171,9 +169,9 @@
width : 100%;
height : 100%;
max-height : 100%;
padding : 70px 50px;
padding : 50px 50px 70px 50px;
overflow-y : scroll;
container-type : inline-size;
background-color : #2C3E50;
h3 { font-size : 25px; }
@@ -238,7 +236,6 @@
margin-right : 40px;
color : black;
isolation : isolate;
transition : width 0.5s;
&::after {
position : absolute;
@@ -272,8 +269,8 @@
.links { z-index : 2; }
hr {
visibility : hidden;
margin : 0px;
visibility : hidden;
}
.thumbnail { z-index : -1; }
@@ -281,37 +278,30 @@
.paginationControls {
position : absolute;
top : 35px;
left : 50%;
display : grid;
grid-template-areas : 'previousPage currentPage nextPage';
grid-template-columns : 50px 1fr 50px;
gap : 20px;
place-items : center;
width : auto;
font-size : 15px;
translate : -50%;
&:last-child { top : unset; }
.pages {
display : flex;
grid-area : currentPage;
gap : 1em;
justify-content : space-evenly;
width : 100%;
height : 100%;
padding : 5px 8px;
text-align : center;
.pageNumber {
place-content : center;
width : fit-content;
min-width : 2em;
margin-inline : 1vw;
font-family : 'Open Sans';
font-weight : 900;
color : white;
text-wrap : nowrap;
text-underline-position : under;
text-wrap : nowrap;
cursor : pointer;
&.currentPage {
@@ -339,6 +329,7 @@
}
}
}
}
@keyframes trailingDots {
@@ -353,7 +344,8 @@
100% { content : ' ...'; }
}
@container (width < 670px) {
// media query for when the page is smaller than 1079 px in width
@media screen and (max-width : 1079px) {
.vaultPage {
.dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; }

View File

@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 94.65 94.6"
version="1.1"
id="svg11"
sodipodi:docname="thumbnail.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
id="namedview13"
pagecolor="#ffffff"
bordercolor="#111111"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="8.4989431"
inkscape:cx="38.887188"
inkscape:cy="47.417661"
inkscape:window-width="1920"
inkscape:window-height="1043"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg11" />
<defs
id="defs4">
<style
id="style2">.cls-1{fill:#ed1f24;}</style>
</defs>
<title
id="title6">NaturalCritLogo</title>
<g
id="Layer_2"
data-name="Layer 2"
style="fill:#000000;stroke:#000000">
<g
id="base"
style="fill:#000000;stroke:#000000">
<path
id="D20"
class="cls-1"
d="M63.45.09s-45.91,12.4-46,12.45a.71.71,0,0,0-.15.08l-.15.1-.12.11a1.07,1.07,0,0,0-.14.16l-.09.11-.12.23,0,.06L.2,54.9a1.59,1.59,0,0,0,.11,1.69L29.36,94h0l0,0,.08.08.08.08.09.09.08.06.13.07a0,0,0,0,0,0,0,1.59,1.59,0,0,0,.27.12l.13.05.06,0a1.55,1.55,0,0,0,.37,0,1.63,1.63,0,0,0,.31,0l45.67-8.3.16,0,.11,0,.12,0,.06,0s0,0,0,0l.06,0a1.65,1.65,0,0,0,.36-.28l0-.06a1.6,1.6,0,0,0,.26-.38s0,0,0,0v0h0a.14.14,0,0,1,0-.06L94.52,43.74a1.4,1.4,0,0,0,.11-.4.41.41,0,0,0,0-.11,1.13,1.13,0,0,0,0-.26.66.66,0,0,0,0-.14,2,2,0,0,0-.06-.26l0-.11a2.68,2.68,0,0,0-.18-.33v0L65.29.6C64.77-.31,63.45.09,63.45.09ZM74.9,81.7l-28.81-18L78.5,38.49ZM44.1,61l-11-40.17L77,35.39ZM82,37.78l8.92,5.95L79,73.48Zm4.46-1.1-4.6-3.06L75.69,21.36Zm-9.26-4.8-42.07-14,28.05-14ZM30.56,16.34l-6.49-2.16L47.85,7.7Zm-11.35-.21L27.88,19,7.64,45Zm10.73,5.76L40.78,61.64,4.64,54.42Zm10.82,43.2L30.26,89.6,5.75,58.09Zm3.16,1.24L71.74,83.72l-38.26,7Z"
style="fill:#000000;fill-opacity:1;stroke:#000000" />
</g>
</g>
<metadata
id="metadata1">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>NaturalCritLogo</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,74 +0,0 @@
import requestMiddleware from './request-middleware';
jest.mock('superagent');
import request from 'superagent';
describe('request-middleware', ()=>{
let version;
let setFn;
let testFn;
beforeEach(()=>{
jest.resetAllMocks();
version = global.version;
global.version = '999';
setFn = jest.fn();
testFn = jest.fn(()=>{ return { set: setFn }; });
});
afterEach(()=>{
global.version = version;
});
it('should add header to get', ()=>{
// Ensure tests functions have been reset
expect(testFn).not.toHaveBeenCalled();
expect(setFn).not.toHaveBeenCalled();
request.get = testFn;
requestMiddleware.get('path');
expect(testFn).toHaveBeenCalledWith('path');
expect(setFn).toHaveBeenCalledWith('Homebrewery-Version', '999');
});
it('should add header to put', ()=>{
expect(testFn).not.toHaveBeenCalled();
expect(setFn).not.toHaveBeenCalled();
request.put = testFn;
requestMiddleware.put('path');
expect(testFn).toHaveBeenCalledWith('path');
expect(setFn).toHaveBeenCalledWith('Homebrewery-Version', '999');
});
it('should add header to post', ()=>{
expect(testFn).not.toHaveBeenCalled();
expect(setFn).not.toHaveBeenCalled();
request.post = testFn;
requestMiddleware.post('path');
expect(testFn).toHaveBeenCalledWith('path');
expect(setFn).toHaveBeenCalledWith('Homebrewery-Version', '999');
});
it('should add header to delete', ()=>{
expect(testFn).not.toHaveBeenCalled();
expect(setFn).not.toHaveBeenCalled();
request.delete = testFn;
requestMiddleware.delete('path');
expect(testFn).toHaveBeenCalledWith('path');
expect(setFn).toHaveBeenCalledWith('Homebrewery-Version', '999');
});
});

View File

@@ -1,35 +0,0 @@
const getLocalStorageMap = function(){
const localStorageMap = {
'AUTOSAVE_ON' : 'HB_editor_autoSaveOn',
'HOMEBREWERY-EDITOR-THEME' : 'HB_editor_theme',
'liveScroll' : 'HB_editor_liveScroll',
'naturalcrit-pane-split' : 'HB_editor_splitWidth',
'HOMEBREWERY-LISTPAGE-SORTDIR' : 'HB_listPage_sortDir',
'HOMEBREWERY-LISTPAGE-SORTTYPE' : 'HB_listPage_sortType',
'HOMEBREWERY-LISTPAGE-VISIBILITY-published' : 'HB_listPage_visibility_group_published',
'HOMEBREWERY-LISTPAGE-VISIBILITY-unpublished' : 'HB_listPage_visibility_group_unpublished',
'hbAdminTab' : 'HB_adminPage_currentTab',
'homebrewery-new' : 'HB_newPage_content',
'homebrewery-new-meta' : 'HB_newPage_metadata',
'homebrewery-new-style' : 'HB_newPage_style',
'homebrewery-recently-edited' : 'HB_nav_recentlyEdited',
'homebrewery-recently-viewed' : 'HB_nav_recentlyViewed',
'hb_toolbarState' : 'HB_renderer_toolbarState',
'hb_toolbarVisibility' : 'HB_renderer_toolbarVisibility'
};
if(global?.account?.username){
const username = global.account.username;
localStorageMap[`HOMEBREWERY-DEFAULT-SAVE-LOCATION-${username}`] = `HB_editor_defaultSave_${username}`;
}
return localStorageMap;
};
export default getLocalStorageMap;

View File

@@ -1,22 +0,0 @@
import getLocalStorageMap from './localStorageKeyMap.js';
const updateLocalStorage = function(){
// Return if no window and thus no local storage
if(typeof window === 'undefined') return;
const localStorageKeyMap = getLocalStorageMap();
const storage = window.localStorage;
Object.keys(localStorageKeyMap).forEach((key)=>{
if(storage[key]){
if(!storage[localStorageKeyMap[key]]){
const data = storage.getItem(key);
storage.setItem(localStorageKeyMap[key], data);
};
storage.removeItem(key);
}
});
};
export { updateLocalStorage };

View File

@@ -42,7 +42,6 @@ function parseBrewForStorage(brew, slot = 0) {
title : brew.title,
text : brew.text,
style : brew.style,
snippets : brew.snippets,
version : brew.version,
shareId : brew.shareId,
savedAt : brew?.savedAt || new Date(),

View File

@@ -1,34 +1,84 @@
.fac {
display : inline-block;
width : 1em;
aspect-ratio : 1;
background-color : currentColor;
mask-size : contain;
mask-repeat : no-repeat;
mask-position : center;
mask-size : contain;
width : 1em;
aspect-ratio : 1;
}
.position-top-left {
mask-image: url('../icons/position-top-left.svg');
}
.position-top-right {
mask-image: url('../icons/position-top-right.svg');
}
.position-bottom-left {
mask-image: url('../icons/position-bottom-left.svg');
}
.position-bottom-right {
mask-image: url('../icons/position-bottom-right.svg');
}
.position-top {
mask-image: url('../icons/position-top.svg');
}
.position-right {
mask-image: url('../icons/position-right.svg');
}
.position-bottom {
mask-image: url('../icons/position-bottom.svg');
}
.position-left {
mask-image: url('../icons/position-left.svg');
}
.mask-edge {
mask-image: url('../icons/mask-edge.svg');
}
.mask-corner {
mask-image: url('../icons/mask-corner.svg');
}
.mask-center {
mask-image: url('../icons/mask-center.svg');
}
.book-front-cover {
mask-image: url('../icons/book-front-cover.svg');
}
.book-back-cover {
mask-image: url('../icons/book-back-cover.svg');
}
.book-inside-cover {
mask-image: url('../icons/book-inside-cover.svg');
}
.book-part-cover {
mask-image: url('../icons/book-part-cover.svg');
}
.image-wrap-left {
mask-image: url('../icons/image-wrap-left.svg');
}
.image-wrap-right {
mask-image: url('../icons/image-wrap-right.svg');
}
.davek {
mask-image: url('../icons/Davek.svg');
}
.rellanic {
mask-image: url('../icons/Rellanic.svg');
}
.iokharic {
mask-image: url('../icons/Iokharic.svg');
}
.zoom-to-fit {
mask-image: url('../icons/zoom-to-fit.svg');
}
.fit-width {
mask-image: url('../icons/fit-width.svg');
}
.single-spread {
mask-image: url('../icons/single-spread.svg');
}
.facing-spread {
mask-image: url('../icons/facing-spread.svg');
}
.flow-spread {
mask-image: url('../icons/flow-spread.svg');
}
.position-top-left { mask-image : url('../icons/position-top-left.svg'); }
.position-top-right { mask-image : url('../icons/position-top-right.svg'); }
.position-bottom-left { mask-image : url('../icons/position-bottom-left.svg'); }
.position-bottom-right { mask-image : url('../icons/position-bottom-right.svg'); }
.position-top { mask-image : url('../icons/position-top.svg'); }
.position-right { mask-image : url('../icons/position-right.svg'); }
.position-bottom { mask-image : url('../icons/position-bottom.svg'); }
.position-left { mask-image : url('../icons/position-left.svg'); }
.mask-edge { mask-image : url('../icons/mask-edge.svg'); }
.mask-corner { mask-image : url('../icons/mask-corner.svg'); }
.mask-center { mask-image : url('../icons/mask-center.svg'); }
.book-front-cover { mask-image : url('../icons/book-front-cover.svg'); }
.book-back-cover { mask-image : url('../icons/book-back-cover.svg'); }
.book-inside-cover { mask-image : url('../icons/book-inside-cover.svg'); }
.book-part-cover { mask-image : url('../icons/book-part-cover.svg'); }
.image-wrap-left { mask-image : url('../icons/image-wrap-left.svg'); }
.image-wrap-right { mask-image : url('../icons/image-wrap-right.svg'); }
.davek { mask-image : url('../icons/Davek.svg'); }
.rellanic { mask-image : url('../icons/Rellanic.svg'); }
.iokharic { mask-image : url('../icons/Iokharic.svg'); }
.zoom-to-fit { mask-image : url('../icons/zoom-to-fit.svg'); }
.fit-width { mask-image : url('../icons/fit-width.svg'); }
.single-spread { mask-image : url('../icons/single-spread.svg'); }
.facing-spread { mask-image : url('../icons/facing-spread.svg'); }
.flow-spread { mask-image : url('../icons/flow-spread.svg'); }

View File

@@ -14,6 +14,7 @@ const template = async function(name, title='', props = {}){
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, height=device-height, interactive-widget=resizes-visual" />
<link href="//use.fontawesome.com/releases/v6.5.1/css/all.css" rel="stylesheet" type="text/css" />
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
<link href=${`/${name}/bundle.css`} type="text/css" rel='stylesheet' />
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" />

View File

@@ -1,9 +1,10 @@
{
"development": true,
"host" : "homebrewery.local.naturalcrit.com:8000",
"naturalcrit_url" : "local.naturalcrit.com:8010",
"secret" : "secret",
"web_port" : 8000,
"enable_v3" : true,
"enable_themes" : true,
"local_environments" : ["docker", "local"],
"publicUrl" : "https://homebrewery.naturalcrit.com",
"hb_images" : null,

View File

@@ -32,7 +32,7 @@ export default [{
"max-depth" : ["warn", { max: 4 }],
"max-params" : ["warn", { max: 5 }],
"no-restricted-syntax" : ["warn", "ClassDeclaration", "SwitchStatement"],
"no-unused-vars" : ["warn", { vars: "all", args: "none", varsIgnorePattern: "config|_|cx|createReactClass" }],
"no-unused-vars" : ["warn", { vars: "all", args: "none", varsIgnorePattern: "config|_|cx|createClass" }],
"react/jsx-uses-vars" : "warn",
/** Fixable **/

View File

@@ -1,3 +0,0 @@
# About
Run `deploy.bash` to download, extract, and deploy the font awesome files into place for building. Should only be needed when Font Awesome version changes and we want the new version.

View File

@@ -1,42 +0,0 @@
#!/bin/bash
# Deploys the Font Awesome files for HB self-hosting to settle various issues.
THEURL=https://use.fontawesome.com/releases/v6.7.2/fontawesome-free-6.7.2-web.zip
THEFILE=fontawesome-free-6.7.2-web.zip
if [ ! "$(which wget)" ]; then
echo "Please manually download ${THEURL}"
exit -1
fi
wget ${THEURL}
if [ $? -ne 0 ]; then
echo "Error downloading ${THEURL}"
exit -2
fi
if [ ! "$(which unzip)" ]; then
echo "Please unzip the file with your tool of choice."
exit -3
fi
unzip fontawesome-free-6.7.2-web.zip
if [ $? -ne 0 ]; then
echo "Error extracting ${THEFILE}"
fi
echo "Copying fonts"
cp -rv fontawesome-free-*-web/webfonts/*.woff2 ../themes/fonts/iconFonts
echo "Copying and updating css"
echo "fontawesome-free.less"
sed 's/..\/webfonts/\/fonts\/iconFonts/g' fontawesome-free-*-web/css/fontawesome.css > ../themes/fonts/iconFonts/fontawesome-free.less
echo "fontawesome-solid.less"
sed 's/..\/webfonts/\/fonts\/iconFonts/g' fontawesome-free-*-web/css/solid.css > ../themes/fonts/iconFonts/fontawesome-solid.less
echo "fontawesome-brands.less"
sed 's/..\/webfonts/\/fonts\/iconFonts/g' fontawesome-free-*-web/css/brands.css > ../themes/fonts/iconFonts/fontawesome-brands.less
echo "fontawesome-regular.less"
sed 's/..\/webfonts/\/fonts\/iconFonts/g' fontawesome-free-*-web/css/regular.css > ../themes/fonts/iconFonts/fontawesome-regular.less

8678
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
{
"name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.20.1",
"version": "3.17.0",
"type": "module",
"engines": {
"npm": "^10.8.x",
"npm": "^10.2.x",
"node": "^20.18.x"
},
"repository": {
@@ -36,6 +36,7 @@
"test:mustache-syntax:inline": "jest \".*(mustache-syntax).*\" -t '^Inline:.*' --verbose --noStackTrace",
"test:mustache-syntax:block": "jest \".*(mustache-syntax).*\" -t '^Block:.*' --verbose --noStackTrace",
"test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace",
"test:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace",
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
"test:non-breaking-spaces": "jest tests/markdown/non-breaking-spaces.test.js --verbose --noStackTrace",
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
@@ -44,9 +45,7 @@
"phb": "node --experimental-require-module scripts/phb.js",
"prod": "set NODE_ENV=production && npm run build",
"postinstall": "npm run build",
"start": "node --experimental-require-module server.js",
"docker:build": "docker build -t ${DOCKERID}/homebrewery:$npm_package_version .",
"docker:publish": "docker login && docker push ${DOCKERID}/homebrewery:$npm_package_version"
"start": "node --experimental-require-module server.js"
},
"author": "stolksdorf",
"license": "MIT",
@@ -61,11 +60,8 @@
"server"
],
"transformIgnorePatterns": [
"node_modules/(?!(nanoid|@exodus/bytes|parse5)/)"
"node_modules/(?!nanoid/).*"
],
"transform": {
"^.+\\.js$": "babel-jest"
},
"coveragePathIgnorePatterns": [
"build/*"
],
@@ -77,7 +73,7 @@
"lines": 50
},
"server/homebrew.api.js": {
"statements": 60,
"statements": 70,
"branches": 50,
"functions": 65,
"lines": 70
@@ -88,75 +84,63 @@
]
},
"dependencies": {
"@babel/core": "^7.28.4",
"@babel/plugin-transform-runtime": "^7.28.3",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.28.5",
"@babel/runtime": "^7.28.4",
"@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^19.2.0",
"@sanity/diff-match-patch": "^3.2.0",
"body-parser": "^2.2.0",
"@babel/core": "^7.26.9",
"@babel/plugin-transform-runtime": "^7.26.9",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@googleapis/drive": "^8.14.0",
"body-parser": "^1.20.2",
"classnames": "^2.5.1",
"codemirror": "^5.65.6",
"cookie-parser": "^1.4.7",
"core-js": "^3.47.0",
"core-js": "^3.40.0",
"cors": "^2.8.5",
"create-react-class": "^15.7.0",
"dedent": "^1.7.1",
"dedent-tabs": "^0.10.3",
"dompurify": "^3.2.4",
"expr-eval": "^2.0.2",
"express": "^5.1.0",
"express": "^4.21.2",
"express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.0",
"fflate": "^0.8.2",
"fs-extra": "11.3.2",
"hash-wasm": "^4.12.0",
"idb-keyval": "^6.2.2",
"js-yaml": "^4.1.1",
"express-static-gzip": "2.2.0",
"fs-extra": "11.3.0",
"idb-keyval": "^6.2.1",
"js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6",
"less": "^3.13.1",
"lodash": "^4.17.21",
"marked": "15.0.12",
"marked-alignment-paragraphs": "^1.0.0",
"marked-definition-lists": "^1.0.1",
"marked-emoji": "^2.0.2",
"marked-extended-tables": "^2.0.1",
"marked-gfm-heading-id": "^4.1.3",
"marked-nonbreaking-spaces": "^1.0.1",
"marked": "13.0.3",
"marked-emoji": "^1.4.3",
"marked-extended-tables": "^1.1.0",
"marked-gfm-heading-id": "^4.0.1",
"marked-smartypants-lite": "^1.0.3",
"marked-subsuper-text": "^1.0.4",
"marked-variables": "^1.0.4",
"marked-subsuper-text": "^1.0.3",
"markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1",
"mongoose": "^8.20.0",
"nanoid": "5.1.6",
"nconf": "^0.13.0",
"mongoose": "^8.10.1",
"nanoid": "5.1.0",
"nconf": "^0.12.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-frame-component": "^4.1.3",
"react-router": "^7.9.6",
"romans": "^3.1.0",
"react-frame-component": "^5.2.7",
"react-router": "^7.2.0",
"sanitize-filename": "1.6.3",
"superagent": "^10.2.1",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git",
"written-number": "^0.11.1"
"superagent": "^10.1.1",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
},
"devDependencies": {
"@stylistic/stylelint-plugin": "^4.0.0",
"babel-jest": "^30.2.0",
"babel-plugin-transform-import-meta": "^2.3.3",
"eslint": "^9.39.1",
"eslint-plugin-jest": "^29.1.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.4.0",
"jest": "^30.2.0",
"@stylistic/stylelint-plugin": "^3.1.2",
"babel-plugin-transform-import-meta": "^2.3.2",
"eslint": "^9.20.1",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-react": "^7.37.4",
"globals": "^15.15.0",
"jest": "^29.7.0",
"jest-expect-message": "^1.1.3",
"jsdom": "^27.4.0",
"jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0",
"stylelint": "^16.25.0",
"stylelint-config-recess-order": "^7.3.0",
"stylelint-config-recommended": "^17.0.0",
"supertest": "^7.1.4"
"stylelint": "^16.14.1",
"stylelint-config-recess-order": "^6.0.0",
"stylelint-config-recommended": "^15.0.0",
"supertest": "^7.0.0"
}
}

View File

@@ -10,7 +10,7 @@ import babel from '@babel/core';
import babelConfig from '../babel.config.json' with { type : 'json' };
import less from 'less';
const isDev = !!process.argv.find((arg)=>arg === '--dev');
const isDev = !!process.argv.find((arg) => arg === '--dev');
const babelify = async (code)=>(await babel.transformAsync(code, babelConfig)).code;
@@ -53,7 +53,7 @@ fs.emptyDirSync('./build');
const themes = { Legacy: {}, V3: {} };
let themeFiles = fs.readdirSync('./themes/Legacy');
for (const dir of themeFiles) {
for (let dir of themeFiles) {
const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`).toString());
themeData.path = dir;
themes.Legacy[dir] = (themeData);
@@ -70,7 +70,7 @@ fs.emptyDirSync('./build');
}
themeFiles = fs.readdirSync('./themes/V3');
for (const dir of themeFiles) {
for (let dir of themeFiles) {
const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`).toString());
themeData.path = dir;
themes.V3[dir] = (themeData);
@@ -113,7 +113,7 @@ fs.emptyDirSync('./build');
const stream = fs.createWriteStream(editorThemeFile, { flags: 'a' });
stream.write('[\n"default"');
for (const themeFile of editorThemeFiles) {
for (let themeFile of editorThemeFiles) {
stream.write(`,\n"${themeFile.slice(0, -4)}"`);
}
stream.write('\n]\n');

View File

@@ -27,8 +27,6 @@
"codemirror/addon/selection/active-line.js",
"codemirror/addon/hint/show-hint.js",
"moment",
"superagent",
"@sanity/diff-match-patch",
"fflate"
"superagent"
]
}

View File

@@ -1,4 +1,3 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
import { model as HomebrewModel } from './homebrew.model.js';
import { model as NotificationModel } from './notifications.model.js';
import express from 'express';
@@ -12,7 +11,6 @@ import { splitTextStyleAndMetadata } from '../shared/helpers.js';
const router = express.Router();
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
@@ -164,180 +162,6 @@ router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
}
});
// ####################### LOCKS
router.get('/api/lock/count', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksQuery = {
lock : { $exists: true }
};
const count = await HomebrewModel.countDocuments(countLocksQuery)
.catch((error)=>{
throw { name: 'Lock Count Error', message: 'Unable to get lock count', status: 500, HBErrorCode: '61', error };
});
return res.json({ count });
}));
router.get('/api/locks', mw.adminOnly, asyncHandler(async (req, res)=>{
const countLocksPipeline = [
{
$match :
{
'lock' : { '$exists': 1 }
},
},
{
$project : {
shareId : 1,
editId : 1,
title : 1,
lock : 1
}
}
];
const lockedDocuments = await HomebrewModel.aggregate(countLocksPipeline)
.catch((error)=>{
throw { name: 'Can Not Get Locked Brews', message: 'Unable to get locked brew collection', status: 500, HBErrorCode: '68', error };
});
return res.json({
lockedDocuments
});
}));
router.post('/api/lock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const lock = req.body;
lock.applied = new Date;
const filter = {
shareId : req.params.id
};
const brew = await HomebrewModel.findOne(filter);
if(!brew) throw { name: 'Brew Not Found', message: 'Cannot find brew to lock', shareId: req.params.id, status: 500, HBErrorCode: '63' };
if(brew.lock && !lock.overwrite) {
throw { name: 'Already Locked', message: 'Lock already exists on brew', shareId: req.params.id, title: brew.title, status: 500, HBErrorCode: '64' };
}
lock.overwrite = undefined;
brew.lock = lock;
brew.markModified('lock');
await brew.save()
.catch((error)=>{
throw { name: 'Lock Error', message: 'Unable to set lock', shareId: req.params.id, status: 500, HBErrorCode: '62', error };
});
return res.json({ name: 'LOCKED', message: `Lock applied to brew ID ${brew.shareId} - ${brew.title}`, ...lock });
}));
router.put('/api/unlock/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const filter = {
shareId : req.params.id
};
const brew = await HomebrewModel.findOne(filter);
if(!brew) throw { name: 'Brew Not Found', message: 'Cannot find brew to unlock', shareId: req.params.id, status: 500, HBErrorCode: '66' };
if(!brew.lock) throw { name: 'Not Locked', message: 'Cannot unlock as brew is not locked', shareId: req.params.id, status: 500, HBErrorCode: '67' };
brew.lock = undefined;
brew.markModified('lock');
await brew.save()
.catch((error)=>{
throw { name: 'Cannot Unlock', message: 'Unable to clear lock', shareId: req.params.id, status: 500, HBErrorCode: '65', error };
});
return res.json({ name: 'Unlocked', message: `Lock removed from brew ID ${req.params.id}` });
}));
router.get('/api/lock/reviews', mw.adminOnly, asyncHandler(async (req, res)=>{
const countReviewsPipeline = [
{
$match :
{
'lock.reviewRequested' : { '$exists': 1 }
},
},
{
$project : {
shareId : 1,
editId : 1,
title : 1,
lock : 1
}
}
];
const reviewDocuments = await HomebrewModel.aggregate(countReviewsPipeline)
.catch((error)=>{
throw { name: 'Can Not Get Reviews', message: 'Unable to get review collection', status: 500, HBErrorCode: '68', error };
});
return res.json({
reviewDocuments
});
}));
router.put('/api/lock/review/request/:id', asyncHandler(async (req, res)=>{
// === This route is NOT Admin only ===
// Any user can request a review of their document
const filter = {
shareId : req.params.id,
lock : { $exists: 1 }
};
const brew = await HomebrewModel.findOne(filter);
if(!brew) { throw { name: 'Brew Not Found', message: `Cannot find a locked brew with ID ${req.params.id}`, code: 500, HBErrorCode: '70' }; };
if(brew.lock.reviewRequested){
throw { name: 'Review Already Requested', message: `Review already requested for brew ${brew.shareId} - ${brew.title}`, code: 500, HBErrorCode: '71' };
};
brew.lock.reviewRequested = new Date();
brew.markModified('lock');
await brew.save()
.catch((error)=>{
throw { name: 'Can Not Set Review Request', message: `Unable to set request for review on brew ID ${req.params.id}`, code: 500, HBErrorCode: '69', error };
});
return res.json({ name: 'Review Requested', message: `Review requested on brew ID ${brew.shareId} - ${brew.title}` });
}));
router.put('/api/lock/review/remove/:id', mw.adminOnly, asyncHandler(async (req, res)=>{
const filter = {
shareId : req.params.id,
'lock.reviewRequested' : { $exists: 1 }
};
const brew = await HomebrewModel.findOne(filter);
if(!brew) { throw { name: 'Can Not Clear Review Request', message: `Brew ID ${req.params.id} does not have a review pending!`, HBErrorCode: '73' }; };
brew.lock.reviewRequested = undefined;
brew.markModified('lock');
await brew.save()
.catch((error)=>{
throw { name: 'Can Not Clear Review Request', message: `Unable to remove request for review on brew ID ${req.params.id}`, HBErrorCode: '72', error };
});
return res.json({ name: 'Review Request Cleared', message: `Review request removed for brew ID ${brew.shareId} - ${brew.title}` });
}));
// ####################### NOTIFICATIONS
router.get('/admin/notification/all', async (req, res, next)=>{

View File

@@ -1,8 +1,6 @@
/*eslint max-lines: ["warn", {"max": 1000, "skipBlankLines": true, "skipComments": true}]*/
import supertest from 'supertest';
import HBApp from './app.js';
import { model as NotificationModel } from './notifications.model.js';
import { model as HomebrewModel } from './homebrew.model.js';
import {model as NotificationModel } from './notifications.model.js';
// Mimic https responses to avoid being redirected all the time
@@ -18,7 +16,7 @@ describe('Tests for admin api', ()=>{
const testNotifications = ['a', 'b'];
jest.spyOn(NotificationModel, 'find')
.mockImplementationOnce(()=>{
.mockImplementationOnce(() => {
return { exec: jest.fn().mockResolvedValue(testNotifications) };
});
@@ -61,7 +59,7 @@ describe('Tests for admin api', ()=>{
expect(response.body).toEqual(savedNotification);
});
it('should handle error adding a notification without dismissKey', async ()=>{
it('should handle error adding a notification without dismissKey', async () => {
const inputNotification = {
title : 'Test Notification',
text : 'This is a test notification',
@@ -77,7 +75,7 @@ describe('Tests for admin api', ()=>{
const response = await app
.post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.set('Authorization', 'Basic ' + Buffer.from('admin:password3').toString('base64'))
.send(inputNotification);
expect(response.status).toBe(500);
@@ -88,14 +86,14 @@ describe('Tests for admin api', ()=>{
const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete')
.mockImplementationOnce((key)=>{
.mockImplementationOnce((key) => {
return { exec: jest.fn().mockResolvedValue(key) };
});
const response = await app
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ 'dismissKey': 'testKey' });
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({'dismissKey': 'testKey'});
expect(response.status).toBe(200);
expect(response.body).toEqual({ dismissKey: 'testKey' });
});
@@ -104,602 +102,16 @@ describe('Tests for admin api', ()=>{
const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete')
.mockImplementationOnce(()=>{
.mockImplementationOnce(() => {
return { exec: jest.fn().mockResolvedValue() };
});
const response = await app
.delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({ 'dismissKey': 'testKey' });
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({'dismissKey': 'testKey'});
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Notification not found' });
});
});
describe('Locks', ()=>{
describe('Count', ()=>{
it('Count of all locked documents', async ()=>{
const testNumber = 16777216; // 8^8, because why not
jest.spyOn(HomebrewModel, 'countDocuments')
.mockImplementationOnce(()=>{
return Promise.resolve(testNumber);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/count');
expect(response.status).toBe(200);
expect(response.body).toEqual({ count: testNumber });
});
it('Handle error while fetching count of locked documents', async ()=>{
jest.spyOn(HomebrewModel, 'countDocuments')
.mockImplementationOnce(()=>{
return Promise.reject();
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/count');
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '61',
message : 'Unable to get lock count',
name : 'Lock Count Error',
originalUrl : '/api/lock/count',
status : 500,
});
});
});
describe('Lists', ()=>{
it('Get list of all locked documents', async ()=>{
const testLocks = ['a', 'b'];
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.resolve(testLocks);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/locks');
expect(response.status).toBe(200);
expect(response.body).toEqual({ lockedDocuments: testLocks });
});
it('Handle error while fetching list of all locked documents', async ()=>{
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.reject();
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/locks');
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '68',
message : 'Unable to get locked brew collection',
name : 'Can Not Get Locked Brews',
originalUrl : '/api/locks',
status : 500
});
});
it('Get list of all locked documents with pending review requests', async ()=>{
const testLocks = ['a', 'b'];
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.resolve(testLocks);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/reviews');
expect(response.status).toBe(200);
expect(response.body).toEqual({ reviewDocuments: testLocks });
});
it('Handle error while fetching list of all locked documents with pending review requests', async ()=>{
jest.spyOn(HomebrewModel, 'aggregate')
.mockImplementationOnce(()=>{
return Promise.reject();
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.get('/api/lock/reviews');
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '68',
message : 'Unable to get review collection',
name : 'Can Not Get Reviews',
originalUrl : '/api/lock/reviews',
status : 500
});
});
});
describe('Lock', ()=>{
it('Lock a brew', async ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); }
};
const testLock = {
code : 999,
editMessage : 'edit',
shareMessage : 'share'
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.post(`/api/lock/${testBrew.shareId}`)
.send(testLock);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
applied : expect.any(String),
code : testLock.code,
editMessage : testLock.editMessage,
shareMessage : testLock.shareMessage,
name : 'LOCKED',
message : `Lock applied to brew ID ${testBrew.shareId} - ${testBrew.title}`
});
});
it('Overwrite lock on a locked brew', async ()=>{
const testLock = {
code : 999,
editMessage : 'newEdit',
shareMessage : 'newShare',
overwrite : true
};
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
lock : {
code : 1,
editMessage : 'oldEdit',
shareMessage : 'oldShare',
}
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.post(`/api/lock/${testBrew.shareId}`)
.send(testLock);
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
applied : expect.any(String),
code : testLock.code,
editMessage : testLock.editMessage,
shareMessage : testLock.shareMessage,
name : 'LOCKED',
message : `Lock applied to brew ID ${testBrew.shareId} - ${testBrew.title}`
});
});
it('Error when locking a locked brew', async ()=>{
const testLock = {
code : 999,
editMessage : 'newEdit',
shareMessage : 'newShare'
};
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
lock : {
code : 1,
editMessage : 'oldEdit',
shareMessage : 'oldShare',
}
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.post(`/api/lock/${testBrew.shareId}`)
.send(testLock);
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '64',
message : 'Lock already exists on brew',
name : 'Already Locked',
originalUrl : `/api/lock/${testBrew.shareId}`,
shareId : testBrew.shareId,
status : 500,
title : 'title'
});
});
it('Handle save error while locking a brew', async ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); }
};
const testLock = {
code : 999,
editMessage : 'edit',
shareMessage : 'share'
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.post(`/api/lock/${testBrew.shareId}`)
.send(testLock);
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '62',
message : 'Unable to set lock',
name : 'Lock Error',
originalUrl : `/api/lock/${testBrew.shareId}`,
shareId : testBrew.shareId,
status : 500
});
});
});
describe('Unlock', ()=>{
it('Unlock a brew', async ()=>{
const testLock = {
applied : 'YES',
code : 999,
editMessage : 'edit',
shareMessage : 'share'
};
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/unlock/${testBrew.shareId}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({
name : 'Unlocked',
message : `Lock removed from brew ID ${testBrew.shareId}`
});
});
it('Error when unlocking a brew with no lock', async ()=>{
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/unlock/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '67',
message : 'Cannot unlock as brew is not locked',
name : 'Not Locked',
originalUrl : `/api/unlock/${testBrew.shareId}`,
shareId : testBrew.shareId,
status : 500,
});
});
it('Handle error while unlocking a brew', async ()=>{
const testLock = {
applied : 'YES',
code : 999,
editMessage : 'edit',
shareMessage : 'share'
};
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); },
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/unlock/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '65',
message : 'Unable to clear lock',
name : 'Cannot Unlock',
originalUrl : `/api/unlock/${testBrew.shareId}`,
shareId : testBrew.shareId,
status : 500
});
});
});
describe('Reviews', ()=>{
it('Add review request to a locked brew', async ()=>{
const testLock = {
applied : 'YES',
code : 999,
editMessage : 'edit',
shareMessage : 'share'
};
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({
message : `Review requested on brew ID ${testBrew.shareId} - ${testBrew.title}`,
name : 'Review Requested',
});
});
it('Error when cannot find a locked brew', async ()=>{
const testBrew = {
shareId : 'shareId'
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`)
.catch((err)=>{return err;});
expect(response.status).toBe(500);
expect(response.body).toEqual({
message : `Cannot find a locked brew with ID ${testBrew.shareId}`,
name : 'Brew Not Found',
HBErrorCode : '70',
code : 500,
originalUrl : `/api/lock/review/request/${testBrew.shareId}`
});
});
it('Error when review is already requested', async ()=>{
const testLock = {
applied : 'YES',
code : 999,
editMessage : 'edit',
shareMessage : 'share',
reviewRequested : 'YES'
};
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`)
.catch((err)=>{return err;});
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '70',
code : 500,
message : `Cannot find a locked brew with ID ${testBrew.shareId}`,
name : 'Brew Not Found',
originalUrl : `/api/lock/review/request/${testBrew.shareId}`
});
});
it('Handle error while adding review request to a locked brew', async ()=>{
const testLock = {
applied : 'YES',
code : 999,
editMessage : 'edit',
shareMessage : 'share'
};
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); },
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.put(`/api/lock/review/request/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '69',
code : 500,
message : `Unable to set request for review on brew ID ${testBrew.shareId}`,
name : 'Can Not Set Review Request',
originalUrl : `/api/lock/review/request/${testBrew.shareId}`
});
});
it('Clear review request from a locked brew', async ()=>{
const testLock = {
applied : 'YES',
code : 999,
editMessage : 'edit',
shareMessage : 'share',
reviewRequested : 'YES'
};
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.resolve(); },
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`);
expect(response.status).toBe(200);
expect(response.body).toEqual({
message : `Review request removed for brew ID ${testBrew.shareId} - ${testBrew.title}`,
name : 'Review Request Cleared'
});
});
it('Error when clearing review request from a brew with no review request', async ()=>{
const testBrew = {
shareId : 'shareId',
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(false);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '73',
message : `Brew ID ${testBrew.shareId} does not have a review pending!`,
name : 'Can Not Clear Review Request',
originalUrl : `/api/lock/review/remove/${testBrew.shareId}`
});
});
it('Handle error while clearing review request from a locked brew', async ()=>{
const testLock = {
applied : 'YES',
code : 999,
editMessage : 'edit',
shareMessage : 'share',
reviewRequested : 'YES'
};
const testBrew = {
shareId : 'shareId',
title : 'title',
markModified : ()=>{ return true; },
save : ()=>{ return Promise.reject(); },
lock : testLock
};
jest.spyOn(HomebrewModel, 'findOne')
.mockImplementationOnce(()=>{
return Promise.resolve(testBrew);
});
const response = await app
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
.put(`/api/lock/review/remove/${testBrew.shareId}`);
expect(response.status).toBe(500);
expect(response.body).toEqual({
HBErrorCode : '72',
message : `Unable to remove request for review on brew ID ${testBrew.shareId}`,
name : 'Can Not Clear Review Request',
originalUrl : `/api/lock/review/remove/${testBrew.shareId}`
});
});
});
});
});

View File

@@ -2,7 +2,7 @@
// Set working directory to project root
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import packageJSON from './../package.json' with { type: 'json' };
import packageJSON from './../package.json' with { type: 'json' };
const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(`${__dirname}/..`);
@@ -11,6 +11,7 @@ const version = packageJSON.version;
import _ from 'lodash';
import jwt from 'jwt-simple';
import express from 'express';
import yaml from 'js-yaml';
import config from './config.js';
import fs from 'fs-extra';
@@ -35,7 +36,6 @@ import contentNegotiation from './middleware/content-negotiation.js';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import forceSSL from './forcessl.mw.js';
import dbCheck from './middleware/dbCheck.js';
const sanitizeBrew = (brew, accessType)=>{
@@ -70,11 +70,13 @@ const corsOptions = {
'https://homebrewery-stage.herokuapp.com',
];
const localNetworkRegex = /^http:\/\/(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+):\d+$/;
if(isLocalEnvironment) {
allowedOrigins.push('http://localhost:8000', 'http://localhost:8010');
}
const herokuRegex = /^https:\/\/(?:homebrewery-pr-\d+\.herokuapp\.com|naturalcrit-pr-\d+\.herokuapp\.com)$/; // Matches any Heroku app
if(!origin || allowedOrigins.includes(origin) || herokuRegex.test(origin) || (isLocalEnvironment && localNetworkRegex.test(origin))) {
if(!origin || allowedOrigins.includes(origin) || herokuRegex.test(origin)) {
callback(null, true);
} else {
console.log(origin, 'not allowed');
@@ -275,7 +277,7 @@ app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{
app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);});
//User Page
app.get('/user/:username', dbCheck, async (req, res, next)=>{
app.get('/user/:username', async (req, res, next)=>{
const ownAccount = req.account && (req.account.username == req.params.username);
req.ogMeta = { ...defaultMetaTags,
@@ -347,10 +349,10 @@ app.get('/user/:username', dbCheck, async (req, res, next)=>{
});
//Change author name on brews
app.put('/api/user/rename', dbCheck, async (req, res)=>{
app.put('/api/user/rename', async (req, res)=>{
const { username, newUsername } = req.body;
const ownAccount = req.account && (req.account.username == newUsername);
if(!username || !newUsername)
return res.status(400).json({ error: 'Username and newUsername are required.' });
if(!ownAccount)
@@ -384,7 +386,6 @@ app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res,
title : req.brew.title || 'Untitled Brew',
description : req.brew.description || 'No description.',
image : req.brew.thumbnail || defaultMetaTags.image,
locale : req.brew.lang,
type : 'article'
};
@@ -406,7 +407,6 @@ app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res,
renderer : req.brew.renderer,
theme : req.brew.theme,
tags : req.brew.tags,
snippets : req.brew.snippets
};
req.brew = _.defaults(brew, DEFAULT_BREW);
@@ -433,10 +433,10 @@ app.get('/new', asyncHandler(async(req, res, next)=>{
}));
//Share Page
app.get('/share/:id', dbCheck, asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
const { brew } = req;
req.ogMeta = { ...defaultMetaTags,
title : `${req.brew.title || 'Untitled Brew'} - ${req.brew.authors[0] || 'No author.'}`,
title : req.brew.title || 'Untitled Brew',
description : req.brew.description || 'No description.',
image : req.brew.thumbnail || defaultMetaTags.image,
type : 'article'
@@ -460,7 +460,7 @@ app.get('/share/:id', dbCheck, asyncHandler(getBrew('share')), asyncHandler(asyn
}));
//Account Page
app.get('/account', dbCheck, asyncHandler(async (req, res, next)=>{
app.get('/account', asyncHandler(async (req, res, next)=>{
const data = {};
data.title = 'Account Information Page';
@@ -488,8 +488,8 @@ app.get('/account', dbCheck, asyncHandler(async (req, res, next)=>{
const query = { authors: req.account.username, googleId: { $exists: false } };
const mongoCount = await HomebrewModel.countDocuments(query)
.catch((err)=>{
mongoCount = 0;
console.log(err);
return 0;
});
data.accountDetails = {
@@ -563,6 +563,8 @@ const renderPage = async (req, res)=>{
brews : req.brews,
googleBrews : req.googleBrews,
account : req.account,
enable_v3 : config.get('enable_v3'),
enable_themes : config.get('enable_themes'),
config : configuration,
ogMeta : req.ogMeta,
userThemes : req.userThemes

View File

@@ -22,29 +22,16 @@ const handleConnectionError = (error)=>{
}
};
const addListeners = (conn)=>{
conn.connection.on('disconnecting', ()=>{console.log('Mongo disconnecting...');});
conn.connection.on('disconnected', ()=>{console.log('Mongo disconnected!');});
conn.connection.on('connecting', ()=>{console.log('Mongo connecting...');});
conn.connection.on('connected', ()=>{console.log('Mongo connected!');});
return conn;
};
const disconnect = async ()=>{
return await Mongoose.disconnect();
};
const connect = async (config)=>{
return await Mongoose.connect(getMongoDBURL(config), {
retryWrites : false,
autoIndex : (config.get('local_environments').includes(config.get('node_env')))
})
.then(addListeners(Mongoose))
.catch((error)=>handleConnectionError(error));
return await Mongoose.connect(getMongoDBURL(config), { retryWrites: false })
.catch((error)=>handleConnectionError(error));
};
export default {
connect,
disconnect
};

View File

@@ -1,66 +0,0 @@
import forceSSL from './forcessl.mw';
describe('Tests for ForceSSL middleware', ()=>{
let originalEnv;
let nextFn;
let req = {};
let res = {};
beforeEach(()=>{
originalEnv = process.env.NODE_ENV;
nextFn = jest.fn();
req = {
header : ()=>{ return 'http'; },
get : ()=>{ return 'test'; },
url : 'URL'
};
res = {
redirect : jest.fn()
};
});
afterEach(()=>{
process.env.NODE_ENV = originalEnv;
jest.clearAllMocks();
});
it('should not redirect when NODE_ENV is set to local', ()=>{
process.env.NODE_ENV = 'local';
forceSSL(null, null, nextFn);
expect(res.redirect).not.toHaveBeenCalled();
expect(nextFn).toHaveBeenCalled();
});
it('should not redirect when NODE_ENV is set to docker', ()=>{
process.env.NODE_ENV = 'docker';
forceSSL(null, null, nextFn);
expect(res.redirect).not.toHaveBeenCalled();
expect(nextFn).toHaveBeenCalled();
});
it('should redirect with 302 when header is not HTTPS and NODE_ENV is not local or docker', ()=>{
process.env.NODE_ENV = 'test';
forceSSL(req, res, nextFn);
expect(res.redirect).toHaveBeenCalledWith(302, 'https://testURL');
expect(nextFn).not.toHaveBeenCalled();
});
it('should not redirect when header is HTTPS and NODE_ENV is not local or docker', ()=>{
process.env.NODE_ENV = 'test';
req.header = ()=>{ return 'https'; };
forceSSL(req, res, nextFn);
expect(res.redirect).not.toHaveBeenCalled();
expect(nextFn).toHaveBeenCalled();
});
});

View File

@@ -6,7 +6,6 @@ import config from './config.js';
let serviceAuth;
let clientEmail;
if(!config.get('service_account')){
const reset = '\x1b[0m'; // Reset to default style
const yellow = '\x1b[33m'; // yellow color
@@ -16,10 +15,6 @@ if(!config.get('service_account')){
JSON.parse(config.get('service_account')) :
config.get('service_account');
if(keys?.client_email) {
clientEmail = keys.client_email;
}
try {
serviceAuth = googleDrive.auth.fromJSON(keys);
serviceAuth.scopes = ['https://www.googleapis.com/auth/drive'];
@@ -32,12 +27,12 @@ if(!config.get('service_account')){
const defaultAuth = serviceAuth || config.get('google_api_key');
const retryConfig = {
retry : 3, // Number of retry attempts
retryDelay : 100, // Initial delay in milliseconds
retryDelayMultiplier : 2, // Multiplier for exponential backoff
maxRetryDelay : 32000, // Maximum delay in milliseconds
httpMethodsToRetry : ['PATCH'], // Only retry PATCH requests
statusCodesToRetry : [[429, 429]], // Only retry on 429 status code
retry: 3, // Number of retry attempts
retryDelay: 100, // Initial delay in milliseconds
retryDelayMultiplier: 2, // Multiplier for exponential backoff
maxRetryDelay: 32000, // Maximum delay in milliseconds
httpMethodsToRetry: ['PATCH'], // Only retry PATCH requests
statusCodesToRetry: [[429, 429]], // Only retry on 429 status code
};
const GoogleActions = {
@@ -182,8 +177,8 @@ const GoogleActions = {
mimeType : 'text/plain',
body : brew.text
},
headers : {
'X-Forwarded-For' : userIp, // Set the X-Forwarded-For header
headers: {
'X-Forwarded-For': userIp, // Set the X-Forwarded-For header
},
retryConfig
})
@@ -232,30 +227,14 @@ const GoogleActions = {
if(!obj) return;
if(clientEmail) {
await drive.permissions.create({
resource : {
type : 'user',
emailAddress : clientEmail,
role : 'writer'
},
fileId : obj.data.id,
fields : 'id',
})
.catch((err)=>{
console.log('Error adding Service Account permissions on Google Drive file');
console.error(err);
});
}
await drive.permissions.create({
resource : { type : 'anyone',
role : 'writer' },
role : 'writer' },
fileId : obj.data.id,
fields : 'id',
})
.catch((err)=>{
console.log('Error adding "Anyone" permissions on Google Drive file');
console.log('Error updating permissions');
console.error(err);
});

Some files were not shown because too many files have changed in this diff Show More