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

Compare commits

..

2 Commits

Author SHA1 Message Date
Trevor Buckner
aa62b32936 handle header padding 2025-03-25 19:36:25 -04:00
Trevor Buckner
4aa5a00a6d test 2025-03-25 18:02:12 -04:00
139 changed files with 6858 additions and 16864 deletions

View File

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

View File

@@ -5,15 +5,6 @@ updates:
schedule: schedule:
interval: daily interval: daily
open-pull-requests-limit: 99 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: ignore:
- dependency-name: eslint - dependency-name: eslint
versions: versions:

View File

@@ -49,7 +49,7 @@ Make an changes you need to `config/docker.json` then build the image. If it doe
"web_port" : 8000, "web_port" : 8000,
"enable_v3" : true, "enable_v3" : true,
"mongodb_uri": "mongodb://172.17.0.2/homebrewery", "mongodb_uri": "mongodb://172.17.0.2/homebrewery",
"enable_themes" : true "enable_themes" : true,
} }
``` ```
@@ -90,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 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 ## 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. When Homebrewery code updates, your docker container will not automatically follow the changes. To do so you will need to rebuild your homebrewery image.
@@ -124,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 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 install`
1. `npm start` 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. You should now be able to go to [http://localhost:8000](http://localhost:8000)
in your browser and use The Homebrewery offline.
On completion, you should 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: 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 - [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-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 [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 [gitter-url]: https://gitter.im/naturalcrit/Lobby

View File

@@ -88,94 +88,10 @@ pre {
## changelog ## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
### Wednesday 7/09/2025 - v3.19.3
{{taskList
##### calculuschild
* [x] Restoring original saving behavior; will continue investigating why save was failing for some users in background
}}
### Wednesday 7/09/2025 - v3.19.2
{{taskList
##### calculuschild
* [x] Hotfix for saving issues - Please refresh your browser and report if problems continue
}}
### Wednesday 7/09/2025 - v3.19.1
{{taskList
##### calculuschild
* [x] Send diffs instead of full file on save - should help with timeout/disconnect errors
}}
\column
### Thursday 05/22/2025 - v3.19.0
{{taskList
##### abquintic
* [x] Fix crash due to colons after `\page`
Fixes issue [#4105](https://github.com/naturalcrit/homebrewery/issues/4105)
* [x] Fix images with spaces in alt text not rendering
Fixes issue [#3659](https://github.com/naturalcrit/homebrewery/issues/3659)
* [x] Custom snippets! Open the new {{openSans **:fas_table_list: SNIPPETS**}} tab (next to the {{openSans **:fas_paintbrush: STYLE**}} tab). Custom snippets will appear in a new snippet dropdown, and will be included when imported as a custom theme.
* [x] Move several generic styles/snippets from PHB to the Blank theme; generic snippets like image masks no longer require the PHB theme.
* [x] Extract several Markdown+ syntax extensions into their own NPM packages, for use by the wider community.
* [x] Allow `\pagebreak` and `\columnbreak` as alternatives to `\page` and `\column`
Partially fixes issue [#4035](https://github.com/naturalcrit/homebrewery/issues/4035)
* [x] Fix misbehaving column breaks on old Chrome
Fixes issue [#4192](https://github.com/naturalcrit/homebrewery/issues/4192)
* [x] Self-host font-awesome icons; fix missing icons on local installs
Fixes issue [#1965](https://github.com/naturalcrit/homebrewery/issues/1965)
Fixes issue [#1548](https://github.com/naturalcrit/homebrewery/issues/1548)
##### G-Ambatte
* [x] Fix CORS issue on local installs
* [x] Fix print size issues when using the Facing and Flow view options.
Fixes issue [#4146](https://github.com/naturalcrit/homebrewery/issues/4146)
* [x] New built-in `$[HB_pageNumber]` variable. Works with math operations or can be reassigned like any other variable for more customization over the old `{{pageNumber,auto}}` snippet.\
New snippet found at {{openSans **:fas_pencil: TEXT EDITOR :fas_arrow_right: :fas_bookmark: PAGE NUMBERING :fas_arrow_right: :fas_arrow_down_1_9: VARIABLE AUTO PAGE NUMBER**}}
##### 5e-Cleric
* [x] Fix search bar covering up snippet bar (3 times)
Fixes issue [#4098](https://github.com/naturalcrit/homebrewery/issues/4098)
* [x] Save view toolbar settings across sessions
Fixes issue [#3835](https://github.com/naturalcrit/homebrewery/issues/3835)
* [x] Fix styling issues on the view toolbar
* [x] Update the Darkbrewery editor theme
Fixes issue [#3312](https://github.com/naturalcrit/homebrewery/issues/3312)
}}
\page
### Monday 03/10/2025 - v3.18.0 ### Monday 03/10/2025 - v3.18.0
{{taskList {{taskList
##### abquintic ##### dbolack
* [x] Add ability to paste in any Share ID/URL into a brew's {{openSans :fas_circle_info: **Properties** :fas_arrow_right: **THEMES**}} selection, as long as that brew has been tagged as `meta:theme`. You can now share your custom brew themes without needing to make a personal copy. * [x] Add ability to paste in any Share ID/URL into a brew's {{openSans :fas_circle_info: **Properties** :fas_arrow_right: **THEMES**}} selection, as long as that brew has been tagged as `meta:theme`. You can now share your custom brew themes without needing to make a personal copy.
* [x] Begin migration of custom Markdown extensions into their own NPM packages, for easier adoption by other users or projects * [x] Begin migration of custom Markdown extensions into their own NPM packages, for easier adoption by other users or projects
* [x] Fix external HTML appearing in open codeblocks * [x] Fix external HTML appearing in open codeblocks
@@ -198,9 +114,6 @@ Fixes issue [#1729](https://github.com/naturalcrit/homebrewery/issues/1729)
##### 5e-Cleric ##### 5e-Cleric
* [x] Style fixes for covers art and logos on A4 size pages * [x] Style fixes for covers art and logos on A4 size pages
* [x] Fix crash when trying to open brews that don't exist * [x] Fix crash when trying to open brews that don't exist
* [x] Tweaks and style update styling on {{openSans **VAULT** :fas_dungeon:}} page.
Fixes issue [#4079](https://github.com/naturalcrit/homebrewery/issues/4079)
##### Calculuschild ##### Calculuschild
* [x] `` now produces `<br>` instead of a `<div>` * [x] `` now produces `<br>` instead of a `<div>`
@@ -237,7 +150,7 @@ Fixes issue [#4073](https://github.com/naturalcrit/homebrewery/issues/4073)
* [x] Fix Reddit link crash when title has non-latin chars * [x] Fix Reddit link crash when title has non-latin chars
##### abquintic ##### dbolack
* [x] Fix page shadows toolbar option * [x] Fix page shadows toolbar option

View File

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

View File

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

View File

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

View File

@@ -1,342 +0,0 @@
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
require('./lockTools.less');
const React = require('react');
const createClass = require('create-react-class');
import request from '../../homebrew/utils/request-middleware.js';
const LockTools = createClass({
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 = createClass({
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 = createClass({
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 = createClass({
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>;
}
});
module.exports = 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

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

View File

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

View File

@@ -1,11 +1,13 @@
.anchored-box { .anchored-box {
position : absolute; position:absolute;
visibility : hidden; @supports (inset-block-start: anchor(bottom)){
justify-self : anchor-center; inset-block-start: anchor(bottom);
@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

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

View File

@@ -19,15 +19,12 @@ const { printCurrentBrew } = require('../../../shared/helpers.js');
import HeaderNav from './headerNav/headerNav.jsx'; import HeaderNav from './headerNav/headerNav.jsx';
import { safeHTML } from './safeHTML.js'; import { safeHTML } from './safeHTML.js';
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m; const PAGEBREAK_REGEX_V3 = /^(?=\\page(?: *{[^\n{}]*})?$)/m;
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m;
const PAGE_HEIGHT = 1056; const PAGE_HEIGHT = 1056;
const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
const INITIAL_CONTENT = dedent` const INITIAL_CONTENT = dedent`
<!DOCTYPE html><html><head> <!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="//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' /> <link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
<base target=_blank> <base target=_blank>
@@ -41,7 +38,7 @@ const BrewPage = (props)=>{
index : 0, index : 0,
...props ...props
}; };
const pageRef = useRef(null); const pageRef = useRef(null);
const cleanText = safeHTML(props.contents); const cleanText = safeHTML(props.contents);
useEffect(()=>{ useEffect(()=>{
@@ -117,24 +114,16 @@ const BrewRenderer = (props)=>{
zoomLevel : 100, zoomLevel : 100,
spread : 'single', spread : 'single',
startOnRight : true, startOnRight : true,
pageShadows : true, pageShadows : true
rowGap : 5,
columnGap : 10,
}); });
//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 [headerState, setHeaderState] = useState(false);
const mainRef = useRef(null); const mainRef = useRef(null);
const pagesRef = useRef(null); const pagesRef = useRef(null);
if(props.renderer == 'legacy') { if(props.renderer == 'legacy') {
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY); rawPages = props.text.split('\\page');
} else { } else {
rawPages = props.text.split(PAGEBREAK_REGEX_V3); rawPages = props.text.split(PAGEBREAK_REGEX_V3);
} }
@@ -191,27 +180,23 @@ const BrewRenderer = (props)=>{
let attributes = {}; let attributes = {};
if(props.renderer == 'legacy') { if(props.renderer == 'legacy') {
pageText.replace(COLUMNBREAK_REGEX_LEGACY, '```\n````\n'); // Allow Legacy brews to use `\column(break)`
const html = MarkdownLegacy.render(pageText); const html = MarkdownLegacy.render(pageText);
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />; return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
} else { } else {
if(pageText.startsWith('\\page')) { if(pageText.startsWith('\\page')) {
const firstLineTokens = Markdown.marked.lexer(pageText.split('\n', 1)[0])[0].tokens; 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) { if(injectedTags) {
styles = { ...styles, ...injectedTags.styles }; 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(); classes = [classes, injectedTags.classes].join(' ').trim();
attributes = injectedTags.attributes; attributes = injectedTags.attributes;
} }
pageText = pageText.includes('\n') ? pageText.substring(pageText.indexOf('\n') + 1) : ''; // Remove the \page line 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. let html = Markdown.render(pageText, index);
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} />; return <BrewPage className={classes} index={index} key={index} contents={html} style={styles} attributes={attributes} onVisibilityChange={handlePageVisibilityChange} />;
} }
@@ -286,7 +271,6 @@ const BrewRenderer = (props)=>{
const handleDisplayOptionsChange = (newDisplayOptions)=>{ const handleDisplayOptionsChange = (newDisplayOptions)=>{
setDisplayOptions(newDisplayOptions); setDisplayOptions(newDisplayOptions);
localStorage.setItem(TOOLBAR_STATE_KEY, JSON.stringify(newDisplayOptions));
}; };
const pagesStyle = { const pagesStyle = {
@@ -295,6 +279,12 @@ const BrewRenderer = (props)=>{
rowGap : `${displayOptions.rowGap}px` 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]); const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
renderedPages = useMemo(()=>renderPages(), [props.text, displayOptions]); renderedPages = useMemo(()=>renderPages(), [props.text, displayOptions]);
@@ -323,9 +313,10 @@ const BrewRenderer = (props)=>{
contentDidMount={frameDidMount} contentDidMount={frameDidMount}
onClick={()=>{emitClick();}} onClick={()=>{emitClick();}}
> >
<div className='brewRenderer' <div className={`brewRenderer ${global.config.deployment && 'deployment'}`}
onKeyDown={handleControlKeys} onKeyDown={handleControlKeys}
tabIndex={-1} tabIndex={-1}
style={ styleObject }
> >
{/* Apply CSS from Style tab and render pages from Markdown tab */} {/* Apply CSS from Style tab and render pages from Markdown tab */}

View File

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

View File

@@ -25,7 +25,7 @@ const HeaderNav = React.forwardRef(({}, pagesRef)=>{
'.toc' : ()=>{ return 'Table of Contents'; }, '.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(','); 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) 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' text : el.textContent, // Use `textContent` because `innerText` is affected by rendering, e.g. 'content-visibility: auto'
link : el.id link : el.id
}; }
if(el.classList.contains('page')) { if(el.classList.contains('page')) {
let text = `Page ${el.id.slice(1)}`; // Get the page # by trimming off the 'p' from the ID 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)); const pageType = Object.keys(topLevelPages).find(pageType => el.querySelector(pageType));
if(pageType) if (pageType)
text += ` - ${topLevelPages[pageType](el, pageType)}`; // If a Top Level Page, add extra label 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.depth = 0; // Pages are always at the least indented level
navEntry.text = text; navEntry.text = text;
navEntry.className = 'pageLink'; 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 navEntry.depth = el.localName[1]; // Depth is set by the header level
} }
navList.push(navEntry); 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 { .headerNav {
position : fixed; position: fixed;
top : 32px; top: 32px;
left : 0px; left: 0px;
max-width : 40vw; padding: 5px 10px;
max-height : calc(100vh - 32px); background-color: #ccc;
padding : 5px 10px; border-radius: 5px;
overflow-y : auto; max-height: calc(100vh - 32px);
background-color : #CCCCCC; max-width: 40vw;
border-radius : 5px; overflow-y: auto;
&.active { &.active {
padding-bottom : 10px; padding-bottom: 10px;
.navIcon { padding-bottom : 10px; } .navIcon {
padding-bottom: 10px;
}
}
.navIcon {
cursor: pointer;
} }
.navIcon { cursor : pointer; }
li { li {
list-style-type : none; list-style-type: none;
a { a {
display : inline-block; display: inline-block;
width : 100%; width: 100%;
padding : 2px; font-family: 'Open Sans';
font-family : 'Open Sans'; font-size: 12px;
font-size : 12px; padding: 2px;
color : inherit; color: inherit;
text-decoration : none; text-decoration: none;
cursor : pointer; cursor: pointer;
&:hover { text-decoration : underline; } &:hover {
&.pageLink { font-weight : 900; } text-decoration: underline;
}
&.pageLink {
font-weight: 900;
}
@depths: 0,1,2,3,4,5,6,7; @depths: 0,1,2,3,4,5,6,7;

View File

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

View File

@@ -9,8 +9,6 @@ import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anch
const MAX_ZOOM = 300; const MAX_ZOOM = 300;
const MIN_ZOOM = 10; const MIN_ZOOM = 10;
const TOOLBAR_VISIBILITY = 'HB_renderer_toolbarVisibility';
const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages, headerState, setHeaderState })=>{ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages, headerState, setHeaderState })=>{
const [pageNum, setPageNum] = useState(1); const [pageNum, setPageNum] = useState(1);
@@ -22,12 +20,6 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
setPageNum(pageRange); setPageNum(pageRange);
}, [visiblePages]); }, [visiblePages]);
useEffect(()=>{
const Visibility = localStorage.getItem(TOOLBAR_VISIBILITY);
if(Visibility) setToolsVisible(Visibility === 'true');
}, []);
const handleZoomButton = (zoom)=>{ const handleZoomButton = (zoom)=>{
handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM))); handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
}; };
@@ -63,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. // 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; const widestPage = _.maxBy([...pages], 'offsetWidth').offsetWidth;
if(displayOptions.spread === 'facing') desiredZoom = (iframeWidth / widestPage) * 100;
desiredZoom = (iframeWidth / ((widestPage * 2) + parseInt(displayOptions.columnGap))) * 100;
else
desiredZoom = (iframeWidth / (widestPage + 20)) * 100;
} else if(mode == 'fit'){ } 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; let minDimRatio;
if(displayOptions.spread === 'single') // find the page with the largest single dim (height or width) so that zoom can be adapted to fit it.
minDimRatio = [...pages].reduce( if(displayOptions.spread === 'facing')
(minRatio, page)=>Math.min(minRatio, minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth / 2), Infinity); // if 'facing' spread, fit two pages in view
iframeWidth / page.offsetWidth,
iframeHeight / page.offsetHeight
),
Infinity
);
else else
minDimRatio = [...pages].reduce( minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity);
(minRatio, page)=>Math.min(minRatio,
iframeWidth / ((page.offsetWidth * 2) + parseInt(displayOptions.columnGap)),
iframeHeight / page.offsetHeight
),
Infinity
);
desiredZoom = minDimRatio * 100; desiredZoom = minDimRatio * 100;
} }
@@ -100,10 +77,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
return ( return (
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'> <div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
<div className='toggleButton'> <div className='toggleButton'>
<button title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{ <button title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{setToolsVisible(!toolsVisible);}}><i className='fas fa-glasses' /></button>
setToolsVisible(!toolsVisible);
localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible);
}}><i className='fas fa-glasses' /></button>
<button title={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button> <button title={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button>
</div> </div>
{/*v=====----------------------< Zoom Controls >---------------------=====v*/} {/*v=====----------------------< Zoom Controls >---------------------=====v*/}
@@ -168,7 +142,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
id='single-spread' id='single-spread'
className='tool' className='tool'
title='Single Page' title='Single Page'
onClick={()=>{handleOptionChange('spread', 'single');}} onClick={()=>{handleOptionChange('spread', 'active');}}
aria-checked={displayOptions.spread === 'single'} aria-checked={displayOptions.spread === 'single'}
><i className='fac single-spread' /></button> ><i className='fac single-spread' /></button>
<button role='radio' <button role='radio'
@@ -193,11 +167,11 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
<h1>Options</h1> <h1>Options</h1>
<label title='Modify the horizontal space between pages.'> <label title='Modify the horizontal space between pages.'>
Column gap 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>
<label title='Modify the vertical space between rows of pages.'> <label title='Modify the vertical space between rows of pages.'>
Row gap 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>
<label title='Start 1st page on the right side, such as if you have cover page.'> <label title='Start 1st page on the right side, such as if you have cover page.'>
Start on right Start on right

View File

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

View File

@@ -10,10 +10,10 @@ const CodeEditor = require('naturalcrit/codeEditor/codeEditor.jsx');
const SnippetBar = require('./snippetbar/snippetbar.jsx'); const SnippetBar = require('./snippetbar/snippetbar.jsx');
const MetadataEditor = require('./metadataEditor/metadataEditor.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 PAGEBREAK_REGEX_V3 = /^(?=\\page(?: *{[^\n{}]*})?$)/m;
const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/; const SNIPPETBAR_HEIGHT = 25;
const DEFAULT_STYLE_TEXT = dedent` const DEFAULT_STYLE_TEXT = dedent`
/*=======--- Example CSS styling ---=======*/ /*=======--- Example CSS styling ---=======*/
/* Any CSS here will apply to your document! */ /* Any CSS here will apply to your document! */
@@ -22,13 +22,6 @@ const DEFAULT_STYLE_TEXT = dedent`
color: black; 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; let isJumping = false;
const Editor = createClass({ const Editor = createClass({
@@ -40,8 +33,10 @@ const Editor = createClass({
style : '' style : ''
}, },
onBrewChange : ()=>{}, onTextChange : ()=>{},
reportError : ()=>{}, onStyleChange : ()=>{},
onMetaChange : ()=>{},
reportError : ()=>{},
onCursorPageChange : ()=>{}, onCursorPageChange : ()=>{},
onViewPageChange : ()=>{}, onViewPageChange : ()=>{},
@@ -56,9 +51,8 @@ const Editor = createClass({
}, },
getInitialState : function() { getInitialState : function() {
return { return {
editorTheme : this.props.editorTheme, editorTheme : this.props.editorTheme,
view : 'text', //'text', 'style', 'meta', 'snippet' view : 'text' //'text', 'style', 'meta'
snippetbarHeight : 25
}; };
}, },
@@ -68,11 +62,12 @@ const Editor = createClass({
isText : function() {return this.state.view == 'text';}, isText : function() {return this.state.view == 'text';},
isStyle : function() {return this.state.view == 'style';}, isStyle : function() {return this.state.view == 'style';},
isMeta : function() {return this.state.view == 'meta';}, isMeta : function() {return this.state.view == 'meta';},
isSnip : function() {return this.state.view == 'snippet';},
componentDidMount : function() { componentDidMount : function() {
this.updateEditorSize();
this.highlightCustomMarkdown(); this.highlightCustomMarkdown();
window.addEventListener('resize', this.updateEditorSize);
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys); document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys);
document.addEventListener('keydown', this.handleControlKeys); document.addEventListener('keydown', this.handleControlKeys);
@@ -85,7 +80,10 @@ const Editor = createClass({
editorTheme : editorTheme editorTheme : editorTheme
}); });
} }
this.setState({ snippetbarHeight: document.querySelector('.editor > .snippetBar').offsetHeight }); },
componentWillUnmount : function() {
window.removeEventListener('resize', this.updateEditorSize);
}, },
componentDidUpdate : function(prevProps, prevState, snapshot) { componentDidUpdate : function(prevProps, prevState, snapshot) {
@@ -120,6 +118,14 @@ const Editor = createClass({
} }
}, },
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) { updateCurrentCursorPage : function(cursor) {
const lines = this.props.brew.text.split('\n').slice(1, cursor.line + 1); const lines = this.props.brew.text.split('\n').slice(1, cursor.line + 1);
const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/; const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
@@ -140,17 +146,17 @@ const Editor = createClass({
handleViewChange : function(newView){ handleViewChange : function(newView){
this.props.setMoveArrows(newView === 'text'); this.props.setMoveArrows(newView === 'text');
this.setState({ this.setState({
view : newView view : newView
}, ()=>{ }, ()=>{
this.codeEditor.current?.codeMirror.focus(); this.codeEditor.current?.codeMirror.focus();
}); this.updateEditorSize();
}); //TODO: not sure if updateeditorsize needed
}, },
highlightCustomMarkdown : function(){ highlightCustomMarkdown : function(){
if(!this.codeEditor.current) return; if(!this.codeEditor.current) return;
if((this.state.view === 'text') ||(this.state.view === 'snippet')) { if(this.state.view === 'text') {
const codeMirror = this.codeEditor.current.codeMirror; const codeMirror = this.codeEditor.current.codeMirror;
codeMirror.operation(()=>{ // Batch CodeMirror styling codeMirror.operation(()=>{ // Batch CodeMirror styling
@@ -169,18 +175,12 @@ const Editor = createClass({
for (let i=customHighlights.length - 1;i>=0;i--) customHighlights[i].clear(); 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 let editorPageCount = 1; // start page count from page 1
const whichSource = this.state.view === 'text' ? this.props.brew.text : this.props.brew.snippets; _.forEach(this.props.brew.text.split('\n'), (line, lineNumber)=>{
_.forEach(whichSource?.split('\n'), (line, lineNumber)=>{
const tabHighlight = this.state.view === 'text' ? 'pageLine' : 'snippetLine';
const textOrSnip = this.state.view === 'text';
//reset custom line styles //reset custom line styles
codeMirror.removeLineClass(lineNumber, 'background', 'pageLine'); codeMirror.removeLineClass(lineNumber, 'background', 'pageLine');
codeMirror.removeLineClass(lineNumber, 'background', 'snippetLine');
codeMirror.removeLineClass(lineNumber, 'text'); codeMirror.removeLineClass(lineNumber, 'text');
codeMirror.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash'); codeMirror.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash');
@@ -191,25 +191,23 @@ const Editor = createClass({
// Styling for \page breaks // Styling for \page breaks
if((this.props.renderer == 'legacy' && line.includes('\\page')) || 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 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' // 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'), { const pageCountElement = Object.assign(document.createElement('span'), {
className : 'editor-page-count', 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 // New Codemirror styling for V3 renderer
if(this.props.renderer === 'V3') { if(this.props.renderer == 'V3') {
if(line.match(/^\\column(?:break)?$/)){ if(line.match(/^\\column$/)){
codeMirror.addLineClass(lineNumber, 'text', 'columnSplit'); codeMirror.addLineClass(lineNumber, 'text', 'columnSplit');
} }
@@ -410,9 +408,6 @@ const Editor = createClass({
//Called when there are changes to the editor's dimensions //Called when there are changes to the editor's dimensions
update : function(){ update : function(){
this.codeEditor.current?.updateSize(); this.codeEditor.current?.updateSize();
const snipHeight = document.querySelector('.editor > .snippetBar').offsetHeight;
if(snipHeight !== this.state.snippetbarHeight)
this.setState({ snippetbarHeight: snipHeight });
}, },
updateEditorTheme : function(newTheme){ updateEditorTheme : function(newTheme){
@@ -435,10 +430,9 @@ const Editor = createClass({
language='gfm' language='gfm'
view={this.state.view} view={this.state.view}
value={this.props.brew.text} value={this.props.brew.text}
onChange={this.props.onBrewChange('text')} onChange={this.props.onTextChange}
editorTheme={this.state.editorTheme} editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent} rerenderParent={this.rerenderParent} />
style={{ height: `calc(100% - ${this.state.snippetbarHeight}px)` }} />
</>; </>;
} }
if(this.isStyle()){ if(this.isStyle()){
@@ -448,11 +442,10 @@ const Editor = createClass({
language='css' language='css'
view={this.state.view} view={this.state.view}
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT} value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
onChange={this.props.onBrewChange('style')} onChange={this.props.onStyleChange}
enableFolding={true} enableFolding={true}
editorTheme={this.state.editorTheme} editorTheme={this.state.editorTheme}
rerenderParent={this.rerenderParent} rerenderParent={this.rerenderParent} />
style={{ height: `calc(100% - ${this.state.snippetbarHeight}px)` }} />
</>; </>;
} }
if(this.isMeta()){ if(this.isMeta()){
@@ -464,27 +457,11 @@ const Editor = createClass({
<MetadataEditor <MetadataEditor
metadata={this.props.brew} metadata={this.props.brew}
themeBundle={this.props.themeBundle} themeBundle={this.props.themeBundle}
onChange={this.props.onBrewChange('metadata')} onChange={this.props.onMetaChange}
reportError={this.props.reportError} reportError={this.props.reportError}
userThemes={this.props.userThemes}/> 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(){ redo : function(){
@@ -525,7 +502,7 @@ const Editor = createClass({
historySize={this.historySize()} historySize={this.historySize()}
currentEditorTheme={this.state.editorTheme} currentEditorTheme={this.state.editorTheme}
updateEditorTheme={this.updateEditorTheme} updateEditorTheme={this.updateEditorTheme}
themeBundle={this.props.themeBundle} snippetBundle={this.props.snippetBundle}
cursorPos={this.codeEditor.current?.getCursorPosition() || {}} cursorPos={this.codeEditor.current?.getCursorPosition() || {}}
updateBrew={this.props.updateBrew} updateBrew={this.props.updateBrew}
/> />

View File

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

View File

@@ -4,6 +4,7 @@ const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const _ = require('lodash'); const _ = require('lodash');
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
const Nav = require('naturalcrit/nav/nav.jsx');
const Combobox = require('client/components/combobox.jsx'); const Combobox = require('client/components/combobox.jsx');
const TagInput = require('../tagInput/tagInput.jsx'); const TagInput = require('../tagInput/tagInput.jsx');
@@ -47,7 +48,7 @@ const MetadataEditor = createClass({
getInitialState : function(){ getInitialState : function(){
return { return {
showThumbnail : true showThumbnail : true
}; };
}, },
@@ -67,7 +68,7 @@ const MetadataEditor = createClass({
const inputRules = validations[name] ?? []; const inputRules = validations[name] ?? [];
const validationErr = inputRules.map((rule)=>rule(e.target.value)).filter(Boolean); 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, 'setCustomValidity', errMessage);
callIfExists(target, 'reportValidity'); callIfExists(target, 'reportValidity');
}, 300); // 300ms debounce delay, adjust as needed }, 300); // 300ms debounce delay, adjust as needed
@@ -86,7 +87,7 @@ const MetadataEditor = createClass({
return `- ${err}`; return `- ${err}`;
}).join('\n'); }).join('\n');
debouncedReportValidity(e.target, errMessage); debouncedReportValidity(e.target, errMessage);
return false; return false;
} }
@@ -109,7 +110,6 @@ const MetadataEditor = createClass({
} }
this.props.onChange(this.props.metadata, 'renderer'); this.props.onChange(this.props.metadata, 'renderer');
}, },
handlePublish : function(val){ handlePublish : function(val){
this.props.onChange({ this.props.onChange({
...this.props.metadata, ...this.props.metadata,

View File

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

View File

@@ -28,18 +28,18 @@ module.exports = {
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; 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 : [ theme: [
(value)=>{ (value) => {
const URL = global.config.baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); //Escape any regex characters const URL = global.config.baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); //Escape any regex characters
const shareIDPattern = '[a-zA-Z0-9-_]{12}'; const shareIDPattern = '[a-zA-Z0-9-_]{12}';
const shareURLRegex = new RegExp(`^${URL}\\/share\\/${shareIDPattern}$`); const shareURLRegex = new RegExp(`^${URL}\\/share\\/${shareIDPattern}$`);
const shareIDRegex = new RegExp(`^${shareIDPattern}$`); const shareIDRegex = new RegExp(`^${shareIDPattern}$`);
if(value?.length === 0) return null; if (value?.length === 0) return null;
if(shareURLRegex.test(value)) return null; if (shareURLRegex.test(value)) return null;
if(shareIDRegex.test(value)) return null; if (shareIDRegex.test(value)) return null;
return 'Must be a valid Share URL or a 12-character ID.'; return 'Must be a valid Share URL or a 12-character ID.';
} }
] ]
}; };

View File

@@ -6,7 +6,6 @@ const _ = require('lodash');
const cx = require('classnames'); const cx = require('classnames');
import { loadHistory } from '../../utils/versionHistory.js'; import { loadHistory } from '../../utils/versionHistory.js';
import { brewSnippetsToJSON } from '../../../../shared/helpers.js';
//Import all themes //Import all themes
const ThemeSnippets = {}; const ThemeSnippets = {};
@@ -41,7 +40,7 @@ const Snippetbar = createClass({
unfoldCode : ()=>{}, unfoldCode : ()=>{},
updateEditorTheme : ()=>{}, updateEditorTheme : ()=>{},
cursorPos : {}, cursorPos : {},
themeBundle : [], snippetBundle : [],
updateBrew : ()=>{} updateBrew : ()=>{}
}; };
}, },
@@ -65,10 +64,7 @@ const Snippetbar = createClass({
}, },
componentDidUpdate : async function(prevProps, prevState) { componentDidUpdate : async function(prevProps, prevState) {
if(prevProps.renderer != this.props.renderer || if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
prevProps.theme != this.props.theme ||
prevProps.themeBundle != this.props.themeBundle ||
prevProps.brew.snippets != this.props.brew.snippets) {
this.setState({ this.setState({
snippets : this.compileSnippets() snippets : this.compileSnippets()
}); });
@@ -101,7 +97,7 @@ const Snippetbar = createClass({
if(key == 'snippets') { 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 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); return result.filter((snip)=>snip.gen || snip.subsnippets);
}; }
}, },
compileSnippets : function() { compileSnippets : function() {
@@ -109,21 +105,15 @@ const Snippetbar = createClass({
let oldSnippets = _.keyBy(compiledSnippets, 'groupName'); let oldSnippets = _.keyBy(compiledSnippets, 'groupName');
if(this.props.themeBundle.snippets) { for (let snippets of this.props.snippetBundle) {
for (let snippets of this.props.themeBundle.snippets) { if(typeof(snippets) == 'string') // load staticThemes as needed; they were sent as just a file name
if(typeof(snippets) == 'string') // load staticThemes as needed; they were sent as just a file name snippets = ThemeSnippets[snippets];
snippets = ThemeSnippets[snippets];
const newSnippets = _.keyBy(_.cloneDeep(snippets), 'groupName'); const newSnippets = _.keyBy(_.cloneDeep(snippets), 'groupName');
compiledSnippets = _.values(_.mergeWith(oldSnippets, newSnippets, this.mergeCustomizer)); 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; return compiledSnippets;
}, },
@@ -217,60 +207,59 @@ const Snippetbar = createClass({
renderEditorButtons : function(){ renderEditorButtons : function(){
if(!this.props.showEditButtons) return; if(!this.props.showEditButtons) return;
return ( return (
<div className='editors'> <div className='editors'>
{this.props.view !== 'meta' && <><div className='historyTools'> {this.props.view !== 'meta' && <><div className='historyTools'>
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`} <div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
onClick={this.toggleHistoryMenu} > onClick={this.toggleHistoryMenu} >
<i className='fas fa-clock-rotate-left' /> <i className='fas fa-clock-rotate-left' />
{ this.state.showHistory && this.renderHistoryItems() } { this.state.showHistory && this.renderHistoryItems() }
</div>
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
onClick={this.props.undo} >
<i className='fas fa-undo' />
</div>
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
onClick={this.props.redo} >
<i className='fas fa-redo' />
</div>
</div> </div>
<div className='codeTools'> <div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`} onClick={this.props.undo} >
onClick={this.props.foldCode} > <i className='fas fa-undo' />
<i className='fas fa-compress-alt' /> </div>
</div> <div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`} onClick={this.props.redo} >
onClick={this.props.unfoldCode} > <i className='fas fa-redo' />
<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> </div>
</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(){ render : function(){
@@ -283,6 +272,11 @@ const Snippetbar = createClass({
module.exports = Snippetbar; module.exports = Snippetbar;
const SnippetGroup = createClass({ const SnippetGroup = createClass({
displayName : 'SnippetGroup', displayName : 'SnippetGroup',
getDefaultProps : function() { getDefaultProps : function() {
@@ -316,8 +310,7 @@ const SnippetGroup = createClass({
}, },
render : function(){ render : function(){
const snippetGroup = `snippetGroup snippetBarButton ${this.props.snippets.length === 0 ? 'disabledSnippets' : ''}`; return <div className='snippetGroup snippetBarButton'>
return <div className={snippetGroup}>
<div className='text'> <div className='text'>
<i className={this.props.icon} /> <i className={this.props.icon} />
<span className='groupName'>{this.props.groupName}</span> <span className='groupName'>{this.props.groupName}</span>

View File

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

View File

@@ -3,43 +3,43 @@ const React = require('react');
const { useState, useEffect } = React; const { useState, useEffect } = React;
const _ = require('lodash'); const _ = require('lodash');
const TagInput = ({ unique = true, values = [], ...props })=>{ const TagInput = ({ unique = true, values = [], ...props }) => {
const [tempInputText, setTempInputText] = useState(''); 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(()=>{ useEffect(()=>{
handleChange(tagList.map((context)=>context.value)); handleChange(tagList.map((context)=>context.value))
}, [tagList]); }, [tagList])
const handleChange = (value)=>{ const handleChange = (value)=>{
props.onChange({ props.onChange({
target : { value } target : { value }
}); })
}; };
const handleInputKeyDown = ({ evt, value, index, options = {} })=>{ const handleInputKeyDown = ({ evt, value, index, options = {} }) => {
if(_.includes(['Enter', ','], evt.key)) { if (_.includes(['Enter', ','], evt.key)) {
evt.preventDefault(); evt.preventDefault();
submitTag(evt.target.value, value, index); submitTag(evt.target.value, value, index);
if(options.clear) { if (options.clear) {
setTempInputText(''); setTempInputText('');
} }
} }
}; };
const submitTag = (newValue, originalValue, index)=>{ const submitTag = (newValue, originalValue, index) => {
setTagList((prevContext)=>{ setTagList((prevContext) => {
// remove existing tag // remove existing tag
if(newValue === null){ if(newValue === null){
return [...prevContext].filter((context, i)=>i !== index); return [...prevContext].filter((context, i)=>i !== index);
} }
// add new tag // add new tag
if(originalValue === null){ if(originalValue === null){
return [...prevContext, { value: newValue, editing: false }]; return [...prevContext, { value: newValue, editing: false }]
} }
// update existing tag // update existing tag
return prevContext.map((context, i)=>{ return prevContext.map((context, i) => {
if(i === index) { if (i === index) {
return { ...context, value: newValue, editing: false }; return { ...context, value: newValue, editing: false };
} }
return context; return context;
@@ -47,10 +47,10 @@ const TagInput = ({ unique = true, values = [], ...props })=>{
}); });
}; };
const editTag = (index)=>{ const editTag = (index) => {
setTagList((prevContext)=>{ setTagList((prevContext) => {
return prevContext.map((context, i)=>{ return prevContext.map((context, i) => {
if(i === index) { if (i === index) {
return { ...context, editing: true }; return { ...context, editing: true };
} }
return { ...context, editing: false }; return { ...context, editing: false };
@@ -58,25 +58,25 @@ const TagInput = ({ unique = true, values = [], ...props })=>{
}); });
}; };
const renderReadTag = (context, index)=>{ const renderReadTag = (context, index) => {
return ( return (
<li key={index} <li key={index}
data-value={context.value} data-value={context.value}
className='tag' className='tag'
onClick={()=>editTag(index)}> onClick={() => editTag(index)}>
{context.value} {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> </li>
); );
}; };
const renderWriteTag = (context, index)=>{ const renderWriteTag = (context, index) => {
return ( return (
<input type='text' <input type='text'
key={index} key={index}
defaultValue={context.value} defaultValue={context.value}
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: context.value, index: index })} onKeyDown={(evt) => handleInputKeyDown({evt, value: context.value, index: index})}
autoFocus autoFocus
/> />
); );
}; };
@@ -86,7 +86,7 @@ const TagInput = ({ unique = true, values = [], ...props })=>{
<label>{props.label}</label> <label>{props.label}</label>
<div className='value'> <div className='value'>
<ul className='list'> <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> </ul>
<input <input
@@ -94,8 +94,8 @@ const TagInput = ({ unique = true, values = [], ...props })=>{
className='value' className='value'
placeholder={props.placeholder} placeholder={props.placeholder}
value={tempInputText} value={tempInputText}
onChange={(e)=>setTempInputText(e.target.value)} onChange={(e) => setTempInputText(e.target.value)}
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: null, options: { clear: true } })} onKeyDown={(evt) => handleInputKeyDown({ evt, value: null, options: { clear: true } })}
/> />
</div> </div>
</div> </div>

View File

@@ -1,87 +1,95 @@
/* eslint-disable camelcase */ //╔===--------------- Polyfills --------------===╗//
import 'core-js/es/string/to-well-formed.js'; //Polyfill for older browsers import 'core-js/es/string/to-well-formed.js';
import './homebrew.less'; //╚===--------------- ---------------===╝//
import React from 'react';
import { StaticRouter as Router, Route, Routes, useParams, useSearchParams } from 'react-router';
import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js'; 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 HomePage from './pages/homePage/homePage.jsx'; const HomePage = require('./pages/homePage/homePage.jsx');
import EditPage from './pages/editPage/editPage.jsx'; const EditPage = require('./pages/editPage/editPage.jsx');
import UserPage from './pages/userPage/userPage.jsx'; const UserPage = require('./pages/userPage/userPage.jsx');
import SharePage from './pages/sharePage/sharePage.jsx'; const SharePage = require('./pages/sharePage/sharePage.jsx');
import NewPage from './pages/newPage/newPage.jsx'; const NewPage = require('./pages/newPage/newPage.jsx');
import ErrorPage from './pages/errorPage/errorPage.jsx'; const ErrorPage = require('./pages/errorPage/errorPage.jsx');
import VaultPage from './pages/vaultPage/vaultPage.jsx'; const VaultPage = require('./pages/vaultPage/vaultPage.jsx');
import AccountPage from './pages/accountPage/accountPage.jsx'; const AccountPage = require('./pages/accountPage/accountPage.jsx');
const WithRoute = ({ el: Element, ...rest })=>{ const WithRoute = (props)=>{
const params = useParams(); const params = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const queryParams = Object.fromEntries(searchParams?.entries() || []); const queryParams = {};
return <Element {...rest} {...params} query={queryParams} />; for (const [key, value] of searchParams?.entries() || []) {
}; queryParams[key] = value;
}
const Homebrew = (props)=>{ const Element = props.el;
const { const allProps = {
url = '', ...props,
version = '0.0.0', ...params,
account = null, query : queryParams,
enable_v3 = false, el : undefined
enable_themes,
config,
brew = {
title : '',
text : '',
shareId : null,
editId : null,
createdAt : null,
updatedAt : null,
lang : ''
},
userThemes,
brews
} = props;
global.account = account;
global.version = version;
global.enable_v3 = enable_v3;
global.enable_themes = enable_themes;
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;
}; };
updateLocalStorage(); return <Element {...allProps} />;
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>
);
}; };
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; module.exports = Homebrew;

View File

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

View File

@@ -1,138 +1,144 @@
require('./error-navitem.less'); require('./error-navitem.less');
const React = require('react'); const React = require('react');
const Nav = require('naturalcrit/nav/nav.jsx'); const Nav = require('naturalcrit/nav/nav.jsx');
const createClass = require('create-react-class');
const ErrorNavItem = ({error = '', clearError})=>{ const ErrorNavItem = createClass({
const response = error.response; getDefaultProps : function() {
const errorCode = error.code return {
const status = response?.status; error : '',
const HBErrorCode = response?.body?.HBErrorCode; parent : null
const message = response?.body?.message; };
},
render : function() {
const clearError = ()=>{
const state = {
error : null
};
if(this.props.parent.state.isSaving) {
state.isSaving = false;
}
this.props.parent.setState(state);
};
let errMsg = ''; const error = this.props.error;
try { const response = error.response;
errMsg += `${error.toString()}\n\n`; const status = response.status;
errMsg += `\`\`\`\n${error.stack}\n`; const HBErrorCode = response.body?.HBErrorCode;
errMsg += `${JSON.stringify(response?.error, null, ' ')}\n\`\`\``; const message = response.body?.message;
console.log(errMsg); let errMsg = '';
} catch (e){} 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) { if(status === 409) {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'> return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops! Oops!
<div className='errorContainer' onClick={clearError}> <div className='errorContainer' onClick={clearError}>
{message ?? 'Conflict: please refresh to get latest changes'} {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
</div> </div>
</div> </Nav.item>;
</Nav.item>; }
}
if(response?.body?.errors?.[0].reason == 'storageQuotaExceeded') { if(status === 412) {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'> return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops! Oops!
<div className='errorContainer' onClick={clearError}> <div className='errorContainer' onClick={clearError}>
Can't save because your Google Drive seems to be full! {message ?? 'Your client is out of date. Please save your changes elsewhere and refresh.'}
</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>
</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'> return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops! Oops!
<div className='errorContainer' onClick={clearError}> <div className='errorContainer'>
Looks like there was a problem retreiving Looks like there was a problem saving. <br />
the theme, or a theme that it inherits, 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)}`}>
for this brew. Verify that brew <a className='lowercase' target='_blank' rel='noopener noreferrer' href={`/share/${response.body.brewId}`}> here
{response.body.brewId}</a> still exists! </a>.
</div> </div>
</Nav.item>; </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(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>;
};
module.exports = ErrorNavItem; module.exports = ErrorNavItem;

View File

@@ -1,70 +1,78 @@
.navItem.error { .navItem.error {
position : relative; position : relative;
background-color : @red; background-color : @red;
} }
.errorContainer { .errorContainer{
position : absolute; animation-name: glideDown;
top : 100%; animation-duration: 0.4s;
left : 50%; position : absolute;
z-index : 1000; top : 100%;
width : 140px; left : 50%;
padding : 3px; z-index : 1000;
font-size : 10px; width : 140px;
font-weight : 800; padding : 3px;
color : white; color : white;
text-align : center; background-color : #333;
text-transform : uppercase; border : 3px solid #444;
background-color : #333333; border-radius : 5px;
border : 3px solid #444444; transform : translate(-50% + 3px, 10px);
border-radius : 5px; text-align : center;
transform : translate(-50% + 3px, 10px); font-size : 10px;
animation-name : glideDown; font-weight : 800;
animation-duration : 0.4s; text-transform : uppercase;
.lowercase { text-transform : none; } .lowercase {
a { color : @teal; } text-transform : none;
&::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; }
} }
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

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

View File

@@ -5,45 +5,33 @@ const { splitTextStyleAndMetadata } = require('../../../shared/helpers.js'); //
const BREWKEY = 'homebrewery-new'; const BREWKEY = 'homebrewery-new';
const STYLEKEY = 'homebrewery-new-style'; const STYLEKEY = 'homebrewery-new-style';
const METAKEY = 'homebrewery-new-meta'; const METAKEY = 'homebrewery-new-meta';
const NewBrew = ()=>{ const NewBrew = ()=>{
const handleFileChange = (e)=>{ const handleFileChange = (e)=>{
const file = e.target.files[0]; const file = e.target.files[0];
if(!file) return; if(file) {
const reader = new FileReader();
const currentNew = localStorage.getItem(BREWKEY); reader.onload = (e)=>{
if(currentNew && !confirm( const fileContent = e.target.result;
`You have some text in the new brew space, if you load a file that text will be lost, are you sure you want to load the file?` const newBrew = {
)) return; text : fileContent,
style : ''
const reader = new FileReader(); };
reader.onload = (e)=>{ if(fileContent.startsWith('```metadata')) {
const fileContent = e.target.result; splitTextStyleAndMetadata(newBrew); // Modify newBrew directly
const newBrew = { text: fileContent, style: '' }; localStorage.setItem(BREWKEY, newBrew.text);
localStorage.setItem(STYLEKEY, newBrew.style);
if(fileContent.startsWith('```metadata')) { localStorage.setItem(METAKEY, JSON.stringify(_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])));
splitTextStyleAndMetadata(newBrew); window.location.href = '/new';
localStorage.setItem(BREWKEY, newBrew.text); } else {
localStorage.setItem(STYLEKEY, newBrew.style); alert('This file is invalid, please, enter a valid file');
localStorage.setItem(METAKEY, JSON.stringify( }
_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang']) };
)); reader.readAsText(file);
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);
}; };
return ( return (
<Nav.dropdown> <Nav.dropdown>
<Nav.item <Nav.item

View File

@@ -5,8 +5,8 @@ const Moment = require('moment');
const Nav = require('naturalcrit/nav/nav.jsx'); const Nav = require('naturalcrit/nav/nav.jsx');
const EDIT_KEY = 'HB_nav_recentlyEdited'; const EDIT_KEY = 'homebrewery-recently-edited';
const VIEW_KEY = 'HB_nav_recentlyViewed'; const VIEW_KEY = 'homebrewery-recently-viewed';
const RecentItems = createClass({ const RecentItems = createClass({

View File

@@ -1,35 +0,0 @@
import React from 'react';
import dedent from 'dedent-tabs';
import Nav from 'naturalcrit/nav/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

@@ -13,7 +13,7 @@ const AccountPage = (props)=>{
// initialize save location from local storage based on user id // initialize save location from local storage based on user id
React.useEffect(()=>{ React.useEffect(()=>{
if(!saveLocation && accountDetails.username) { 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. // if no SAVEKEY in local storage, default save location to Google Drive if user has Google account.
let saveLocation = window.localStorage.getItem(SAVEKEY); let saveLocation = window.localStorage.getItem(SAVEKEY);
saveLocation = saveLocation ?? (accountDetails.googleId ? 'GOOGLE-DRIVE' : 'HOMEBREWERY'); saveLocation = saveLocation ?? (accountDetails.googleId ? 'GOOGLE-DRIVE' : 'HOMEBREWERY');

View File

@@ -5,7 +5,7 @@ const moment = require('moment');
import request from '../../../../utils/request-middleware.js'; import request from '../../../../utils/request-middleware.js';
const googleDriveIcon = require('../../../../googleDrive.svg'); const googleDriveIcon = require('../../../../googleDrive.svg');
const homebreweryIcon = require('../../../../thumbnail.svg'); const homebreweryIcon = require('../../../../thumbnail.png');
const dedent = require('dedent-tabs').default; const dedent = require('dedent-tabs').default;
const BrewItem = ({ const BrewItem = ({
@@ -30,11 +30,11 @@ const BrewItem = ({
} }
request.delete(`/api/${brew.googleId ?? ''}${brew.editId}`).send().end((err, res)=>{ 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]); }, [brew, reportError]);
const updateFilter = useCallback((type, term)=>updateListFilter(type, term), [updateListFilter]); const updateFilter = useCallback((type, term)=> updateListFilter(type, term), [updateListFilter]);
const renderDeleteBrewLink = ()=>{ const renderDeleteBrewLink = ()=>{
if(!brew.editId) return null; if(!brew.editId) return null;

View File

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

View File

@@ -7,9 +7,7 @@ const moment = require('moment');
const BrewItem = require('./brewItem/brewItem.jsx'); const BrewItem = require('./brewItem/brewItem.jsx');
const USERPAGE_SORT_DIR = 'HB_listPage_sortDir'; const USERPAGE_KEY_PREFIX = 'HOMEBREWERY-LISTPAGE';
const USERPAGE_SORT_TYPE = 'HB_listPage_sortType';
const USERPAGE_GROUP_VISIBILITY_PREFIX = 'HB_listPage_visibility_group';
const DEFAULT_SORT_TYPE = 'alpha'; const DEFAULT_SORT_TYPE = 'alpha';
const DEFAULT_SORT_DIR = 'asc'; const DEFAULT_SORT_DIR = 'asc';
@@ -52,12 +50,12 @@ const ListPage = createClass({
// LOAD FROM LOCAL STORAGE // LOAD FROM LOCAL STORAGE
if(typeof window !== 'undefined') { if(typeof window !== 'undefined') {
const newSortType = (this.state.sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE)); const newSortType = (this.state.sortType ?? (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-SORTTYPE`) || DEFAULT_SORT_TYPE));
const newSortDir = (this.state.sortDir ?? (localStorage.getItem(USERPAGE_SORT_DIR) || DEFAULT_SORT_DIR)); const newSortDir = (this.state.sortDir ?? (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-SORTDIR`) || DEFAULT_SORT_DIR));
this.updateUrl(this.state.filterString, newSortType, newSortDir); this.updateUrl(this.state.filterString, newSortType, newSortDir);
const brewCollection = this.props.brewCollection.map((brewGroup)=>{ 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; return brewGroup;
}); });
@@ -75,10 +73,10 @@ const ListPage = createClass({
saveToLocalStorage : function() { saveToLocalStorage : function() {
this.state.brewCollection.map((brewGroup)=>{ 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_KEY_PREFIX}-SORTTYPE`, this.state.sortType);
localStorage.setItem(USERPAGE_SORT_DIR, this.state.sortDir); localStorage.setItem(`${USERPAGE_KEY_PREFIX}-SORTDIR`, this.state.sortDir);
}, },
renderBrews : function(brews){ renderBrews : function(brews){

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,17 @@
import './lockNotification.less'; require('./lockNotification.less');
import * as React from 'react'; const React = require('react');
import request from '../../../utils/request-middleware.js';
import Dialog from '../../../../components/dialog.jsx'; import Dialog from '../../../../components/dialog.jsx';
function LockNotification(props) { function LockNotification(props) {
props = { props = {
shareId : 0, shareId : 0,
disableLock : ()=>{}, disableLock : ()=>{},
lock : {}, message : '',
message : 'Unable to retrieve Lock Message',
reviewRequested : false,
...props ...props
}; };
const [reviewState, setReviewState] = React.useState(props.reviewRequested); const removeLock = ()=>{
alert(`Not yet implemented - ID ${props.shareId}`);
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>;
}; };
return <Dialog className='lockNotification' blocking closeText='CONTINUE TO EDITOR' > return <Dialog className='lockNotification' blocking closeText='CONTINUE TO EDITOR' >
@@ -32,11 +19,11 @@ 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> <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 /> <hr />
<h3>LOCK REASON</h3> <h3>LOCK REASON</h3>
<p>{props.message}</p> <p>{props.message || 'Unable to retrieve Lock Message'}</p>
<hr /> <hr />
<p>Once you have resolved this issue, click REQUEST LOCK REMOVAL to notify the Administrators for review.</p> <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> <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>; </Dialog>;
}; };

View File

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

View File

@@ -167,7 +167,7 @@ const errorIndex = (props)=>{
**Requested access:** ${props.brew.accessType} **Requested access:** ${props.brew.accessType}
**Brew ID:** ${props.brew.brewId}`, **Brew ID:** ${props.brew.brewId}`,
// Theme Not Valid // Theme Not Valid
'10' : dedent` '10' : dedent`
## The selected theme is not tagged as a theme. ## The selected theme is not tagged as a theme.
@@ -176,26 +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.`, 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}`,
//account page when account is not defined //account page when account is not defined
'50' : dedent` '50' : dedent`
## You are not signed in ## You are not signed in
@@ -214,47 +194,13 @@ const errorIndex = (props)=>{
**Brew ID:** ${props.brew.brewId} **Brew ID:** ${props.brew.brewId}
**Brew Title:** ${escape(props.brew.brewTitle)} **Brew Title:** ${escape(props.brew.brewTitle)}`,
**Brew Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${author})`;}).join(', ') || 'Unable to list authors'}`,
// ####### Admin page error ####### // ####### Admin page error #######
'52' : dedent` '52' : dedent`
## Access Denied ## Access Denied
You need to provide correct administrator credentials to access this page.`, 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. '90' : dedent` An unexpected error occurred while looking for these brews.
Try again in a few minutes.`, Try again in a few minutes.`,

View File

@@ -1,224 +1,141 @@
/* eslint-disable max-lines */ require('./homePage.less');
import './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 const Nav = require('naturalcrit/nav/nav.jsx');
import React, { useState, useEffect, useRef } from 'react'; const Navbar = require('../../navbar/navbar.jsx');
import request from '../../utils/request-middleware.js'; const NewBrewItem = require('../../navbar/newbrew.navitem.jsx');
import Markdown from 'naturalcrit/markdown.js'; const HelpNavItem = require('../../navbar/help.navitem.jsx');
import _ from 'lodash'; 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'; const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js'; const Editor = require('../../editor/editor.jsx');
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
import SplitPane from 'client/components/splitPane/splitPane.jsx'; const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
import Nav from 'naturalcrit/nav/nav.jsx'; const HomePage = createClass({
import Navbar from '../../navbar/navbar.jsx'; displayName : 'HomePage',
import NewBrewItem from '../../navbar/newbrew.navitem.jsx'; getDefaultProps : function() {
import AccountNavItem from '../../navbar/account.navitem.jsx'; return {
import ErrorNavItem from '../../navbar/error-navitem.jsx'; brew : DEFAULT_BREW,
import HelpNavItem from '../../navbar/help.navitem.jsx'; ver : '0.0.0'
import VaultNavItem from '../../navbar/vault.navitem.jsx';
import PrintNavItem from '../../navbar/print.navitem.jsx';
import { both as RecentNavItem } from '../../navbar/recent.navitem.jsx';
// 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));
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();
}
}; };
},
document.addEventListener('keydown', handleControlKeys); getInitialState : function() {
return {
return () => { brew : this.props.brew,
document.removeEventListener('keydown', handleControlKeys); welcomeText : this.props.brew.text,
error : undefined,
currentEditorViewPageNum : 1,
currentEditorCursorPageNum : 1,
currentBrewRendererPageNum : 1,
themeBundle : {}
}; };
}, []); },
const save = ()=>{ editor : React.createRef(null),
componentDidMount : function() {
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
},
handleSave : function(){
request.post('/api') request.post('/api')
.send(currentBrew) .send(this.state.brew)
.end((err, res)=>{ .end((err, res)=>{
if(err) { if(err) {
setError(err); this.setState({ error: err });
return; return;
} }
const saved = res.body; const brew = res.body;
window.location = `/edit/${saved.editId}`; window.location = `/edit/${brew.editId}`;
}); });
}; },
handleSplitMove : function(){
this.editor.current.update();
},
useEffect(()=>{ handleEditorViewPageChange : function(pageNumber){
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current); this.setState({ currentEditorViewPageNum: pageNumber });
setUnsavedChanges(hasChange); },
if(autoSaveEnabled) trySave(false, hasChange); handleEditorCursorPageChange : function(pageNumber){
}, [currentBrew]); this.setState({ currentEditorCursorPageNum: pageNumber });
},
const handleSplitMove = ()=>{ handleBrewRendererPageChange : function(pageNumber){
editorRef.current.update(); this.setState({ currentBrewRendererPageNum: pageNumber });
}; },
const handleBrewChange = (field) => (value, subfield) => { //'text', 'style', 'snippets', 'metadata' handleTextChange : function(text){
if (subfield == 'renderer' || subfield == 'theme') this.setState((prevState)=>({
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme); brew : { ...prevState.brew, text: text },
}));
//If there are HTML errors, run the validator on every change to give quick feedback },
if(HTMLErrors.length && (field == 'text' || field == 'snippets')) renderNavbar : function(){
setHTMLErrors(Markdown.validate(value)); return <Navbar ver={this.props.ver}>
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}>
<Nav.section> <Nav.section>
{error {this.state.error ?
? <ErrorNavItem error={error} clearError={clearError} /> <ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
: renderSaveButton()} null
}
<NewBrewItem /> <NewBrewItem />
<PrintNavItem />
<HelpNavItem /> <HelpNavItem />
<VaultNavItem /> <VaultNavItem />
<RecentNavItem /> <RecentNavItem />
<AccountNavItem /> <AccountNavItem />
</Nav.section> </Nav.section>
</Navbar>; </Navbar>;
}; },
return ( render : function(){
<div className='homePage sitePage'> return <div className='homePage sitePage'>
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' /> <Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
{renderNavbar()} {this.renderNavbar()}
<div className='content'> <div className="content">
<SplitPane onDragFinish={handleSplitMove}> <SplitPane onDragFinish={this.handleSplitMove}>
<Editor <Editor
ref={editorRef} ref={this.editor}
brew={currentBrew} brew={this.state.brew}
onBrewChange={handleBrewChange} onTextChange={this.handleTextChange}
renderer={currentBrew.renderer} renderer={this.state.brew.renderer}
showEditButtons={false} showEditButtons={false}
themeBundle={themeBundle} snippetBundle={this.state.themeBundle.snippets}
onCursorPageChange={setCurrentEditorCursorPageNum} onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={setCurrentEditorViewPageNum} onViewPageChange={this.handleEditorViewPageChange}
currentEditorViewPageNum={currentEditorViewPageNum} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum} currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum} currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
/> />
<BrewRenderer <BrewRenderer
text={currentBrew.text} text={this.state.brew.text}
style={currentBrew.style} style={this.state.brew.style}
renderer={currentBrew.renderer} renderer={this.state.brew.renderer}
onPageChange={setCurrentBrewRendererPageNum} onPageChange={this.handleBrewRendererPageChange}
currentEditorViewPageNum={currentEditorViewPageNum} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
currentEditorCursorPageNum={currentEditorCursorPageNum} currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
currentBrewRendererPageNum={currentBrewRendererPageNum} currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
themeBundle={themeBundle} themeBundle={this.state.themeBundle}
/> />
</SplitPane> </SplitPane>
</div> </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' /> Save current <i className='fas fa-save' />
</div> </div>
<a href='/new' className='floatingNewButton'> <a href='/new' className='floatingNewButton'>
Create your own <i className='fas fa-magic' /> Create your own <i className='fas fa-magic' />
</a> </a>
</div> </div>;
) }
}; });
module.exports = HomePage; module.exports = HomePage;

View File

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

View File

@@ -1,270 +1,264 @@
/* eslint-disable max-lines */ /*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
import './newPage.less'; require('./newPage.less');
const React = require('react');
const createClass = require('create-react-class');
import request from '../../utils/request-middleware.js';
// Common imports import Markdown from 'naturalcrit/markdown.js';
import React, { useState, useEffect, useRef } from 'react';
import request from '../../utils/request-middleware.js';
import Markdown from 'naturalcrit/markdown.js';
import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; const Nav = require('naturalcrit/nav/nav.jsx');
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js'; 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'; const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
import Editor from '../../editor/editor.jsx'; const Editor = require('../../editor/editor.jsx');
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
import Nav from 'naturalcrit/nav/nav.jsx'; const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
import Navbar from '../../navbar/navbar.jsx'; const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
import NewBrewItem from '../../navbar/newbrew.navitem.jsx';
import AccountNavItem from '../../navbar/account.navitem.jsx';
import ErrorNavItem from '../../navbar/error-navitem.jsx';
import HelpNavItem from '../../navbar/help.navitem.jsx';
import VaultNavItem from '../../navbar/vault.navitem.jsx';
import PrintNavItem from '../../navbar/print.navitem.jsx';
import { both as RecentNavItem } from '../../navbar/recent.navitem.jsx';
// Page specific imports const BREWKEY = 'homebrewery-new';
import { Meta } from 'vitreum/headtags'; 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 NewPage = createClass({
const neverSaved = true; displayName : 'NewPage',
getDefaultProps : function() {
const NewPage = (props) => { return {
props = { brew : DEFAULT_BREW
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));
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();
}
}; };
},
document.addEventListener('keydown', handleControlKeys); getInitialState : function() {
const brew = this.props.brew;
return () => { return {
document.removeEventListener('keydown', handleControlKeys); 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 = ()=>{ editor : React.createRef(null),
const brew = { ...currentBrew };
if(!brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser 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 brewStorage = localStorage.getItem(BREWKEY);
const styleStorage = localStorage.getItem(STYLEKEY); 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.text = brewStorage ?? brew.text;
brew.style = styleStorage ?? brew.style; 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.renderer = metaStorage?.renderer ?? brew.renderer;
brew.theme = metaStorage?.theme ?? brew.theme; brew.theme = metaStorage?.theme ?? brew.theme;
brew.lang = metaStorage?.lang ?? brew.lang; 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'; const saveStorage = localStorage.getItem(SAVEKEY) || 'HOMEBREWERY';
setCurrentBrew(brew); this.setState({
lastSavedBrew.current = brew; brew : brew,
setSaveGoogle(saveStorage == 'GOOGLE-DRIVE' && saveGoogle); saveGoogle : (saveStorage == 'GOOGLE-DRIVE' && this.state.saveGoogle)
});
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
localStorage.setItem(BREWKEY, brew.text); localStorage.setItem(BREWKEY, brew.text);
if(brew.style) if(brew.style)
localStorage.setItem(STYLEKEY, brew.style); localStorage.setItem(STYLEKEY, brew.style);
localStorage.setItem(METAKEY, JSON.stringify({ renderer: brew.renderer, theme: brew.theme, lang: brew.lang })); localStorage.setItem(METAKEY, JSON.stringify({ 'renderer': brew.renderer, 'theme': brew.theme, 'lang': brew.lang }));
if(window.location.pathname !== '/new') if(window.location.pathname != '/new') {
window.history.replaceState({}, window.location.title, '/new/'); window.history.replaceState({}, window.location.title, '/new/');
};
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange);
if(autoSaveEnabled) trySave(false, hasChange);
}, [currentBrew]);
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 save = async () => { handleControlKeys : function(e){
setIsSaving(true); 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();
}
},
let updatedBrew = { ...currentBrew }; handleSplitMove : function(){
splitTextStyleAndMetadata(updatedBrew); this.editor.current.update();
},
const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^\\page$/gm; handleEditorViewPageChange : function(pageNumber){
updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1; 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 const res = await request
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`) .post(`/api${this.state.saveGoogle ? '?saveToGoogle=true' : ''}`)
.send(updatedBrew) .send(brew)
.catch((err) => { .catch((err)=>{
setIsSaving(false); this.setState({ isSaving: false, error: err });
setError(err);
}); });
if(!res) return;
setIsSaving(false) brew = res.body;
if (!res) return;
const savedBrew = res.body;
localStorage.removeItem(BREWKEY); localStorage.removeItem(BREWKEY);
localStorage.removeItem(STYLEKEY); localStorage.removeItem(STYLEKEY);
localStorage.removeItem(METAKEY); localStorage.removeItem(METAKEY);
window.location = `/edit/${savedBrew.editId}`; window.location = `/edit/${brew.editId}`;
}; },
const renderSaveButton = ()=>{ renderSaveButton : function(){
// #1 - Currently saving, show SAVING if(this.state.isSaving){
if(isSaving) return <Nav.item icon='fas fa-spinner fa-spin' className='save'>
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>; 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 renderNavbar : function(){
// if(unsavedChanges && warnUnsavedChanges) { return <Navbar>
// 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 = () => (
<Navbar>
<Nav.section> <Nav.section>
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item> <Nav.item className='brewTitle'>{this.state.brew.title}</Nav.item>
</Nav.section> </Nav.section>
<Nav.section> <Nav.section>
{error {this.state.error ?
? <ErrorNavItem error={error} clearError={clearError} /> <ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
: renderSaveButton()} this.renderSaveButton()
<NewBrewItem /> }
<PrintNavItem /> <PrintNavItem />
<HelpNavItem /> <HelpNavItem />
<VaultNavItem />
<RecentNavItem /> <RecentNavItem />
<AccountNavItem /> <AccountNavItem />
</Nav.section> </Nav.section>
</Navbar> </Navbar>;
); },
return ( render : function(){
<div className='newPage sitePage'> return <div className='newPage sitePage'>
{renderNavbar()} {this.renderNavbar()}
<div className='content'> <div className="content">
<SplitPane onDragFinish={handleSplitMove}> <SplitPane onDragFinish={this.handleSplitMove}>
<Editor <Editor
ref={editorRef} ref={this.editor}
brew={currentBrew} brew={this.state.brew}
onBrewChange={handleBrewChange} onTextChange={this.handleTextChange}
renderer={currentBrew.renderer} onStyleChange={this.handleStyleChange}
userThemes={props.userThemes} onMetaChange={this.handleMetaChange}
themeBundle={themeBundle} renderer={this.state.brew.renderer}
onCursorPageChange={setCurrentEditorCursorPageNum} userThemes={this.props.userThemes}
onViewPageChange={setCurrentEditorViewPageNum} themeBundle={this.state.themeBundle}
currentEditorViewPageNum={currentEditorViewPageNum} snippetBundle={this.state.themeBundle.snippets}
currentEditorCursorPageNum={currentEditorCursorPageNum} onCursorPageChange={this.handleEditorCursorPageChange}
currentBrewRendererPageNum={currentBrewRendererPageNum} onViewPageChange={this.handleEditorViewPageChange}
/> currentEditorViewPageNum={this.state.currentEditorViewPageNum}
<BrewRenderer currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
text={currentBrew.text} currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
style={currentBrew.style} />
renderer={currentBrew.renderer} <BrewRenderer
theme={currentBrew.theme} text={this.state.brew.text}
themeBundle={themeBundle} style={this.state.brew.style}
errors={HTMLErrors} renderer={this.state.brew.renderer}
lang={currentBrew.lang} theme={this.state.brew.theme}
onPageChange={setCurrentBrewRendererPageNum} themeBundle={this.state.themeBundle}
currentEditorViewPageNum={currentEditorViewPageNum} errors={this.state.htmlErrors}
currentEditorCursorPageNum={currentEditorCursorPageNum} lang={this.state.brew.lang}
currentBrewRendererPageNum={currentBrewRendererPageNum} onPageChange={this.handleBrewRendererPageChange}
allowPrint={true} currentEditorViewPageNum={this.state.currentEditorViewPageNum}
/> currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
</SplitPane> currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
allowPrint={true}
/>
</SplitPane>
</div> </div>
</div> </div>;
); }
}; });
module.exports = NewPage; module.exports = NewPage;

View File

@@ -1,12 +1,8 @@
.newPage { .newPage{
.navItem.save { .navItem.save{
.fadeInRight(); background-color: @orange;
.transition(opacity); &:hover{
background-color : @orange; background-color: @green;
&:hover { background-color : @green; }
&.neverSaved {
.fadeOutRight();
opacity: 0;
} }
} }
} }

View File

@@ -17,11 +17,15 @@ const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpe
const SharePage = (props)=>{ const SharePage = (props)=>{
const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props; const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props;
const [themeBundle, setThemeBundle] = useState({}); const [state, setState] = useState({
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); themeBundle : {},
currentBrewRendererPageNum : 1,
});
const handleBrewRendererPageChange = useCallback((pageNumber)=>{ const handleBrewRendererPageChange = useCallback((pageNumber)=>{
setCurrentBrewRendererPageNum(pageNumber); setState((prevState)=>({
currentBrewRendererPageNum : pageNumber,
...prevState }));
}, []); }, []);
const handleControlKeys = (e)=>{ const handleControlKeys = (e)=>{
@@ -36,7 +40,11 @@ const SharePage = (props)=>{
useEffect(()=>{ useEffect(()=>{
document.addEventListener('keydown', handleControlKeys); document.addEventListener('keydown', handleControlKeys);
fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme); fetchThemeBundle(
{ setState },
brew.renderer,
brew.theme
);
return ()=>{ return ()=>{
document.removeEventListener('keydown', handleControlKeys); document.removeEventListener('keydown', handleControlKeys);
@@ -106,9 +114,9 @@ const SharePage = (props)=>{
lang={brew.lang} lang={brew.lang}
renderer={brew.renderer} renderer={brew.renderer}
theme={brew.theme} theme={brew.theme}
themeBundle={themeBundle} themeBundle={state.themeBundle}
onPageChange={handleBrewRendererPageChange} onPageChange={handleBrewRendererPageChange}
currentBrewRendererPageNum={currentBrewRendererPageNum} currentBrewRendererPageNum={state.currentBrewRendererPageNum}
allowPrint={true} allowPrint={true}
/> />
</div> </div>

View File

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

View File

@@ -39,14 +39,10 @@ const UserPage = (props)=>{
}] : []) }] : [])
]; ];
const clearError = ()=>{
setError(null);
};
const navItems = ( const navItems = (
<Navbar> <Navbar>
<Nav.section> <Nav.section>
{error && (<ErrorNavItem error={error} clearError={clearError}></ErrorNavItem>)} {error && (<ErrorNavItem error={error} parent={null}></ErrorNavItem>)}
<NewBrew /> <NewBrew />
<HelpNavItem /> <HelpNavItem />
<VaultNavitem /> <VaultNavitem />

View File

@@ -12,7 +12,7 @@ const Account = require('../../navbar/account.navitem.jsx');
const NewBrew = require('../../navbar/newbrew.navitem.jsx'); const NewBrew = require('../../navbar/newbrew.navitem.jsx');
const HelpNavItem = require('../../navbar/help.navitem.jsx'); const HelpNavItem = require('../../navbar/help.navitem.jsx');
const BrewItem = require('../basePages/listPage/brewItem/brewItem.jsx'); const BrewItem = require('../basePages/listPage/brewItem/brewItem.jsx');
const SplitPane = require('client/components/splitPane/splitPane.jsx'); const SplitPane = require('../../../../shared/naturalcrit/splitPane/splitPane.jsx');
const ErrorIndex = require('../errorPage/errors/errorIndex.js'); const ErrorIndex = require('../errorPage/errors/errorIndex.js');
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
@@ -99,14 +99,14 @@ const VaultPage = (props)=>{
setSearching(true); setSearching(true);
setError(null); setError(null);
const title = titleRef.current.value || ''; const title = titleRef.current.value || '';
const author = authorRef.current.value || ''; const author = authorRef.current.value || '';
const count = countRef.current.value || 10; const count = countRef.current.value || 10;
const v3 = v3Ref.current.checked != false; const v3 = v3Ref.current.checked != false;
const legacy = legacyRef.current.checked != false; const legacy = legacyRef.current.checked != false;
const sortOption = sort || 'title'; const sortOption = sort || 'title';
const dirOption = dir || 'asc'; const dirOption = dir || 'asc';
const pageProp = page || 1; const pageProp = page || 1;
setSort(sortOption); setSort(sortOption);
setdir(dirOption); setdir(dirOption);
@@ -247,7 +247,7 @@ const VaultPage = (props)=>{
</li> </li>
<li> <li>
Some common words like "a", "after", "through", "itself", "here", etc., 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'> <a href='https://github.com/mongodb/mongo/blob/0e3b3ca8480ddddf5d0105d11a94bd4698335312/src/mongo/db/fts/stop_words_english.txt'>
here here
</a> </a>
@@ -286,9 +286,9 @@ const VaultPage = (props)=>{
}; };
const renderPaginationControls = ()=>{ const renderPaginationControls = ()=>{
if(!totalBrews || totalBrews < 10) return null; if(!totalBrews) return null;
const countInt = parseInt(brewCollection.length || 20); const countInt = parseInt(props.query.count || 20);
const totalPages = Math.ceil(totalBrews / countInt); const totalPages = Math.ceil(totalBrews / countInt);
let startPage, endPage; let startPage, endPage;
@@ -355,7 +355,7 @@ const VaultPage = (props)=>{
}; };
const renderFoundBrews = ()=>{ const renderFoundBrews = ()=>{
if(searching && !brewCollection) { if(searching) {
return ( return (
<div className='foundBrews searching'> <div className='foundBrews searching'>
<h3 className='searchAnim'>Searching</h3> <h3 className='searchAnim'>Searching</h3>
@@ -395,7 +395,6 @@ const VaultPage = (props)=>{
{`Brews found: `} {`Brews found: `}
<span>{totalBrews}</span> <span>{totalBrews}</span>
</span> </span>
{brewCollection.length > 10 && renderPaginationControls()}
{brewCollection.map((brew, index)=>{ {brewCollection.map((brew, index)=>{
return ( return (
<BrewItem <BrewItem
@@ -416,14 +415,14 @@ const VaultPage = (props)=>{
<link href='/themes/V3/Blank/style.css' rel='stylesheet' /> <link href='/themes/V3/Blank/style.css' rel='stylesheet' />
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet' /> <link href='/themes/V3/5ePHB/style.css' rel='stylesheet' />
{renderNavItems()} {renderNavItems()}
<div className='content'> <div className="content">
<SplitPane showDividerButtons={false}> <SplitPane showDividerButtons={false}>
<div className='form dataGroup'>{renderForm()}</div> <div className='form dataGroup'>{renderForm()}</div>
<div className='resultsContainer dataGroup'> <div className='resultsContainer dataGroup'>
{renderSortBar()} {renderSortBar()}
{renderFoundBrews()} {renderFoundBrews()}
</div> </div>
</SplitPane> </SplitPane>
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -1,34 +1,84 @@
.fac { .fac {
display : inline-block; display : inline-block;
width : 1em;
aspect-ratio : 1;
background-color : currentColor; background-color : currentColor;
mask-size : contain;
mask-repeat : no-repeat; mask-repeat : no-repeat;
mask-position : center; 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> <html>
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1, height=device-height, interactive-widget=resizes-visual" /> <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="//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 href=${`/${name}/bundle.css`} type="text/css" rel='stylesheet' />
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" /> <link rel="icon" href="/assets/favicon.ico" type="image/x-icon" />

View File

@@ -1,5 +1,4 @@
{ {
"development": true,
"host" : "homebrewery.local.naturalcrit.com:8000", "host" : "homebrewery.local.naturalcrit.com:8000",
"naturalcrit_url" : "local.naturalcrit.com:8010", "naturalcrit_url" : "local.naturalcrit.com:8010",
"secret" : "secret", "secret" : "secret",

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

6343
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,10 @@
{ {
"name": "homebrewery", "name": "homebrewery",
"description": "Create authentic looking D&D homebrews using only markdown", "description": "Create authentic looking D&D homebrews using only markdown",
"version": "3.19.3", "version": "3.18.0",
"type": "module", "type": "module",
"engines": { "engines": {
"npm": "^10.8.x", "npm": "^10.2.x",
"node": "^20.18.x" "node": "^20.18.x"
}, },
"repository": { "repository": {
@@ -36,6 +36,7 @@
"test:mustache-syntax:inline": "jest \".*(mustache-syntax).*\" -t '^Inline:.*' --verbose --noStackTrace", "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:block": "jest \".*(mustache-syntax).*\" -t '^Block:.*' --verbose --noStackTrace",
"test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace", "test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace",
"test:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace",
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace", "test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
"test:non-breaking-spaces": "jest tests/markdown/non-breaking-spaces.test.js --verbose --noStackTrace", "test:non-breaking-spaces": "jest tests/markdown/non-breaking-spaces.test.js --verbose --noStackTrace",
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace", "test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
@@ -72,7 +73,7 @@
"lines": 50 "lines": 50
}, },
"server/homebrew.api.js": { "server/homebrew.api.js": {
"statements": 60, "statements": 70,
"branches": 50, "branches": 50,
"functions": 65, "functions": 65,
"lines": 70 "lines": 70
@@ -83,72 +84,63 @@
] ]
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.27.1", "@babel/core": "^7.26.9",
"@babel/plugin-transform-runtime": "^7.28.0", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/preset-env": "^7.28.0", "@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.27.1", "@babel/preset-react": "^7.26.3",
"@babel/runtime": "^7.27.6", "@googleapis/drive": "^8.16.0",
"@dmsnell/diff-match-patch": "^1.1.0", "body-parser": "^1.20.2",
"@googleapis/drive": "^13.0.1",
"@sanity/diff-match-patch": "^3.2.0",
"body-parser": "^2.2.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror": "^5.65.6", "codemirror": "^5.65.6",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"core-js": "^3.44.0", "core-js": "^3.41.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"create-react-class": "^15.7.0", "create-react-class": "^15.7.0",
"dedent-tabs": "^0.10.3", "dedent-tabs": "^0.10.3",
"dompurify": "^3.2.4",
"expr-eval": "^2.0.2", "expr-eval": "^2.0.2",
"express": "^5.1.0", "express": "^4.21.2",
"express-async-handler": "^1.2.0", "express-async-handler": "^1.2.0",
"express-static-gzip": "3.0.0", "express-static-gzip": "2.2.0",
"fflate": "^0.8.2",
"fs-extra": "11.3.0", "fs-extra": "11.3.0",
"hash-wasm": "^4.12.0", "idb-keyval": "^6.2.1",
"idb-keyval": "^6.2.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6", "jwt-simple": "^0.5.6",
"less": "^3.13.1", "less": "^3.13.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"marked": "15.0.12", "marked": "14.0.0",
"marked-alignment-paragraphs": "^1.0.0", "marked-emoji": "^2.0.0",
"marked-definition-lists": "^1.0.1", "marked-extended-tables": "^2.0.0",
"marked-emoji": "^2.0.1", "marked-gfm-heading-id": "^4.0.1",
"marked-extended-tables": "^2.0.1",
"marked-gfm-heading-id": "^4.1.2",
"marked-nonbreaking-spaces": "^1.0.1",
"marked-smartypants-lite": "^1.0.3", "marked-smartypants-lite": "^1.0.3",
"marked-subsuper-text": "^1.0.3", "marked-subsuper-text": "^1.0.3",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.16.3", "mongoose": "^8.12.1",
"nanoid": "5.1.5", "nanoid": "5.1.2",
"nconf": "^0.13.0", "nconf": "^0.12.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-frame-component": "^4.1.3", "react-frame-component": "^4.1.3",
"react-router": "^7.6.3", "react-router": "^7.3.0",
"romans": "^3.1.0",
"sanitize-filename": "1.6.3", "sanitize-filename": "1.6.3",
"superagent": "^10.2.1", "superagent": "^10.1.1",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git", "vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
"written-number": "^0.11.1"
}, },
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^4.0.0", "@stylistic/stylelint-plugin": "^3.1.2",
"babel-plugin-transform-import-meta": "^2.3.3", "babel-plugin-transform-import-meta": "^2.3.2",
"eslint": "^9.35.0", "eslint": "^9.22.0",
"eslint-plugin-jest": "^29.0.1", "eslint-plugin-jest": "^28.11.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.4",
"globals": "^16.3.0", "globals": "^16.0.0",
"jest": "^30.1.3", "jest": "^29.7.0",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"jsdom-global": "^3.0.2", "jsdom-global": "^3.0.2",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"stylelint": "^16.24.0", "stylelint": "^16.15.0",
"stylelint-config-recess-order": "^7.3.0", "stylelint-config-recess-order": "^6.0.0",
"stylelint-config-recommended": "^17.0.0", "stylelint-config-recommended": "^15.0.0",
"supertest": "^7.1.4" "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 babelConfig from '../babel.config.json' with { type : 'json' };
import less from 'less'; 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; const babelify = async (code)=>(await babel.transformAsync(code, babelConfig)).code;
@@ -53,7 +53,7 @@ fs.emptyDirSync('./build');
const themes = { Legacy: {}, V3: {} }; const themes = { Legacy: {}, V3: {} };
let themeFiles = fs.readdirSync('./themes/Legacy'); let themeFiles = fs.readdirSync('./themes/Legacy');
for (const dir of themeFiles) { for (let dir of themeFiles) {
const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`).toString()); const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`).toString());
themeData.path = dir; themeData.path = dir;
themes.Legacy[dir] = (themeData); themes.Legacy[dir] = (themeData);
@@ -70,7 +70,7 @@ fs.emptyDirSync('./build');
} }
themeFiles = fs.readdirSync('./themes/V3'); 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()); const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`).toString());
themeData.path = dir; themeData.path = dir;
themes.V3[dir] = (themeData); themes.V3[dir] = (themeData);
@@ -113,7 +113,7 @@ fs.emptyDirSync('./build');
const stream = fs.createWriteStream(editorThemeFile, { flags: 'a' }); const stream = fs.createWriteStream(editorThemeFile, { flags: 'a' });
stream.write('[\n"default"'); stream.write('[\n"default"');
for (const themeFile of editorThemeFiles) { for (let themeFile of editorThemeFiles) {
stream.write(`,\n"${themeFile.slice(0, -4)}"`); stream.write(`,\n"${themeFile.slice(0, -4)}"`);
} }
stream.write('\n]\n'); stream.write('\n]\n');

View File

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

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 HomebrewModel } from './homebrew.model.js';
import { model as NotificationModel } from './notifications.model.js'; import { model as NotificationModel } from './notifications.model.js';
import express from 'express'; import express from 'express';
@@ -12,7 +11,6 @@ import { splitTextStyleAndMetadata } from '../shared/helpers.js';
const router = express.Router(); const router = express.Router();
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin'; process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3'; process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
@@ -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 // ####################### NOTIFICATIONS
router.get('/admin/notification/all', async (req, res, next)=>{ 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 supertest from 'supertest';
import HBApp from './app.js'; import HBApp from './app.js';
import { model as NotificationModel } from './notifications.model.js'; import {model as NotificationModel } from './notifications.model.js';
import { model as HomebrewModel } from './homebrew.model.js';
// Mimic https responses to avoid being redirected all the time // Mimic https responses to avoid being redirected all the time
@@ -18,7 +16,7 @@ describe('Tests for admin api', ()=>{
const testNotifications = ['a', 'b']; const testNotifications = ['a', 'b'];
jest.spyOn(NotificationModel, 'find') jest.spyOn(NotificationModel, 'find')
.mockImplementationOnce(()=>{ .mockImplementationOnce(() => {
return { exec: jest.fn().mockResolvedValue(testNotifications) }; return { exec: jest.fn().mockResolvedValue(testNotifications) };
}); });
@@ -61,7 +59,7 @@ describe('Tests for admin api', ()=>{
expect(response.body).toEqual(savedNotification); 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 = { const inputNotification = {
title : 'Test Notification', title : 'Test Notification',
text : 'This is a test notification', text : 'This is a test notification',
@@ -77,7 +75,7 @@ describe('Tests for admin api', ()=>{
const response = await app const response = await app
.post('/admin/notification/add') .post('/admin/notification/add')
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) .set('Authorization', 'Basic ' + Buffer.from('admin:password3').toString('base64'))
.send(inputNotification); .send(inputNotification);
expect(response.status).toBe(500); expect(response.status).toBe(500);
@@ -88,14 +86,14 @@ describe('Tests for admin api', ()=>{
const dismissKey = 'testKey'; const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete') jest.spyOn(NotificationModel, 'findOneAndDelete')
.mockImplementationOnce((key)=>{ .mockImplementationOnce((key) => {
return { exec: jest.fn().mockResolvedValue(key) }; return { exec: jest.fn().mockResolvedValue(key) };
}); });
const response = await app const response = await app
.delete(`/admin/notification/delete/${dismissKey}`) .delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`); .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.status).toBe(200);
expect(response.body).toEqual({ dismissKey: 'testKey' }); expect(response.body).toEqual({ dismissKey: 'testKey' });
}); });
@@ -104,602 +102,16 @@ describe('Tests for admin api', ()=>{
const dismissKey = 'testKey'; const dismissKey = 'testKey';
jest.spyOn(NotificationModel, 'findOneAndDelete') jest.spyOn(NotificationModel, 'findOneAndDelete')
.mockImplementationOnce(()=>{ .mockImplementationOnce(() => {
return { exec: jest.fn().mockResolvedValue() }; return { exec: jest.fn().mockResolvedValue() };
}); });
const response = await app const response = await app
.delete(`/admin/notification/delete/${dismissKey}`) .delete(`/admin/notification/delete/${dismissKey}`)
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`); .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.status).toBe(500);
expect(response.body).toEqual({ message: 'Notification not found' }); 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 // Set working directory to project root
import { dirname } from 'path'; import { dirname } from 'path';
import { fileURLToPath } from 'url'; 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)); const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(`${__dirname}/..`); process.chdir(`${__dirname}/..`);
@@ -11,6 +11,7 @@ const version = packageJSON.version;
import _ from 'lodash'; import _ from 'lodash';
import jwt from 'jwt-simple'; import jwt from 'jwt-simple';
import express from 'express'; import express from 'express';
import yaml from 'js-yaml';
import config from './config.js'; import config from './config.js';
import fs from 'fs-extra'; import fs from 'fs-extra';
@@ -69,11 +70,13 @@ const corsOptions = {
'https://homebrewery-stage.herokuapp.com', '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 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); callback(null, true);
} else { } else {
console.log(origin, 'not allowed'); console.log(origin, 'not allowed');
@@ -349,7 +352,7 @@ app.get('/user/:username', async (req, res, next)=>{
app.put('/api/user/rename', async (req, res)=>{ app.put('/api/user/rename', async (req, res)=>{
const { username, newUsername } = req.body; const { username, newUsername } = req.body;
const ownAccount = req.account && (req.account.username == newUsername); const ownAccount = req.account && (req.account.username == newUsername);
if(!username || !newUsername) if(!username || !newUsername)
return res.status(400).json({ error: 'Username and newUsername are required.' }); return res.status(400).json({ error: 'Username and newUsername are required.' });
if(!ownAccount) if(!ownAccount)
@@ -383,7 +386,6 @@ app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res,
title : req.brew.title || 'Untitled Brew', title : req.brew.title || 'Untitled Brew',
description : req.brew.description || 'No description.', description : req.brew.description || 'No description.',
image : req.brew.thumbnail || defaultMetaTags.image, image : req.brew.thumbnail || defaultMetaTags.image,
locale : req.brew.lang,
type : 'article' type : 'article'
}; };
@@ -405,7 +407,6 @@ app.get('/new/:id', asyncHandler(getBrew('share')), asyncHandler(async(req, res,
renderer : req.brew.renderer, renderer : req.brew.renderer,
theme : req.brew.theme, theme : req.brew.theme,
tags : req.brew.tags, tags : req.brew.tags,
snippets : req.brew.snippets
}; };
req.brew = _.defaults(brew, DEFAULT_BREW); req.brew = _.defaults(brew, DEFAULT_BREW);
@@ -435,7 +436,7 @@ app.get('/new', asyncHandler(async(req, res, next)=>{
app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{ app.get('/share/:id', asyncHandler(getBrew('share')), asyncHandler(async (req, res, next)=>{
const { brew } = req; const { brew } = req;
req.ogMeta = { ...defaultMetaTags, 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.', description : req.brew.description || 'No description.',
image : req.brew.thumbnail || defaultMetaTags.image, image : req.brew.thumbnail || defaultMetaTags.image,
type : 'article' type : 'article'
@@ -487,8 +488,8 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
const query = { authors: req.account.username, googleId: { $exists: false } }; const query = { authors: req.account.username, googleId: { $exists: false } };
const mongoCount = await HomebrewModel.countDocuments(query) const mongoCount = await HomebrewModel.countDocuments(query)
.catch((err)=>{ .catch((err)=>{
mongoCount = 0;
console.log(err); console.log(err);
return 0;
}); });
data.accountDetails = { data.accountDetails = {

View File

@@ -27,10 +27,7 @@ const disconnect = async ()=>{
}; };
const connect = async (config)=>{ const connect = async (config)=>{
return await Mongoose.connect(getMongoDBURL(config), { return await Mongoose.connect(getMongoDBURL(config), { retryWrites: false })
retryWrites : false,
autoIndex : (config.get('local_environments').includes(config.get('node_env')))
})
.catch((error)=>handleConnectionError(error)); .catch((error)=>handleConnectionError(error));
}; };

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

@@ -27,12 +27,12 @@ if(!config.get('service_account')){
const defaultAuth = serviceAuth || config.get('google_api_key'); const defaultAuth = serviceAuth || config.get('google_api_key');
const retryConfig = { const retryConfig = {
retry : 3, // Number of retry attempts retry: 3, // Number of retry attempts
retryDelay : 100, // Initial delay in milliseconds retryDelay: 100, // Initial delay in milliseconds
retryDelayMultiplier : 2, // Multiplier for exponential backoff retryDelayMultiplier: 2, // Multiplier for exponential backoff
maxRetryDelay : 32000, // Maximum delay in milliseconds maxRetryDelay: 32000, // Maximum delay in milliseconds
httpMethodsToRetry : ['PATCH'], // Only retry PATCH requests httpMethodsToRetry: ['PATCH'], // Only retry PATCH requests
statusCodesToRetry : [[429, 429]], // Only retry on 429 status code statusCodesToRetry: [[429, 429]], // Only retry on 429 status code
}; };
const GoogleActions = { const GoogleActions = {
@@ -177,8 +177,8 @@ const GoogleActions = {
mimeType : 'text/plain', mimeType : 'text/plain',
body : brew.text body : brew.text
}, },
headers : { headers: {
'X-Forwarded-For' : userIp, // Set the X-Forwarded-For header 'X-Forwarded-For': userIp, // Set the X-Forwarded-For header
}, },
retryConfig retryConfig
}) })

View File

@@ -8,13 +8,9 @@ import Markdown from '../shared/naturalcrit/markdown.js';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import asyncHandler from 'express-async-handler'; import asyncHandler from 'express-async-handler';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import {makePatches, applyPatches, stringifyPatches, parsePatch} from '@sanity/diff-match-patch'; import { splitTextStyleAndMetadata } from '../shared/helpers.js';
import { md5 } from 'hash-wasm';
import { splitTextStyleAndMetadata,
brewSnippetsToJSON, debugTextMismatch } from '../shared/helpers.js';
import checkClientVersion from './middleware/check-client-version.js'; import checkClientVersion from './middleware/check-client-version.js';
const router = express.Router(); const router = express.Router();
import { DEFAULT_BREW, DEFAULT_BREW_LOAD } from './brewDefaults.js'; import { DEFAULT_BREW, DEFAULT_BREW_LOAD } from './brewDefaults.js';
@@ -48,20 +44,6 @@ const api = {
} }
id = id.slice(googleId.length); id = id.slice(googleId.length);
} }
// ID Validation Checks
// Homebrewery ID
// Typically 12 characters, but the DB shows a range of 7 to 14 characters
if(!id.match(/^[a-zA-Z0-9-_]{7,14}$/)){
throw { name: 'ID Error', message: 'Invalid ID', status: 404, HBErrorCode: '11', brewId: id };
}
// Google ID
// Typically 33 characters, old format is 44 - always starts with a 1
// Managed by Google, may change outside of our control, so any length between 33 and 44 is acceptable
if(googleId && !googleId.match(/^1(?:[a-zA-Z0-9-_]{32,43})$/)){
throw { name: 'Google ID Error', message: 'Invalid ID', status: 404, HBErrorCode: '12', brewId: id };
}
return { id, googleId }; return { id, googleId };
}, },
//Get array of any of this user's brews tagged with `meta:theme` //Get array of any of this user's brews tagged with `meta:theme`
@@ -110,7 +92,7 @@ const api = {
const accessMap = { const accessMap = {
edit : { editId: id }, edit : { editId: id },
share : { shareId: id }, share : { shareId: id },
admin : { $or: [{ editId: id }, { shareId: id }] } admin : { $or : [{ editId: id }, { shareId: id }] }
}; };
// Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine. // Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine.
@@ -136,8 +118,8 @@ const api = {
throw { ...accessError, message: 'User is not logged in', HBErrorCode: '04' }; throw { ...accessError, message: 'User is not logged in', HBErrorCode: '04' };
} }
if(stub?.lock && accessType === 'share') { if(stub?.lock?.locked && accessType != 'edit') {
throw { HBErrorCode: '51', code: stub.lock.code, message: stub.lock.shareMessage, brewId: stub.shareId, brewTitle: stub.title, brewAuthors: stub.authors }; throw { HBErrorCode: '51', code: stub?.lock.code, message: stub?.lock.shareMessage, brewId: stub?.shareId, brewTitle: stub?.title };
} }
// If there's a google id, get it if requesting the full brew or if no stub found yet // If there's a google id, get it if requesting the full brew or if no stub found yet
@@ -193,15 +175,12 @@ const api = {
`${text}`; `${text}`;
} }
const metadata = _.pick(brew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme']); const metadata = _.pick(brew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme']);
const snippetsArray = brewSnippetsToJSON('brew_snippets', brew.snippets, null, false).snippets;
metadata.snippets = snippetsArray.length > 0 ? snippetsArray : undefined;
text = `\`\`\`metadata\n` + text = `\`\`\`metadata\n` +
`${yaml.dump(metadata)}\n` + `${yaml.dump(metadata)}\n` +
`\`\`\`\n\n` + `\`\`\`\n\n` +
`${text}`; `${text}`;
return text; return text;
}, },
getGoodBrewTitle : (text)=>{ getGoodBrewTitle : (text)=>{
const tokens = Markdown.marked.lexer(text); const tokens = Markdown.marked.lexer(text);
return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title') return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title')
@@ -315,13 +294,13 @@ const api = {
currentTheme = req.brew; currentTheme = req.brew;
splitTextStyleAndMetadata(currentTheme); splitTextStyleAndMetadata(currentTheme);
if(!currentTheme.tags.some((tag)=>tag === 'meta:theme' || tag === 'meta:Theme')) if(!currentTheme.tags.some(tag => tag === "meta:theme" || tag === "meta:Theme"))
throw { brewId: req.params.id, name: 'Invalid Theme Selected', message: 'Selected theme does not have the meta:theme tag', status: 422, HBErrorCode: '10' }; throw { brewId: req.params.id, name: 'Invalid Theme Selected', message: 'Selected theme does not have the meta:theme tag', status: 422, HBErrorCode: '10' };
themeName ??= currentTheme.title; themeName ??= currentTheme.title;
themeAuthor ??= currentTheme.authors?.[0]; themeAuthor ??= currentTheme.authors?.[0];
// If there is anything in the snippets or style members, append them to the appropriate array // If there is anything in the snippets or style members, append them to the appropriate array
if(currentTheme?.snippets) completeSnippets.push({ name: currentTheme.title, snippets: currentTheme.snippets }); if(currentTheme?.snippets) completeSnippets.push(JSON.parse(currentTheme.snippets));
if(currentTheme?.style) completeStyles.push(`/* From Brew: ${req.protocol}://${req.get('host')}/share/${req.params.id} */\n\n${currentTheme.style}`); if(currentTheme?.style) completeStyles.push(`/* From Brew: ${req.protocol}://${req.get('host')}/share/${req.params.id} */\n\n${currentTheme.style}`);
req.params.id = currentTheme.theme; req.params.id = currentTheme.theme;
@@ -353,52 +332,21 @@ const api = {
// Initialize brew from request and body, destructure query params, and set the initial value for the after-save method // Initialize brew from request and body, destructure query params, and set the initial value for the after-save method
const brewFromClient = api.excludePropsFromUpdate(req.body); const brewFromClient = api.excludePropsFromUpdate(req.body);
const brewFromServer = req.brew; const brewFromServer = req.brew;
splitTextStyleAndMetadata(brewFromServer); if(brewFromServer.version && brewFromClient.version && brewFromServer.version > brewFromClient.version) {
if(brewFromServer?.version !== brewFromClient?.version){
console.log(`Version mismatch on brew ${brewFromClient.editId}`); console.log(`Version mismatch on brew ${brewFromClient.editId}`);
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');
return res.status(409).send(JSON.stringify({ message: `The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` })); return res.status(409).send(JSON.stringify({ message: `The brew has been changed on a different device. Please save your changes elsewhere, refresh, and try again.` }));
} }
brewFromServer.text = brewFromServer.text.normalize('NFC'); let brew = _.assign(brewFromServer, brewFromClient);
brewFromServer.hash = await md5(brewFromServer.text);
if(brewFromServer?.hash !== brewFromClient?.hash) {
console.log(`Hash mismatch on brew ${brewFromClient.editId}`);
//debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
res.setHeader('Content-Type', 'application/json');
return res.status(409).send(JSON.stringify({ message: `The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.` }));
}
try {
const patches = parsePatch(brewFromClient.patches);
// Patch to a throwaway variable while parallelizing - we're more concerned with error/no error.
const patchedResult = decodeURI(applyPatches(patches, encodeURI(brewFromServer.text))[0]);
if(patchedResult != brewFromClient.text)
throw("Patches did not apply cleanly, text mismatch detected");
// brew.text = applyPatches(patches, brewFromServer.text)[0];
} catch (err) {
//debugTextMismatch(brewFromClient.text, brewFromServer.text, `edit/${brewFromClient.editId}`);
console.error('Failed to apply patches:', {
//patches : brewFromClient.patches,
brewId : brewFromClient.editId || 'unknown',
error : err
});
// While running in parallel, don't throw the error upstream.
// throw err; // rethrow to preserve the 500 behavior
}
let brew = _.assign(brewFromServer, brewFromClient);
brew.title = brew.title.trim();
brew.description = brew.description.trim() || '';
brew.text = api.mergeBrewText(brew);
const googleId = brew.googleId; const googleId = brew.googleId;
const { saveToGoogle, removeFromGoogle } = req.query; const { saveToGoogle, removeFromGoogle } = req.query;
let afterSave = async ()=>true; let afterSave = async ()=>true;
brew.title = brew.title.trim();
brew.description = brew.description.trim() || '';
brew.text = api.mergeBrewText(brew);
if(brew.googleId && removeFromGoogle) { if(brew.googleId && removeFromGoogle) {
// If the google id exists and we're removing it from google, set afterSave to delete the google brew and mark the brew's google id as undefined // If the google id exists and we're removing it from google, set afterSave to delete the google brew and mark the brew's google id as undefined
afterSave = async ()=>{ afterSave = async ()=>{
@@ -459,8 +407,6 @@ const api = {
const after = await afterSave(); const after = await afterSave();
if(!after) return; if(!after) return;
saved.textBin = undefined; // Remove textBin from the saved object to save bandwidth
res.status(200).send(saved); res.status(200).send(saved);
}, },
deleteGoogleBrew : async (account, id, editId, res)=>{ deleteGoogleBrew : async (account, id, editId, res)=>{
@@ -531,10 +477,10 @@ const api = {
}; };
router.post('/api', checkClientVersion, asyncHandler(api.newBrew)); router.post('/api', checkClientVersion, asyncHandler(api.newBrew));
router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew)); router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', false)), asyncHandler(api.updateBrew)); router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew)); router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew)); router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew));
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle)); router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
export default api; export default api;

View File

@@ -99,87 +99,18 @@ describe('Tests for api', ()=>{
expect(googleId).toBeUndefined(); expect(googleId).toBeUndefined();
}); });
it('should throw if id is too short', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcd'
}
});
} catch (e) {
err = e;
};
expect(err).toEqual({ HBErrorCode: '11', brewId: 'abcd', message: 'Invalid ID', name: 'ID Error', status: 404 });
});
it('should return id and google id from request body', ()=>{ it('should return id and google id from request body', ()=>{
const { id, googleId } = api.getId({ const { id, googleId } = api.getId({
params : { params : {
id : 'abcdefghijkl' id : 'abcdefgh'
}, },
body : { body : {
googleId : '123456789012345678901234567890123' googleId : '12345'
} }
}); });
expect(id).toEqual('abcdefghijkl'); expect(id).toEqual('abcdefgh');
expect(googleId).toEqual('123456789012345678901234567890123'); expect(googleId).toEqual('12345');
});
it('should throw invalid - google id right length but does not match pattern', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcdefghijkl'
},
body : {
googleId : '012345678901234567890123456789012'
}
});
} catch (e) {
err = e;
}
expect(err).toEqual({ HBErrorCode: '12', brewId: 'abcdefghijkl', message: 'Invalid ID', name: 'Google ID Error', status: 404 });
});
it('should throw invalid - google id too short (32 char)', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcdefghijkl'
},
body : {
googleId : '12345678901234567890123456789012'
}
});
} catch (e) {
err = e;
}
expect(err).toEqual({ HBErrorCode: '12', brewId: 'abcdefghijkl', message: 'Invalid ID', name: 'Google ID Error', status: 404 });
});
it('should throw invalid - google id too long (45 char)', ()=>{
let err;
try {
api.getId({
params : {
id : 'abcdefghijkl'
},
body : {
googleId : '123456789012345678901234567890123456789012345'
}
});
} catch (e) {
err = e;
}
expect(err).toEqual({ HBErrorCode: '12', brewId: 'abcdefghijkl', message: 'Invalid ID', name: 'Google ID Error', status: 404 });
}); });
it('should return 12-char id and google id from params', ()=>{ it('should return 12-char id and google id from params', ()=>{
@@ -371,7 +302,7 @@ describe('Tests for api', ()=>{
}); });
it('access is denied to a locked brew', async()=>{ it('access is denied to a locked brew', async()=>{
const lockBrew = { title: 'test brew', shareId: '1', lock: { code: 404, shareMessage: 'brew locked' } }; const lockBrew = { title: 'test brew', shareId: '1', lock: { locked: true, code: 404, shareMessage: 'brew locked' } };
model.get = jest.fn(()=>toBrewPromise(lockBrew)); model.get = jest.fn(()=>toBrewPromise(lockBrew));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
@@ -1008,7 +939,7 @@ brew`);
}); });
describe('Get CSS', ()=>{ describe('Get CSS', ()=>{
it('should return brew style content as CSS text', async ()=>{ it('should return brew style content as CSS text', async ()=>{
const testBrew = { title: 'test brew', text: '```css\n\nI Have a style!\n```\n\n' }; const testBrew = { title: 'test brew', text: '```css\n\nI Have a style!\n````\n\n' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew })); const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined })); api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
@@ -1103,7 +1034,7 @@ brew`);
expect(testBrew.theme).toEqual('5ePHB'); expect(testBrew.theme).toEqual('5ePHB');
expect(testBrew.lang).toEqual('en'); expect(testBrew.lang).toEqual('en');
// Style // Style
expect(testBrew.style).toEqual('style\nstyle\nstyle\n'); expect(testBrew.style).toEqual('style\nstyle\nstyle');
// Text // Text
expect(testBrew.text).toEqual('text\n'); expect(testBrew.text).toEqual('text\n');
}); });
@@ -1121,83 +1052,4 @@ brew`);
expect(testBrew.tags).toEqual(['tag a']); expect(testBrew.tags).toEqual(['tag a']);
}); });
}); });
describe('updateBrew', ()=>{
it('should return error on version mismatch', async ()=>{
const brewFromClient = { version: 1 };
const brewFromServer = { version: 1000, text: '' };
const req = {
brew : brewFromServer,
body : brewFromClient
};
await api.updateBrew(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
});
it('should return error on hash mismatch', async ()=>{
const brewFromClient = { version: 1, hash: '1234' };
const brewFromServer = { version: 1, text: 'test' };
const req = {
brew : brewFromServer,
body : brewFromClient
};
await api.updateBrew(req, res);
expect(req.brew.hash).toBe('098f6bcd4621d373cade4e832627b4f6');
expect(res.status).toHaveBeenCalledWith(409);
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
});
// Commenting this one out for now, since we are no longer throwing this error while we monitor
// it('should return error on applying patches', async ()=>{
// const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: 'not a valid patch string' };
// const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
// const req = {
// brew : brewFromServer,
// body : brewFromClient,
// };
// let err;
// try {
// await api.updateBrew(req, res);
// } catch (e) {
// err = e;
// }
// expect(err).toEqual(Error('Invalid patch string: not a valid patch string'));
// });
it('should save brew, no ID', async ()=>{
const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: '' };
const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
model.save = jest.fn((brew)=>{return brew;});
const req = {
brew : brewFromServer,
body : brewFromClient,
query : { saveToGoogle: false, removeFromGoogle: false }
};
await api.updateBrew(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({
_id : '1',
description : 'Test Description',
hash : '098f6bcd4621d373cade4e832627b4f6',
title : 'Test Title',
version : 2
})
);
});
});
}); });

View File

@@ -7,29 +7,27 @@ import zlib from 'zlib';
const HomebrewSchema = mongoose.Schema({ const HomebrewSchema = mongoose.Schema({
shareId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } }, shareId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
editId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } }, editId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
googleId : { type: String, index: true }, googleId : { type: String },
title : { type: String, default: '', index: true }, title : { type: String, default: '' },
text : { type: String, default: '' }, text : { type: String, default: '' },
textBin : { type: Buffer }, textBin : { type: Buffer },
pageCount : { type: Number, default: 1, index: true }, pageCount : { type: Number, default: 1 },
description : { type: String, default: '' }, description : { type: String, default: '' },
tags : { type: [String], index: true }, tags : [String],
systems : [String], systems : [String],
lang : { type: String, default: 'en', index: true }, lang : { type: String, default: 'en' },
renderer : { type: String, default: '', index: true }, renderer : { type: String, default: '' },
authors : { type: [String], index: true }, authors : [String],
invitedAuthors : [String], invitedAuthors : [String],
published : { type: Boolean, default: false, index: true }, published : { type: Boolean, default: false },
thumbnail : { type: String, default: '', index: true }, thumbnail : { type: String, default: '' },
createdAt : { type: Date, default: Date.now, index: true }, createdAt : { type: Date, default: Date.now },
updatedAt : { type: Date, default: Date.now, index: true }, updatedAt : { type: Date, default: Date.now },
lastViewed : { type: Date, default: Date.now, index: true }, lastViewed : { type: Date, default: Date.now },
views : { type: Number, default: 0 }, views : { type: Number, default: 0 },
version : { type: Number, default: 1, index: true }, version : { type: Number, default: 1 }
lock : { type: Object, index: true }
}, { versionKey: false }); }, { versionKey: false });
HomebrewSchema.statics.increaseView = async function(query) { HomebrewSchema.statics.increaseView = async function(query) {
@@ -43,8 +41,6 @@ HomebrewSchema.statics.increaseView = async function(query) {
return brew; return brew;
}; };
// STATIC FUNCTIONS
HomebrewSchema.statics.get = async function(query, fields=null){ HomebrewSchema.statics.get = async function(query, fields=null){
const brew = await Homebrew.findOne(query, fields).orFail() const brew = await Homebrew.findOne(query, fields).orFail()
.catch((error)=>{throw 'Can not find brew';}); .catch((error)=>{throw 'Can not find brew';});
@@ -65,18 +61,9 @@ HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, f
return brews; return brews;
}; };
// INDEXES
HomebrewSchema.index({ updatedAt: -1, lastViewed: -1 });
HomebrewSchema.index({ published: 1, title: 'text' });
HomebrewSchema.index({ lock: 1, sparse: true });
HomebrewSchema.path('lock.reviewRequested').index({ sparse: true });
const Homebrew = mongoose.model('Homebrew', HomebrewSchema); const Homebrew = mongoose.model('Homebrew', HomebrewSchema);
export { export {
HomebrewSchema as schema, HomebrewSchema as schema,
Homebrew as model Homebrew as model
}; };

View File

@@ -5,16 +5,21 @@ import config from './config.js';
const generateAccessToken = (account)=>{ const generateAccessToken = (account)=>{
const payload = account; const payload = account;
payload.issued = (new Date()); // When the token was issued // When the token was issued
payload.issuer = config.get('authentication_token_issuer'); // Which service issued the Token payload.issued = (new Date());
payload.audience = config.get('authentication_token_audience'); // Which service is the token intended for // Which service issued the Token
const secret = config.get('authentication_token_secret'); // The signing key for signing the token payload.issuer = config.get('authentication_token_issuer');
// Which service is the token intended for
payload.audience = config.get('authentication_token_audience');
// The signing key for signing the token
delete payload.password; delete payload.password;
delete payload._id; delete payload._id;
const secret = config.get('authentication_token_secret');
const token = jwt.encode(payload, secret); const token = jwt.encode(payload, secret);
return token; return token;
}; };
export default generateAccessToken; export default generateAccessToken;

View File

@@ -1,27 +0,0 @@
import { expect, jest } from '@jest/globals';
import config from './config.js';
import generateAccessToken from './token';
describe('Tests for Token', ()=>{
it('Get token', ()=>{
// Mock the Config module, so we aren't grabbing actual secrets for testing
jest.mock('./config.js');
config.get = jest.fn((param)=>{
// The requested key name will be reflected to the output
return param;
});
const account = {};
const token = generateAccessToken(account);
// If these tests fail, the config mock has failed
expect(account).toHaveProperty('issuer', 'authentication_token_issuer');
expect(account).toHaveProperty('audience', 'authentication_token_audience');
// Because the inputs are fixed, this JWT key should be static
expect(typeof token).toBe('string');
});
});

View File

@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import asyncHandler from 'express-async-handler'; import asyncHandler from 'express-async-handler';
import { model as HomebrewModel } from './homebrew.model.js'; import {model as HomebrewModel } from './homebrew.model.js';
const router = express.Router(); const router = express.Router();
@@ -29,7 +29,7 @@ const rendererConditions = (legacy, v3)=>{
return {}; // If all renderers selected, renderer field not needed in query for speed return {}; // If all renderers selected, renderer field not needed in query for speed
}; };
const sortConditions = (sort, dir)=>{ const sortConditions = (sort, dir) => {
return { [sort]: dir === 'asc' ? 1 : -1 }; return { [sort]: dir === 'asc' ? 1 : -1 };
}; };

View File

@@ -2,103 +2,24 @@ import _ from 'lodash';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import request from '../client/homebrew/utils/request-middleware.js'; import request from '../client/homebrew/utils/request-middleware.js';
// Convert the templates from a brew to a Snippets Structure.
const brewSnippetsToJSON = (menuTitle, userBrewSnippets, themeBundleSnippets=null, full=true)=>{
const textSplit = /^(\\snippet +.+\n)/gm;
const mpAsSnippets = [];
// Snippets from Themes first.
if(themeBundleSnippets) {
for (let themes of themeBundleSnippets) {
if(typeof themes !== 'string') {
const userSnippets = [];
const snipSplit = themes.snippets.trim().split(textSplit).slice(1);
for (let snips = 0; snips < snipSplit.length; snips+=2) {
if(!snipSplit[snips].startsWith('\\snippet ')) break;
const snippetName = snipSplit[snips].split(/\\snippet +/)[1].split('\n')[0].trim();
if(snippetName.length != 0) {
userSnippets.push({
name : snippetName,
icon : '',
gen : snipSplit[snips + 1],
});
}
}
if(userSnippets.length > 0) {
mpAsSnippets.push({
name : themes.name,
icon : '',
gen : '',
subsnippets : userSnippets
});
}
}
}
}
// Local Snippets
if(userBrewSnippets) {
const userSnippets = [];
const snipSplit = userBrewSnippets.trim().split(textSplit).slice(1);
for (let snips = 0; snips < snipSplit.length; snips+=2) {
if(!snipSplit[snips].startsWith('\\snippet ')) break;
const snippetName = snipSplit[snips].split(/\\snippet +/)[1].split('\n')[0].trim();
if(snippetName.length != 0) {
const subSnip = {
name : snippetName,
gen : snipSplit[snips + 1],
};
// if(full) subSnip.icon = '';
userSnippets.push(subSnip);
}
}
if(userSnippets.length) {
mpAsSnippets.push({
name : menuTitle,
// icon : '',
subsnippets : userSnippets
});
}
}
const returnObj = {
snippets : mpAsSnippets
};
if(full) {
returnObj.groupName = 'Brew Snippets';
returnObj.icon = 'fas fa-th-list';
returnObj.view = 'text';
}
return returnObj;
};
const yamlSnippetsToText = (yamlObj)=>{
if(typeof yamlObj == 'string') return yamlObj;
let snippetsText = '';
for (let snippet of yamlObj) {
for (let subSnippet of snippet.subsnippets) {
snippetsText = `${snippetsText}\\snippet ${subSnippet.name}\n${subSnippet.gen || ''}\n`;
}
}
return snippetsText;
};
const splitTextStyleAndMetadata = (brew)=>{ const splitTextStyleAndMetadata = (brew)=>{
brew.text = brew.text.replaceAll('\r\n', '\n'); brew.text = brew.text.replaceAll('\r\n', '\n');
if(brew.text.startsWith('```metadata')) { if(brew.text.startsWith('```metadata')) {
const index = brew.text.indexOf('\n```\n\n'); const index = brew.text.indexOf('```\n\n');
const metadataSection = brew.text.slice(11, index + 1); const metadataSection = brew.text.slice(12, index - 1);
const metadata = yaml.load(metadataSection); const metadata = yaml.load(metadataSection);
Object.assign(brew, _.pick(metadata, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])); Object.assign(brew, _.pick(metadata, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang']));
brew.snippets = yamlSnippetsToText(_.pick(metadata, ['snippets']).snippets || ''); brew.text = brew.text.slice(index + 5);
brew.text = brew.text.slice(index + 6);
} }
if(brew.text.startsWith('```css')) { if(brew.text.startsWith('```css')) {
const index = brew.text.indexOf('\n```\n\n'); const index = brew.text.indexOf('```\n\n');
brew.style = brew.text.slice(7, index + 1); brew.style = brew.text.slice(7, index - 1);
brew.text = brew.text.slice(index + 6); brew.text = brew.text.slice(index + 5);
}
if(brew.text.startsWith('```snippets')) {
const index = brew.text.indexOf('```\n\n');
brew.snippets = brew.text.slice(11, index - 1);
brew.text = brew.text.slice(index + 5);
} }
// Handle old brews that still have empty strings in the tags metadata // Handle old brews that still have empty strings in the tags metadata
@@ -116,62 +37,31 @@ const printCurrentBrew = ()=>{
} }
}; };
const fetchThemeBundle = async (setError, setThemeBundle, renderer, theme)=>{ const fetchThemeBundle = async (obj, renderer, theme)=>{
if(!renderer || !theme) return; if(!renderer || !theme) return;
const res = await request const res = await request
.get(`/api/theme/${renderer}/${theme}`) .get(`/api/theme/${renderer}/${theme}`)
.catch((err)=>{ .catch((err)=>{
setError(err) obj.setState({ error: err });
}); });
if(!res) { if(!res) {
setThemeBundle({}); obj.setState((prevState)=>({
...prevState,
themeBundle : {}
}));
return; return;
} }
const themeBundle = res.body; const themeBundle = res.body;
themeBundle.joinedStyles = themeBundle.styles.map((style)=>`<style>${style}</style>`).join('\n\n'); themeBundle.joinedStyles = themeBundle.styles.map((style)=>`<style>${style}</style>`).join('\n\n');
setThemeBundle(themeBundle); obj.setState((prevState)=>({
setError(null); ...prevState,
themeBundle : themeBundle,
error : null
}));
}; };
const debugTextMismatch = (clientTextRaw, serverTextRaw, label) => {
const clientText = clientTextRaw?.normalize('NFC') || '';
const serverText = serverTextRaw?.normalize('NFC') || '';
const clientBuffer = Buffer.from(clientText, 'utf8');
const serverBuffer = Buffer.from(serverText, 'utf8');
if (clientBuffer.equals(serverBuffer)) {
console.log(`${label} text matches byte-for-byte.`);
return;
}
console.warn(`${label} text mismatch detected.`);
console.log(`Client length: ${clientBuffer.length}`);
console.log(`Server length: ${serverBuffer.length}`);
// Byte-level diff
for (let i = 0; i < Math.min(clientBuffer.length, serverBuffer.length); i++) {
if (clientBuffer[i] !== serverBuffer[i]) {
console.log(`Byte mismatch at offset ${i}: client=0x${clientBuffer[i].toString(16)} server=0x${serverBuffer[i].toString(16)}`);
break;
}
}
// Char-level diff
for (let i = 0; i < Math.min(clientText.length, serverText.length); i++) {
if (clientText[i] !== serverText[i]) {
console.log(`Char mismatch at index ${i}:`);
console.log(` Client: '${clientText[i]}' (U+${clientText.charCodeAt(i).toString(16).toUpperCase()})`);
console.log(` Server: '${serverText[i]}' (U+${serverText.charCodeAt(i).toString(16).toUpperCase()})`);
break;
}
}
}
export { export {
splitTextStyleAndMetadata, splitTextStyleAndMetadata,
printCurrentBrew, printCurrentBrew,
fetchThemeBundle, fetchThemeBundle,
brewSnippetsToJSON,
debugTextMismatch
}; };

View File

@@ -38,6 +38,15 @@
animation-duration : 0.4s; animation-duration : 0.4s;
} }
.CodeMirror-vscrollbar {
&::-webkit-scrollbar { width : 20px; }
&::-webkit-scrollbar-thumb {
width : 20px;
background : linear-gradient(90deg, #858585 15px, #808080 15px);
}
}
//.cm-tab { //.cm-tab {
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right; // background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
//} //}

View File

@@ -4,15 +4,10 @@ import _ from 'lodash';
import { Parser as MathParser } from 'expr-eval'; import { Parser as MathParser } from 'expr-eval';
import { marked as Marked } from 'marked'; import { marked as Marked } from 'marked';
import MarkedExtendedTables from 'marked-extended-tables'; import MarkedExtendedTables from 'marked-extended-tables';
import MarkedDefinitionLists from 'marked-definition-lists';
import MarkedAlignedParagraphs from 'marked-alignment-paragraphs';
import MarkedNonbreakingSpaces from 'marked-nonbreaking-spaces';
import MarkedSubSuperText from 'marked-subsuper-text';
import { markedSmartypantsLite as MarkedSmartypantsLite } from 'marked-smartypants-lite'; import { markedSmartypantsLite as MarkedSmartypantsLite } from 'marked-smartypants-lite';
import { gfmHeadingId as MarkedGFMHeadingId, resetHeadings as MarkedGFMResetHeadingIDs } from 'marked-gfm-heading-id'; import { gfmHeadingId as MarkedGFMHeadingId, resetHeadings as MarkedGFMResetHeadingIDs } from 'marked-gfm-heading-id';
import { markedEmoji as MarkedEmojis } from 'marked-emoji'; import { markedEmoji as MarkedEmojis } from 'marked-emoji';
import { romanize } from 'romans'; import MarkedSubSuperText from 'marked-subsuper-text';
import writtenNumber from 'written-number';
//Icon fonts included so they can appear in emoji autosuggest dropdown //Icon fonts included so they can appear in emoji autosuggest dropdown
import diceFont from '../../themes/fonts/iconFonts/diceFont.js'; import diceFont from '../../themes/fonts/iconFonts/diceFont.js';
@@ -64,53 +59,6 @@ mathParser.functions.signed = function (a) {
if(a >= 0) return `+${a}`; if(a >= 0) return `+${a}`;
return `${a}`; return `${a}`;
}; };
// Add Roman numeral functions
mathParser.functions.toRomans = function (a) {
return romanize(a);
};
mathParser.functions.toRomansUpper = function (a) {
return romanize(a).toUpperCase();
};
mathParser.functions.toRomansLower = function (a) {
return romanize(a).toLowerCase();
};
// Add character functions
mathParser.functions.toChar = function (a) {
if(a <= 0) return a;
const genChars = function (i) {
return (i > 26 ? genChars(Math.floor((i - 1) / 26)) : '') + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[(i - 1) % 26];
};
return genChars(a);
};
mathParser.functions.toCharUpper = function (a) {
return mathParser.functions.toChar(a).toUpperCase();
};
mathParser.functions.toCharLower = function (a) {
return mathParser.functions.toChar(a).toLowerCase();
};
// Add word functions
mathParser.functions.toWords = function (a) {
return writtenNumber(a);
};
mathParser.functions.toWordsUpper = function (a) {
return mathParser.functions.toWords(a).toUpperCase();
};
mathParser.functions.toWordsLower = function (a) {
return mathParser.functions.toWords(a).toLowerCase();
};
mathParser.functions.toWordsCaps = function (a) {
const words = mathParser.functions.toWords(a).split(' ');
return words.map((word)=>{
return word.replace(/(?:^|\b|\s)(\w)/g, function(w, index) {
return index === 0 ? w.toLowerCase() : w.toUpperCase();
});
}).join(' ');
};
// Normalize variable names; trim edge spaces and shorten blocks of whitespace to 1 space
const normalizeVarNames = (label)=>{
return label.trim().replace(/\s+/g, ' ');
};
//Processes the markdown within an HTML block if it's just a class-wrapper //Processes the markdown within an HTML block if it's just a class-wrapper
renderer.html = function (token) { renderer.html = function (token) {
@@ -138,8 +86,8 @@ renderer.paragraph = function(token){
//Fix local links in the Preview iFrame to link inside the frame //Fix local links in the Preview iFrame to link inside the frame
renderer.link = function (token) { renderer.link = function (token) {
let { href, title, tokens } = token; let {href, title, tokens} = token;
const text = this.parser.parseInline(tokens); const text = this.parser.parseInline(tokens)
let self = false; let self = false;
if(href[0] == '#') { if(href[0] == '#') {
self = true; self = true;
@@ -151,7 +99,7 @@ renderer.link = function (token) {
} }
let out = `<a href="${escape(href)}"`; let out = `<a href="${escape(href)}"`;
if(title) { if(title) {
out += ` title="${escape(title)}"`; out += ` title="${title}"`;
} }
if(self) { if(self) {
out += ' target="_self"'; out += ' target="_self"';
@@ -162,7 +110,7 @@ renderer.link = function (token) {
// Expose `src` attribute as `--HB_src` to make the URL accessible via CSS // Expose `src` attribute as `--HB_src` to make the URL accessible via CSS
renderer.image = function (token) { renderer.image = function (token) {
const { href, title, text } = token; let {href, title, text} = token;
if(href === null) if(href === null)
return text; return text;
@@ -185,7 +133,7 @@ const mustacheSpans = {
start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const completeSpan = /^{{[^\n]*}}/; // Regex for the complete token const completeSpan = /^{{[^\n]*}}/; // Regex for the complete token
const inlineRegex = /{{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1 *|}}/g; const inlineRegex = /{{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *|}}/g;
const match = completeSpan.exec(src); const match = completeSpan.exec(src);
if(match) { if(match) {
//Find closing delimiter //Find closing delimiter
@@ -242,7 +190,7 @@ const mustacheDivs = {
start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const completeBlock = /^ *{{[^\n}]* *\n.*\n *}}/s; // Regex for the complete token const completeBlock = /^ *{{[^\n}]* *\n.*\n *}}/s; // Regex for the complete token
const blockRegex = /^ *{{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1 *$|^ *}}$/gm; const blockRegex = /^ *{{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1 *$|^ *}}$/gm;
const match = completeBlock.exec(src); const match = completeBlock.exec(src);
if(match) { if(match) {
//Find closing delimiter //Find closing delimiter
@@ -297,7 +245,7 @@ const mustacheInjectInline = {
level : 'inline', level : 'inline',
start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1}/g; const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/g;
const match = inlineRegex.exec(src); const match = inlineRegex.exec(src);
if(match) { if(match) {
const lastToken = tokens[tokens.length - 1]; const lastToken = tokens[tokens.length - 1];
@@ -343,7 +291,7 @@ const mustacheInjectBlock = {
level : 'block', level : 'block',
start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) { tokenizer(src, tokens) {
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1}/ym; const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/ym;
const match = inlineRegex.exec(src); const match = inlineRegex.exec(src);
if(match) { if(match) {
const lastToken = tokens[tokens.length - 1]; const lastToken = tokens[tokens.length - 1];
@@ -390,6 +338,42 @@ const mustacheInjectBlock = {
} }
}; };
const justifiedParagraphClasses = [];
justifiedParagraphClasses[2] = 'Left';
justifiedParagraphClasses[4] = 'Right';
justifiedParagraphClasses[6] = 'Center';
const justifiedParagraphs = {
name : 'justifiedParagraphs',
level : 'block',
start(src) {
return src.match(/\n(?:-:|:-|-:) {1}/m)?.index;
}, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /^(((:-))|((-:))|((:-:))) .+(\n(([^\n].*\n)*(\n|$))|$)/ygm;
const match = regex.exec(src);
if(match?.length) {
let whichJustify;
if(match[2]?.length) whichJustify = 2;
if(match[4]?.length) whichJustify = 4;
if(match[6]?.length) whichJustify = 6;
return {
type : 'justifiedParagraphs', // Should match "name" above
raw : match[0], // Text to consume from the source
length : match[whichJustify].length,
text : match[0].slice(match[whichJustify].length),
class : justifiedParagraphClasses[whichJustify],
tokens : this.lexer.inlineTokens(match[0].slice(match[whichJustify].length + 1))
};
}
},
renderer(token) {
return `<p align="${token.class}">${this.parser.parseInline(token.tokens)}</p>`;
}
};
const forcedParagraphBreaks = { const forcedParagraphBreaks = {
name : 'hardBreaks', name : 'hardBreaks',
level : 'block', level : 'block',
@@ -397,17 +381,131 @@ const forcedParagraphBreaks = {
tokenizer(src, tokens) { tokenizer(src, tokens) {
const regex = /^(:+)(?:\n|$)/ym; const regex = /^(:+)(?:\n|$)/ym;
const match = regex.exec(src); const match = regex.exec(src);
if(match?.length) { if(match?.length) {
let extraBreak = 0;
const lastToken = tokens[tokens.length - 1];
if(lastToken?.type == 'text')
extraBreak = 1;
return { return {
type : 'hardBreaks', // Should match "name" above type : 'hardBreaks', // Should match "name" above
raw : match[0], // Text to consume from the source raw : match[0], // Text to consume from the source
length : match[1].length + extraBreak,
text : ''
};
}
},
renderer(token) {
return `<br>\n`.repeat(token.length);
}
};
const nonbreakingSpaces = {
name : 'nonbreakingSpaces',
level : 'inline',
start(src) { return src.match(/:>+/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /:(>+)/ym;
const match = regex.exec(src);
if(match?.length) {
return {
type : 'nonbreakingSpaces', // Should match "name" above
raw : match[0], // Text to consume from the source
length : match[1].length, length : match[1].length,
text : '' text : ''
}; };
} }
}, },
renderer(token) { renderer(token) {
return `<div class='blank'></div>\n`.repeat(token.length); return `&nbsp;`.repeat(token.length).concat('');
}
};
const definitionListsSingleLine = {
name : 'definitionListsSingleLine',
level : 'block',
start(src) { return src.match(/\n[^\n]*?::[^\n]*/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym;
let match;
let endIndex = 0;
const definitions = [];
while (match = regex.exec(src)) {
const originalLine = match[0]; // This line and below to handle conflict with emojis
let firstLine = originalLine; // Remove in V4 when definitionListsInline updated to
this.lexer.inlineTokens(firstLine.trim()) // require spaces around `::`
.filter((t)=>t.type == 'emoji')
.map((emoji)=>firstLine = firstLine.replace(emoji.raw, 'x'.repeat(emoji.raw.length)));
const newMatch = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym.exec(firstLine);
if(newMatch) {
definitions.push({
dt : this.lexer.inlineTokens(originalLine.slice(0, newMatch[1].length).trim()),
dd : this.lexer.inlineTokens(originalLine.slice(newMatch[1].length + 2).trim())
});
} // End of emoji hack.
endIndex = regex.lastIndex;
}
if(definitions.length) {
return {
type : 'definitionListsSingleLine',
raw : src.slice(0, endIndex),
definitions
};
}
},
renderer(token) {
return `<dl>${token.definitions.reduce((html, def)=>{
return `${html}<dt>${this.parser.parseInline(def.dt)}</dt>`
+ `<dd>${this.parser.parseInline(def.dd)}</dd>\n`;
}, '')}</dl>`;
}
};
const definitionListsMultiLine = {
name : 'definitionListsMultiLine',
level : 'block',
start(src) { return src.match(/\n[^\n]*\n::[^:\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match
tokenizer(src, tokens) {
const regex = /(\n?\n?(?!::)[^\n]+?(?=\n::[^:\n]))|\n::([^:\n](?:.|\n)*?(?=(?:\n::)|(?:\n\n)|$))/y;
let match;
let endIndex = 0;
const definitions = [];
while (match = regex.exec(src)) {
if(match[1]) {
if(this.lexer.blockTokens(match[1].trim())[0]?.type !== 'paragraph') // DT must not be another block-level token besides <p>
break;
definitions.push({
dt : this.lexer.inlineTokens(match[1].trim()),
dds : []
});
}
if(match[2] && definitions.length) {
definitions[definitions.length - 1].dds.push(
this.lexer.inlineTokens(match[2].trim().replace(/\s/g, ' '))
);
}
endIndex = regex.lastIndex;
}
if(definitions.length) {
return {
type : 'definitionListsMultiLine',
raw : src.slice(0, endIndex),
definitions
};
}
},
renderer(token) {
let returnVal = `<dl>`;
token.definitions.forEach((def)=>{
const dds = def.dds.map((s)=>{
return `\n<dd>${this.parser.parseInline(s).trim()}</dd>`;
}).join('');
returnVal += `<dt>${this.parser.parseInline(def.dt)}</dt>${dds}\n`;
});
returnVal = returnVal.trim();
return `${returnVal}</dl>`;
} }
}; };
@@ -417,7 +515,7 @@ const replaceVar = function(input, hoist=false, allowUnresolved=false) {
const match = regex.exec(input); const match = regex.exec(input);
const prefix = match[1]; const prefix = match[1];
const label = normalizeVarNames(match[2]); // Ensure the label name is normalized as it should be in the var stack. const label = match[2];
//v=====--------------------< HANDLE MATH >-------------------=====v// //v=====--------------------< HANDLE MATH >-------------------=====v//
const mathRegex = /[a-z]+\(|[+\-*/^(),]/g; const mathRegex = /[a-z]+\(|[+\-*/^(),]/g;
@@ -572,8 +670,8 @@ function MarkedVariables() {
}); });
} }
if(match[3]) { // Block Definition if(match[3]) { // Block Definition
const label = match[4] ? normalizeVarNames(match[4]) : null; const label = match[4] ? match[4].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
const content = match[5] ? match[5].trim().replace(/[ \t]+/g, ' ') : null; // Normalize text content (except newlines for block-level content) const content = match[5] ? match[5].trim().replace(/[ \t]+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
varsQueue.push( varsQueue.push(
{ type : 'varDefBlock', { type : 'varDefBlock',
@@ -582,7 +680,7 @@ function MarkedVariables() {
}); });
} }
if(match[6]) { // Block Call if(match[6]) { // Block Call
const label = match[7] ? normalizeVarNames(match[7]) : null; const label = match[7] ? match[7].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
varsQueue.push( varsQueue.push(
{ type : 'varCallBlock', { type : 'varCallBlock',
@@ -591,7 +689,7 @@ function MarkedVariables() {
}); });
} }
if(match[8]) { // Inline Definition if(match[8]) { // Inline Definition
const label = match[10] ? normalizeVarNames(match[10]) : null; const label = match[10] ? match[10].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
let content = match[11] || null; let content = match[11] || null;
// In case of nested (), find the correct matching end ) // In case of nested (), find the correct matching end )
@@ -623,7 +721,7 @@ function MarkedVariables() {
}); });
} }
if(match[12]) { // Inline Call if(match[12]) { // Inline Call
const label = match[13] ? normalizeVarNames(match[13]) : null; const label = match[13] ? match[13].trim().replace(/\s+/g, ' ') : null; // Trim edge spaces and shorten blocks of whitespace to 1 space
varsQueue.push( varsQueue.push(
{ type : 'varCallInline', { type : 'varCallInline',
@@ -679,14 +777,12 @@ const tableTerminators = [
]; ];
Marked.use(MarkedVariables()); Marked.use(MarkedVariables());
Marked.use(MarkedDefinitionLists()); Marked.use({ extensions : [justifiedParagraphs, definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks,
Marked.use({ extensions : [forcedParagraphBreaks, mustacheSpans, mustacheDivs, mustacheInjectInline] }); nonbreakingSpaces, mustacheSpans, mustacheDivs, mustacheInjectInline] });
Marked.use(mustacheInjectBlock); Marked.use(mustacheInjectBlock);
Marked.use(MarkedAlignedParagraphs());
Marked.use(MarkedSubSuperText()); Marked.use(MarkedSubSuperText());
Marked.use(MarkedNonbreakingSpaces());
Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false }); Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
Marked.use(MarkedExtendedTables({ interruptPatterns: tableTerminators }), MarkedGFMHeadingId({ globalSlugs: true }), Marked.use(MarkedExtendedTables({interruptPatterns : tableTerminators}), MarkedGFMHeadingId({ globalSlugs: true }),
MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions)); MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
function cleanUrl(href) { function cleanUrl(href) {
@@ -751,12 +847,12 @@ const processStyleTags = (string)=>{
obj[key.trim()] = value.trim(); obj[key.trim()] = value.trim();
return obj; return obj;
}, {}) || null; }, {}) || null;
const styles = tags?.length ? tags.reduce((styleObj, style)=>{ const styles = tags?.length ? tags.reduce((styleObj, style) => {
const index = style.indexOf(':'); const index = style.indexOf(':');
const [key, value] = [style.substring(0, index), style.substring(index + 1)]; const [key, value] = [style.substring(0, index), style.substring(index + 1)];
styleObj[key.trim()] = value.replace(/"?([^"]*)"?/g, '$1').trim(); styleObj[key.trim()] = value.replace(/"?([^"]*)"?/g, '$1').trim();
return styleObj; return styleObj;
}, {}) : null; }, {}) : null;
return { return {
id : id, id : id,
@@ -772,8 +868,8 @@ const extractHTMLStyleTags = (htmlString)=>{
const id = firstElementOnly.match(/id="([^"]*)"/)?.[1] || null; const id = firstElementOnly.match(/id="([^"]*)"/)?.[1] || null;
const classes = firstElementOnly.match(/class="([^"]*)"/)?.[1] || null; const classes = firstElementOnly.match(/class="([^"]*)"/)?.[1] || null;
const styles = firstElementOnly.match(/style="([^"]*)"/)?.[1] const styles = firstElementOnly.match(/style="([^"]*)"/)?.[1]
?.split(';').reduce((styleObj, style)=>{ ?.split(';').reduce((styleObj, style) => {
if(style.trim() === '') return styleObj; if (style.trim() === '') return styleObj;
const index = style.indexOf(':'); const index = style.indexOf(':');
const [key, value] = [style.substring(0, index), style.substring(index + 1)]; const [key, value] = [style.substring(0, index), style.substring(index + 1)];
styleObj[key.trim()] = value.trim(); styleObj[key.trim()] = value.trim();
@@ -783,7 +879,7 @@ const extractHTMLStyleTags = (htmlString)=>{
?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="')) ?.filter((attr)=>!attr.startsWith('class="') && !attr.startsWith('style="') && !attr.startsWith('id="'))
.reduce((obj, attr)=>{ .reduce((obj, attr)=>{
const index = attr.indexOf('='); const index = attr.indexOf('=');
const [key, value] = [attr.substring(0, index), attr.substring(index + 1)]; let [key, value] = [attr.substring(0, index), attr.substring(index + 1)];
obj[key.trim()] = value.replace(/"/g, ''); obj[key.trim()] = value.replace(/"/g, '');
return obj; return obj;
}, {}) || null; }, {}) || null;
@@ -796,7 +892,7 @@ const extractHTMLStyleTags = (htmlString)=>{
}; };
}; };
const mergeHTMLTags = (originalTags, newTags)=>{ const mergeHTMLTags = (originalTags, newTags) => {
return { return {
id : newTags.id || originalTags.id || null, id : newTags.id || originalTags.id || null,
classes : [originalTags.classes, newTags.classes].join(' ').trim() || null, classes : [originalTags.classes, newTags.classes].join(' ').trim() || null,
@@ -812,20 +908,14 @@ let globalPageNumber = 0;
const Markdown = { const Markdown = {
marked : Marked, marked : Marked,
render : (rawBrewText, pageNumber=0)=>{ render : (rawBrewText, pageNumber=0)=>{
const lastPageNumber = pageNumber > 0 ? globalVarsList[pageNumber - 1].HB_pageNumber.content : 0; globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order
globalVarsList[pageNumber] = { //Reset global links for current page, to ensure values are parsed in order
'HB_pageNumber' : { //Add document variables for this page
content : !isNaN(Number(lastPageNumber)) ? Number(lastPageNumber) + 1 : lastPageNumber,
resolved : true
}
};
varsQueue = []; //Could move into MarkedVariables() varsQueue = []; //Could move into MarkedVariables()
globalPageNumber = pageNumber; globalPageNumber = pageNumber;
if(pageNumber==0) { if(pageNumber==0) {
MarkedGFMResetHeadingIDs(); MarkedGFMResetHeadingIDs();
} }
rawBrewText = rawBrewText.replace(/^\\column(?:break)?$/gm, `\n<div class='columnSplit'></div>\n`); rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`);
const opts = Marked.defaults; const opts = Marked.defaults;

View File

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

View File

@@ -2,8 +2,7 @@ require('./splitPane.less');
const React = require('react'); const React = require('react');
const { useState, useEffect } = React; const { useState, useEffect } = React;
const PANE_WIDTH_KEY = 'HB_editor_splitWidth'; const storageKey = 'naturalcrit-pane-split';
const LIVE_SCROLL_KEY = 'HB_editor_liveScroll';
const SplitPane = (props)=>{ const SplitPane = (props)=>{
const { const {
@@ -19,9 +18,9 @@ const SplitPane = (props)=>{
const [liveScroll, setLiveScroll] = useState(false); const [liveScroll, setLiveScroll] = useState(false);
useEffect(()=>{ useEffect(()=>{
const savedPos = window.localStorage.getItem(PANE_WIDTH_KEY); const savedPos = window.localStorage.getItem(storageKey);
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2); setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2);
setLiveScroll(window.localStorage.getItem(LIVE_SCROLL_KEY) === 'true'); setLiveScroll(window.localStorage.getItem('liveScroll') === 'true');
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
return ()=>window.removeEventListener('resize', handleResize); return ()=>window.removeEventListener('resize', handleResize);
@@ -30,13 +29,13 @@ const SplitPane = (props)=>{
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x))); const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x)));
//when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position //when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position
const handleResize = ()=>setDividerPos(limitPosition(window.localStorage.getItem(PANE_WIDTH_KEY), 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13))); const handleResize = () =>setDividerPos(limitPosition(window.localStorage.getItem(storageKey), 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)));
const handleUp =(e)=>{ const handleUp =(e)=>{
e.preventDefault(); e.preventDefault();
if(isDragging) { if(isDragging) {
onDragFinish(dividerPos); onDragFinish(dividerPos);
window.localStorage.setItem(PANE_WIDTH_KEY, dividerPos); window.localStorage.setItem(storageKey, dividerPos);
} }
setIsDragging(false); setIsDragging(false);
}; };
@@ -53,7 +52,7 @@ const SplitPane = (props)=>{
}; };
const liveScrollToggle = ()=>{ const liveScrollToggle = ()=>{
window.localStorage.setItem(LIVE_SCROLL_KEY, String(!liveScroll)); window.localStorage.setItem('liveScroll', String(!liveScroll));
setLiveScroll(!liveScroll); setLiveScroll(!liveScroll);
}; };

View File

@@ -21,8 +21,8 @@
background-color : #BBBBBB; background-color : #BBBBBB;
.dots { .dots {
display : table-cell; display : table-cell;
vertical-align : middle;
text-align : center; text-align : center;
vertical-align : middle;
i { i {
display : block !important; display : block !important;
margin : 10px 0px; margin : 10px 0px;

View File

@@ -3,127 +3,127 @@
@defaultEasing : ease; @defaultEasing : ease;
//Animates all properties on an element //Animates all properties on an element
.animateAll(@duration : @defaultDuration, @easing : @defaultEasing) { .animateAll(@duration : @defaultDuration, @easing : @defaultEasing){
-webkit-transition : all @duration @easing; -webkit-transition: all @duration @easing;
-moz-transition : all @duration @easing; -moz-transition: all @duration @easing;
-o-transition : all @duration @easing; -o-transition: all @duration @easing;
transition : all @duration @easing; transition: all @duration @easing;
} }
//Animates Specific property //Animates Specific property
.animate(@prop, @duration : @defaultDuration, @easing : @defaultEasing) { .animate(@prop, @duration : @defaultDuration, @easing : @defaultEasing){
-webkit-transition : @prop @duration @easing; -webkit-transition: @prop @duration @easing;
-moz-transition : @prop @duration @easing; -moz-transition: @prop @duration @easing;
-o-transition : @prop @duration @easing; -o-transition: @prop @duration @easing;
transition : @prop @duration @easing; transition: @prop @duration @easing;
} }
.animateMany(...) { .animateMany(...){
@value: ~`"@{arguments}".replace(/[\[\]]|\,\sX/g, '')`; @value: ~`"@{arguments}".replace(/[\[\]]|\,\sX/g, '')`;
-webkit-transition-property : @value; -webkit-transition-property: @value;
-moz-transition-property : @value; -moz-transition-property: @value;
-o-transition-property : @value; -o-transition-property: @value;
transition-property : @value; transition-property: @value;
.animateDuration(); .animateDuration();
.animateEasing(); .animateEasing();
} }
.animateDuration(@duration : @defaultDuration) { .animateDuration(@duration : @defaultDuration){
-webkit-transition-duration : @duration; -webkit-transition-duration: @duration;
-moz-transition-duration : @duration; -moz-transition-duration: @duration;
-o-transition-duration : @duration; -o-transition-duration: @duration;
transition-duration : @duration; transition-duration: @duration;
} }
.animateEasing(@easing : @defaultEasing) { .animateEasing(@easing : @defaultEasing){
-webkit-transition-timing-function : @easing; -webkit-transition-timing-function: @easing;
-moz-transition-timing-function : @easing; -moz-transition-timing-function: @easing;
-o-transition-timing-function : @easing; -o-transition-timing-function: @easing;
transition-timing-function : @easing; transition-timing-function: @easing;
} }
.transition (@prop, @duration: @defaultDuration) { .transition (@prop, @duration: @defaultDuration) {
-webkit-transition : @prop @duration, -webkit-transform @duration; -webkit-transition: @prop @duration, -webkit-transform @duration;
-moz-transition : @prop @duration, -moz-transform @duration; -moz-transition: @prop @duration, -moz-transform @duration;
-o-transition : @prop @duration, -o-transform @duration; -o-transition: @prop @duration, -o-transform @duration;
-ms-transition : @prop @duration, -ms-transform @duration; -ms-transition: @prop @duration, -ms-transform @duration;
transition : @prop @duration, transform @duration; transition: @prop @duration, transform @duration;
} }
.transform (@transform) { .transform (@transform) {
-webkit-transform : @transform; -webkit-transform: @transform;
-moz-transform : @transform; -moz-transform: @transform;
-o-transform : @transform; -o-transform: @transform;
-ms-transform : @transform; -ms-transform: @transform;
transform : @transform; transform: @transform;
} }
.delay(@delay) { .delay(@delay){
-webkit-transition-delay : @delay; animation-delay:@delay;
transition-delay : @delay; -webkit-animation-delay:@delay;
-webkit-animation-delay : @delay; transition-delay:@delay;
animation-delay : @delay; -webkit-transition-delay:@delay;
} }
.keep() { .keep(){
-webkit-animation-fill-mode : forwards; -webkit-animation-fill-mode:forwards;
-moz-animation-fill-mode : forwards; -moz-animation-fill-mode:forwards;
-ms-animation-fill-mode : forwards; -ms-animation-fill-mode:forwards;
-o-animation-fill-mode : forwards; -o-animation-fill-mode:forwards;
animation-fill-mode : forwards; animation-fill-mode:forwards;
} }
.sequentialDelay(@delayInc : 0.2s, @initialDelay : 0s) { .sequentialDelay(@delayInc : 0.2s, @initialDelay : 0s){
&:nth-child(1) {.delay(0*@delayInc + @initialDelay); } &:nth-child(1){.delay(0*@delayInc + @initialDelay)}
&:nth-child(2) {.delay(1*@delayInc + @initialDelay); } &:nth-child(2){.delay(1*@delayInc + @initialDelay)}
&:nth-child(3) {.delay(2*@delayInc + @initialDelay); } &:nth-child(3){.delay(2*@delayInc + @initialDelay)}
&:nth-child(4) {.delay(3*@delayInc + @initialDelay); } &:nth-child(4){.delay(3*@delayInc + @initialDelay)}
&:nth-child(5) {.delay(4*@delayInc + @initialDelay); } &:nth-child(5){.delay(4*@delayInc + @initialDelay)}
&:nth-child(6) {.delay(5*@delayInc + @initialDelay); } &:nth-child(6){.delay(5*@delayInc + @initialDelay)}
&:nth-child(7) {.delay(6*@delayInc + @initialDelay); } &:nth-child(7){.delay(6*@delayInc + @initialDelay)}
&:nth-child(8) {.delay(7*@delayInc + @initialDelay); } &:nth-child(8){.delay(7*@delayInc + @initialDelay)}
&:nth-child(9) {.delay(8*@delayInc + @initialDelay); } &:nth-child(9){.delay(8*@delayInc + @initialDelay)}
&:nth-child(10) {.delay(9*@delayInc + @initialDelay); } &:nth-child(10){.delay(9*@delayInc + @initialDelay)}
&:nth-child(11) {.delay(10*@delayInc + @initialDelay); } &:nth-child(11){.delay(10*@delayInc + @initialDelay)}
&:nth-child(12) {.delay(11*@delayInc + @initialDelay); } &:nth-child(12){.delay(11*@delayInc + @initialDelay)}
&:nth-child(13) {.delay(12*@delayInc + @initialDelay); } &:nth-child(13){.delay(12*@delayInc + @initialDelay)}
&:nth-child(14) {.delay(13*@delayInc + @initialDelay); } &:nth-child(14){.delay(13*@delayInc + @initialDelay)}
&:nth-child(15) {.delay(14*@delayInc + @initialDelay); } &:nth-child(15){.delay(14*@delayInc + @initialDelay)}
&:nth-child(16) {.delay(15*@delayInc + @initialDelay); } &:nth-child(16){.delay(15*@delayInc + @initialDelay)}
&:nth-child(17) {.delay(16*@delayInc + @initialDelay); } &:nth-child(17){.delay(16*@delayInc + @initialDelay)}
&:nth-child(18) {.delay(17*@delayInc + @initialDelay); } &:nth-child(18){.delay(17*@delayInc + @initialDelay)}
&:nth-child(19) {.delay(18*@delayInc + @initialDelay); } &:nth-child(19){.delay(18*@delayInc + @initialDelay)}
&:nth-child(20) {.delay(19*@delayInc + @initialDelay); } &:nth-child(20){.delay(19*@delayInc + @initialDelay)}
} }
.createFrames(@name, @from, @to) { .createFrames(@name, @from, @to){
@frames: { @frames: {
from { @from(); } from { @from(); }
to { @to(); } to { @to(); }
}; };
@-webkit-keyframes @name {@frames();} @-webkit-keyframes @name {@frames();}
@-moz-keyframes @name {@frames();} @-moz-keyframes @name {@frames();}
@-ms-keyframes @name {@frames();} @-ms-keyframes @name {@frames();}
@-o-keyframes @name {@frames();} @-o-keyframes @name {@frames();}
@keyframes @name {@frames();} @keyframes @name {@frames();}
} }
.createAnimation(@name, @duration : @defaultDuration, @easing : @defaultEasing) { .createAnimation(@name, @duration : @defaultDuration, @easing : @defaultEasing){
-webkit-animation-name : @name; -webkit-animation-name: @name;
-moz-animation-name : @name; -moz-animation-name: @name;
-ms-animation-name : @name; -ms-animation-name: @name;
animation-name : @name; animation-name: @name;
-webkit-animation-duration : @duration; -webkit-animation-duration: @duration;
-moz-animation-duration : @duration; -moz-animation-duration: @duration;
-ms-animation-duration : @duration; -ms-animation-duration: @duration;
animation-duration : @duration; animation-duration: @duration;
-webkit-animation-timing-function : @easing; -webkit-animation-timing-function: @easing;
-moz-animation-timing-function : @easing; -moz-animation-timing-function: @easing;
-ms-animation-timing-function : @easing; -ms-animation-timing-function: @easing;
animation-timing-function : @easing; animation-timing-function: @easing;
} }
@@ -132,82 +132,82 @@
Standard Animations Standard Animations
****************************/ ****************************/
.fadeIn(@duration : @defaultDuration, @easing : @defaultEasing) { .fadeIn(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(fadeIn; @duration; @easing); .createAnimation(fadeIn; @duration; @easing);
.createFrames(fadeIn, .createFrames(fadeIn,
{ opacity : 0; }, { opacity : 0; },
{ opacity : 1; } { opacity : 1; }
); );
} }
.fadeInDown(@duration : @defaultDuration, @easing : @defaultEasing) { .fadeInDown(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(fadeInDown; @duration; @easing); .createAnimation(fadeInDown; @duration; @easing);
.createFrames(fadeInDown, .createFrames(fadeInDown,
{ opacity : 0; .transform(translateY(20px));}, { opacity : 0; .transform(translateY(20px));},
{ opacity : 1; .transform(translateY(0px));} { opacity : 1; .transform(translateY(0px));}
); );
} }
.fadeInTop(@duration : @defaultDuration, @easing : @defaultEasing) { .fadeInTop(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(fadeInTop; @duration; @easing); .createAnimation(fadeInTop; @duration; @easing);
.createFrames(fadeInTop, .createFrames(fadeInTop,
{ opacity : 0; .transform(translateY(-20px)); }, { opacity : 0; .transform(translateY(-20px)); },
{ opacity : 1; .transform(translateY(0px));} { opacity : 1; .transform(translateY(0px));}
); );
} }
.fadeInLeft(@duration : @defaultDuration, @easing : @defaultEasing) { .fadeInLeft(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(fadeInLeft; @duration; @easing); .createAnimation(fadeInLeft; @duration; @easing);
.createFrames(fadeInLeft, .createFrames(fadeInLeft,
{ opacity: 0; .transform(translateX(-20px));}, { opacity: 0; .transform(translateX(-20px));},
{ opacity: 1; .transform(translateX(0));} { opacity: 1; .transform(translateX(0));}
); );
} }
.fadeInRight(@duration : @defaultDuration, @easing : @defaultEasing) { .fadeInRight(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(fadeInRight; @duration; @easing); .createAnimation(fadeInRight; @duration; @easing);
.createFrames(fadeInRight, .createFrames(fadeInRight,
{ opacity: 0; .transform(translateX(20px));}, { opacity: 0; .transform(translateX(20px));},
{ opacity: 1; .transform(translateX(0));} { opacity: 1; .transform(translateX(0));}
); );
} }
.fadeOut(@duration : @defaultDuration, @easing : @defaultEasing) { .fadeOut(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(fadeOut; @duration; @easing); .createAnimation(fadeOut; @duration; @easing);
.createFrames(fadeOut, .createFrames(fadeOut,
{ opacity : 1; }, { opacity : 1; },
{ opacity : 0; } { opacity : 0; }
); );
} }
.fadeOutDown(@duration : @defaultDuration, @easing : @defaultEasing) { .fadeOutDown(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(fadeOutDown; @duration; @easing); .createAnimation(fadeOutDown; @duration; @easing);
.createFrames(fadeOutDown, .createFrames(fadeOutDown,
{ opacity : 1; .transform(translateY(0)); visibility: visible;}, { opacity : 1; .transform(translateY(0)); visibility: visible;},
{ opacity : 0; .transform(translateY(20px)); visibility: hidden;} { opacity : 0; .transform(translateY(20px)); visibility: hidden;}
); );
} }
.fadeOutTop(@duration : @defaultDuration, @easing : @defaultEasing) { .fadeOutTop(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(fadeOutTop; @duration; @easing); .createAnimation(fadeOutTop; @duration; @easing);
.createFrames(fadeOutTop, .createFrames(fadeOutTop,
{ opacity : 1; .transform(translateY(0)); }, { opacity : 1; .transform(translateY(0)); },
{ opacity : 0; .transform(translateY(-20px)); } { opacity : 0; .transform(translateY(-20px)); }
); );
} }
.fadeOutLeft(@duration : @defaultDuration, @easing : @defaultEasing) { .fadeOutLeft(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(fadeOutLeft; @duration; @easing); .createAnimation(fadeOutLeft; @duration; @easing);
.createFrames(fadeOutLeft, .createFrames(fadeOutLeft,
{ opacity : 1; .transform(translateX(0));}, { opacity : 1; .transform(translateX(0));},
{ opacity : 0; .transform(translateX(-20px));} { opacity : 0; .transform(translateX(-20px));}
); );
} }
.fadeOutRight(@duration : @defaultDuration, @easing : @defaultEasing) { .fadeOutRight(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(fadeOutRight; @duration; @easing); .createAnimation(fadeOutRight; @duration; @easing);
.createFrames(fadeOutRight, .createFrames(fadeOutRight,
{ opacity : 1; .transform(translateX(0));}, { opacity : 1; .transform(translateX(0));},
{ opacity : 0; .transform(translateX(20px));} { opacity : 0; .transform(translateX(20px));}
); );
} }
@@ -219,50 +219,50 @@
Fun Animations Fun Animations
****************************/ ****************************/
.spin(@duration : @defaultDuration, @easing : @defaultEasing) { .spin(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(spin, @duration, @easing); .createAnimation(spin, @duration, @easing);
.spinKeyFrames() { .spinKeyFrames(){
from { .transform(rotate(0deg)); } from { .transform(rotate(0deg)); }
to { .transform(rotate(360deg)); } to { .transform(rotate(360deg)); }
} }
@-webkit-keyframes spin {.spinKeyFrames();} @-webkit-keyframes spin {.spinKeyFrames();}
@-moz-keyframes spin {.spinKeyFrames();} @-moz-keyframes spin {.spinKeyFrames();}
@-ms-keyframes spin {.spinKeyFrames();} @-ms-keyframes spin {.spinKeyFrames();}
@-o-keyframes spin {.spinKeyFrames();} @-o-keyframes spin {.spinKeyFrames();}
@keyframes spin {.spinKeyFrames();} @keyframes spin {.spinKeyFrames();}
} }
.bounce(@duration : @defaultDuration, @easing : @defaultEasing) { .bounce(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(bounce, @duration, @easing); .createAnimation(bounce, @duration, @easing);
.bounceKeyFrames() { .bounceKeyFrames(){
0%, 20%, 50%, 80%, 100% { .transform(translateY(0));} 0%, 20%, 50%, 80%, 100% { .transform(translateY(0));}
40% { .transform(translateY(-30px));} 40% { .transform(translateY(-30px));}
60% { .transform(translateY(-15px));} 60% { .transform(translateY(-15px));}
} }
@-webkit-keyframes bounce {.bounceKeyFrames();} @-webkit-keyframes bounce {.bounceKeyFrames();}
@-moz-keyframes bounce {.bounceKeyFrames();} @-moz-keyframes bounce {.bounceKeyFrames();}
@-ms-keyframes bounce {.bounceKeyFrames();} @-ms-keyframes bounce {.bounceKeyFrames();}
@-o-keyframes bounce {.bounceKeyFrames();} @-o-keyframes bounce {.bounceKeyFrames();}
@keyframes bounce {.bounceKeyFrames();} @keyframes bounce {.bounceKeyFrames();}
} }
.pulse(@duration : @defaultDuration, @easing : @defaultEasing) { .pulse(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(pulse, @duration, @easing); .createAnimation(pulse, @duration, @easing);
.pulseKeyFrames() { .pulseKeyFrames(){
0% { .transform(scale(1));} 0% { .transform(scale(1));}
50% { .transform(scale(1.4));} 50% { .transform(scale(1.4));}
100% { .transform(scale(1));} 100% { .transform(scale(1));}
} }
@-webkit-keyframes pulse {.pulseKeyFrames();} @-webkit-keyframes pulse {.pulseKeyFrames();}
@-moz-keyframes pulse {.pulseKeyFrames();} @-moz-keyframes pulse {.pulseKeyFrames();}
@-ms-keyframes pulse {.pulseKeyFrames();} @-ms-keyframes pulse {.pulseKeyFrames();}
@-o-keyframes pulse {.pulseKeyFrames();} @-o-keyframes pulse {.pulseKeyFrames();}
@keyframes pulse {.pulseKeyFrames();} @keyframes pulse {.pulseKeyFrames();}
} }
.rubberBand(@duration : @defaultDuration, @easing : @defaultEasing) { .rubberBand(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(rubberBand, @duration, @easing); .createAnimation(rubberBand, @duration, @easing);
.rubberBandKeyFrames() { .rubberBandKeyFrames(){
0% {.transform(scale(1));} 0% {.transform(scale(1));}
30% {.transform(scaleX(1.25) scaleY(0.75));} 30% {.transform(scaleX(1.25) scaleY(0.75));}
40% {.transform(scaleX(0.75) scaleY(1.25));} 40% {.transform(scaleX(0.75) scaleY(1.25));}
@@ -270,32 +270,32 @@
100% {.transform(scale(1));} 100% {.transform(scale(1));}
} }
@-webkit-keyframes rubberBand {.rubberBandKeyFrames();} @-webkit-keyframes rubberBand {.rubberBandKeyFrames();}
@-moz-keyframes rubberBand {.rubberBandKeyFrames();} @-moz-keyframes rubberBand {.rubberBandKeyFrames();}
@-ms-keyframes rubberBand {.rubberBandKeyFrames();} @-ms-keyframes rubberBand {.rubberBandKeyFrames();}
@-o-keyframes rubberBand {.rubberBandKeyFrames();} @-o-keyframes rubberBand {.rubberBandKeyFrames();}
@keyframes rubberBand {.rubberBandKeyFrames();} @keyframes rubberBand {.rubberBandKeyFrames();}
} }
.shake(@duration : @defaultDuration, @easing : @defaultEasing) { .shake(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(shake, @duration, @easing); .createAnimation(shake, @duration, @easing);
.shakeKeyFrames() { .shakeKeyFrames(){
0%, 100% {.transform( translateX(0));} 0%, 100% {.transform( translateX(0));}
10%, 30%, 50%, 70%, 90% {.transform( translateX(-10px));} 10%, 30%, 50%, 70%, 90% {.transform( translateX(-10px));}
20%, 40%, 60%, 80% {.transform( translateX(10px));} 20%, 40%, 60%, 80% {.transform( translateX(10px));}
} }
@-webkit-keyframes shake {.shakeKeyFrames();} @-webkit-keyframes shake {.shakeKeyFrames();}
@-moz-keyframes shake {.shakeKeyFrames();} @-moz-keyframes shake {.shakeKeyFrames();}
@-ms-keyframes shake {.shakeKeyFrames();} @-ms-keyframes shake {.shakeKeyFrames();}
@-o-keyframes shake {.shakeKeyFrames();} @-o-keyframes shake {.shakeKeyFrames();}
@keyframes shake {.shakeKeyFrames();} @keyframes shake {.shakeKeyFrames();}
} }
.swing(@duration : @defaultDuration, @easing : @defaultEasing) { .swing(@duration : @defaultDuration, @easing : @defaultEasing){
-webkit-transform-origin : top center; -webkit-transform-origin: top center;
-ms-transform-origin : top center; -ms-transform-origin: top center;
transform-origin : top center; transform-origin: top center;
.createAnimation(swing, @duration, @easing); .createAnimation(swing, @duration, @easing);
.swingKeyFrames() { .swingKeyFrames(){
20% {.transform(rotate(15deg));} 20% {.transform(rotate(15deg));}
40% {.transform(rotate(-10deg));} 40% {.transform(rotate(-10deg));}
60% {.transform(rotate(5deg));} 60% {.transform(rotate(5deg));}
@@ -303,18 +303,18 @@
100% {.transform(rotate(0deg));} 100% {.transform(rotate(0deg));}
} }
@-webkit-keyframes swing {.swingKeyFrames();} @-webkit-keyframes swing {.swingKeyFrames();}
@-moz-keyframes swing {.swingKeyFrames();} @-moz-keyframes swing {.swingKeyFrames();}
@-ms-keyframes swing {.swingKeyFrames();} @-ms-keyframes swing {.swingKeyFrames();}
@-o-keyframes swing {.swingKeyFrames();} @-o-keyframes swing {.swingKeyFrames();}
@keyframes swing {.swingKeyFrames();} @keyframes swing {.swingKeyFrames();}
} }
.twist(@duration : @defaultDuration, @easing : @defaultEasing) { .twist(@duration : @defaultDuration, @easing : @defaultEasing){
-webkit-transform-origin : center center; -webkit-transform-origin: center center;
-ms-transform-origin : center center; -ms-transform-origin: center center;
transform-origin : center center; transform-origin: center center;
.createAnimation(swing, @duration, @easing); .createAnimation(swing, @duration, @easing);
.swingKeyFrames() { .swingKeyFrames(){
20% {.transform(rotate(15deg));} 20% {.transform(rotate(15deg));}
40% {.transform(rotate(-10deg));} 40% {.transform(rotate(-10deg));}
60% {.transform(rotate(5deg));} 60% {.transform(rotate(5deg));}
@@ -322,15 +322,15 @@
100% {.transform(rotate(0deg));} 100% {.transform(rotate(0deg));}
} }
@-webkit-keyframes swing {.swingKeyFrames();} @-webkit-keyframes swing {.swingKeyFrames();}
@-moz-keyframes swing {.swingKeyFrames();} @-moz-keyframes swing {.swingKeyFrames();}
@-ms-keyframes swing {.swingKeyFrames();} @-ms-keyframes swing {.swingKeyFrames();}
@-o-keyframes swing {.swingKeyFrames();} @-o-keyframes swing {.swingKeyFrames();}
@keyframes swing {.swingKeyFrames();} @keyframes swing {.swingKeyFrames();}
} }
.wobble(@duration : @defaultDuration, @easing : @defaultEasing) { .wobble(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(wobble, @duration, @easing); .createAnimation(wobble, @duration, @easing);
.wobbleKeyFrames() { .wobbleKeyFrames(){
0% {.transform(translateX(0%));} 0% {.transform(translateX(0%));}
15% {.transform(translateX(-25%) rotate(-5deg));} 15% {.transform(translateX(-25%) rotate(-5deg));}
30% {.transform(translateX(20%) rotate(3deg));} 30% {.transform(translateX(20%) rotate(3deg));}
@@ -340,22 +340,22 @@
100% {.transform(translateX(0%));} 100% {.transform(translateX(0%));}
} }
@-webkit-keyframes wobble {.wobbleKeyFrames();} @-webkit-keyframes wobble {.wobbleKeyFrames();}
@-moz-keyframes wobble {.wobbleKeyFrames();} @-moz-keyframes wobble {.wobbleKeyFrames();}
@-ms-keyframes wobble {.wobbleKeyFrames();} @-ms-keyframes wobble {.wobbleKeyFrames();}
@-o-keyframes wobble {.wobbleKeyFrames();} @-o-keyframes wobble {.wobbleKeyFrames();}
@keyframes wobble {.wobbleKeyFrames();} @keyframes wobble {.wobbleKeyFrames();}
} }
.popIn(@duration : @defaultDuration, @easing : @defaultEasing) { .popIn(@duration : @defaultDuration, @easing : @defaultEasing){
.createAnimation(popIn, @duration, @easing); .createAnimation(popIn, @duration, @easing);
.popInKeyFrames() { .popInKeyFrames(){
0% { .transform(scale(0));} 0% { .transform(scale(0));}
70% { .transform(scale(1.4));} 70% { .transform(scale(1.4));}
100% { .transform(scale(1));} 100% { .transform(scale(1));}
} }
@-webkit-keyframes popIn {.popInKeyFrames();} @-webkit-keyframes popIn {.popInKeyFrames();}
@-moz-keyframes popIn {.popInKeyFrames();} @-moz-keyframes popIn {.popInKeyFrames();}
@-ms-keyframes popIn {.popInKeyFrames();} @-ms-keyframes popIn {.popInKeyFrames();}
@-o-keyframes popIn {.popInKeyFrames();} @-o-keyframes popIn {.popInKeyFrames();}
@keyframes popIn {.popInKeyFrames();} @keyframes popIn {.popInKeyFrames();}
} }

View File

@@ -23,47 +23,47 @@
@grey : #7F8C8D; @grey : #7F8C8D;
#backgroundColors { #backgroundColors {
&.tealLight { background-color : @tealLight; }; &.tealLight{ background-color : @tealLight };
&.teal { background-color : @teal; }; &.teal{ background-color : @teal };
&.greenLight { background-color : @greenLight; }; &.greenLight{ background-color : @greenLight };
&.green { background-color : @green; }; &.green{ background-color : @green };
&.blueLight { background-color : @blueLight; }; &.blueLight{ background-color : @blueLight };
&.blue { background-color : @blue; }; &.blue{ background-color : @blue };
&.purpleLight { background-color : @purpleLight; }; &.purpleLight{ background-color : @purpleLight };
&.purple { background-color : @purple; }; &.purple{ background-color : @purple };
&.steelLight { background-color : @steelLight; }; &.steelLight{ background-color : @steelLight };
&.steel { background-color : @steel; }; &.steel{ background-color : @steel };
&.yellowLight { background-color : @yellowLight; }; &.yellowLight{ background-color : @yellowLight };
&.yellow { background-color : @yellow; }; &.yellow{ background-color : @yellow };
&.orangeLight { background-color : @orangeLight; }; &.orangeLight{ background-color : @orangeLight };
&.orange { background-color : @orange; }; &.orange{ background-color : @orange };
&.redLight { background-color : @redLight; }; &.redLight{ background-color : @redLight };
&.red { background-color : @red; }; &.red{ background-color : @red };
&.silverLight { background-color : @silverLight; }; &.silverLight{ background-color : @silverLight };
&.silver { background-color : @silver; }; &.silver{ background-color : @silver };
&.greyLight { background-color : @greyLight; }; &.greyLight{ background-color : @greyLight };
&.grey { background-color : @grey; }; &.grey{ background-color : @grey };
} }
#backgroundColorsHover { #backgroundColorsHover {
&.tealLight:hover { background-color : @tealLight; }; &.tealLight:hover{ background-color : @tealLight };
&.teal:hover { background-color : @teal; }; &.teal:hover{ background-color : @teal };
&.greenLight:hover { background-color : @greenLight; }; &.greenLight:hover{ background-color : @greenLight };
&.green:hover { background-color : @green; }; &.green:hover{ background-color : @green };
&.blueLight:hover { background-color : @blueLight; }; &.blueLight:hover{ background-color : @blueLight };
&.blue:hover { background-color : @blue; }; &.blue:hover{ background-color : @blue };
&.purpleLight:hover { background-color : @purpleLight; }; &.purpleLight:hover{ background-color : @purpleLight };
&.purple:hover { background-color : @purple; }; &.purple:hover{ background-color : @purple };
&.steelLight:hover { background-color : @steelLight; }; &.steelLight:hover{ background-color : @steelLight };
&.steel:hover { background-color : @steel; }; &.steel:hover{ background-color : @steel };
&.yellowLight:hover { background-color : @yellowLight; }; &.yellowLight:hover{ background-color : @yellowLight };
&.yellow:hover { background-color : @yellow; }; &.yellow:hover{ background-color : @yellow };
&.orangeLight:hover { background-color : @orangeLight; }; &.orangeLight:hover{ background-color : @orangeLight };
&.orange:hover { background-color : @orange; }; &.orange:hover{ background-color : @orange };
&.redLight:hover { background-color : @redLight; }; &.redLight:hover{ background-color : @redLight };
&.red:hover { background-color : @red; }; &.red:hover{ background-color : @red };
&.silverLight:hover { background-color : @silverLight; }; &.silverLight:hover{ background-color : @silverLight };
&.silver:hover { background-color : @silver; }; &.silver:hover{ background-color : @silver };
&.greyLight:hover { background-color : @greyLight; }; &.greyLight:hover{ background-color : @greyLight };
&.grey:hover { background-color : @grey; }; &.grey:hover{ background-color : @grey };
} }

View File

@@ -12,31 +12,37 @@
font-family : 'CodeBold'; font-family : 'CodeBold';
src : data-uri('naturalcrit/styles/CODE Bold.otf') format('opentype'); src : data-uri('naturalcrit/styles/CODE Bold.otf') format('opentype');
} }
html,body, #reactRoot { html,body, #reactRoot{
height : 100vh; height : 100vh;
min-height : 100vh; min-height : 100vh;
margin : 0; margin : 0;
font-family : 'Open Sans', sans-serif; font-family : 'Open Sans', sans-serif;
} }
* { box-sizing : border-box; } *{
.colorButton(@backgroundColor : @green) { box-sizing : border-box;
}
.colorButton(@backgroundColor : @green){
.animate(background-color); .animate(background-color);
display : inline-block; display : inline-block;
padding : 0.6em 1.2em; padding : 0.6em 1.2em;
cursor : pointer;
background-color : @backgroundColor;
font-family : 'Open Sans', sans-serif; font-family : 'Open Sans', sans-serif;
font-size : 0.8em; font-size : 0.8em;
font-weight : 800; font-weight : 800;
color : white; color : white;
text-transform : uppercase;
text-decoration : none; text-decoration : none;
cursor : pointer; text-transform : uppercase;
outline : none;
background-color : @backgroundColor;
border : none; border : none;
&:hover { background-color : darken(@backgroundColor, 5%); } outline : none;
&:active { background-color : darken(@backgroundColor, 10%); } &:hover{
&:disabled { background-color : darken(@backgroundColor, 5%);
cursor : not-allowed; }
&:active{
background-color : darken(@backgroundColor, 10%);
}
&:disabled{
background-color : @silver !important; background-color : @silver !important;
cursor:not-allowed;
} }
} }

View File

@@ -1,76 +1,86 @@
@containerWidth : 1000px; @containerWidth : 1000px;
html, body { html, body{
position : relative; position : relative;
height : 100%; height : 100%;
min-height : 100%; min-height : 100%;
background-color : #eee;
font-family : 'Lato', sans-serif; font-family : 'Lato', sans-serif;
color : @copyGrey; color : @copyGrey;
background-color : #EEEEEE;
} }
.container { .container{
position : relative; position : relative;
max-width : @containerWidth; max-width : @containerWidth;
margin : 0 auto;
padding-right : 20px; padding-right : 20px;
padding-left : 20px; padding-left : 20px;
margin : 0 auto;
} }
h1 { h1{
margin-top : 10px; margin-top : 10px;
margin-bottom : 15px; margin-bottom : 15px;
font-size : 2em; font-size : 2em;
} }
h2 { h2{
margin-top : 10px; margin-top : 10px;
margin-bottom : 15px; margin-bottom : 15px;
font-size : 1.5em; font-size : 1.5em;
font-weight : 900; font-weight : 900;
} }
h3 { h3{
margin-top : 5px; margin-top : 5px;
margin-bottom : 7px; margin-bottom : 7px;
font-size : 1em; font-size : 1em;
font-weight : 900; font-weight : 900;
} }
p { p{
margin-bottom : 1em; margin-bottom : 1em;
font-size : 16px; font-size : 16px;
line-height : 1.5em;
color : @copyGrey; color : @copyGrey;
line-height : 1.5em;
} }
code { code{
font-family : 'Courier', "mono"; background-color : #F8F8F8;
font-family : 'Courier', mono;
color : black; color : black;
white-space : pre; white-space : pre;
background-color : #F8F8F8;
} }
a { color : inherit; } a{
strong { font-weight : bold; } color : inherit;
button { }
strong{
font-weight : bold;
}
button{
.button(); .button();
} }
.button(@backgroundColor : @green) { .button(@backgroundColor : @green){
.animate(background-color); .animate(background-color);
display : inline-block; display : inline-block;
padding : 0.6em 1.2em; padding : 0.6em 1.2em;
font-family : 'Lato', "Helvetica", "Arial", sans-serif; cursor : pointer;
background-color : @backgroundColor;
font-family : "Lato", Helvetica, Arial, sans-serif;
font-size : 15px; font-size : 15px;
color : white; color : white;
text-decoration : none; text-decoration : none;
cursor : pointer;
outline : none;
background-color : @backgroundColor;
border : none; border : none;
&:hover { background-color : darken(@backgroundColor, 5%); } outline : none;
&:active { background-color : darken(@backgroundColor, 10%); } &:hover{
&:disabled { background-color : @silver !important; } background-color : darken(@backgroundColor, 5%);
}
&:active{
background-color : darken(@backgroundColor, 10%);
}
&:disabled{
background-color : @silver !important;
}
} }
.iconButton(@backgroundColor : @green) { .iconButton(@backgroundColor : @green){
padding : 0.6em; padding : 0.6em;
cursor : pointer;
background-color : @backgroundColor;
font-size : 14px; font-size : 14px;
color : white; color : white;
text-align : center; text-align : center;
cursor : pointer;
background-color : @backgroundColor;
} }

View File

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

View File

@@ -2,115 +2,116 @@
@tooltipColor : #383838; @tooltipColor : #383838;
@arrowSize : 6px; @arrowSize : 6px;
@arrowPosition : 18px; @arrowPosition : 18px;
[data-tooltip] { [data-tooltip]{
.tooltip(attr(data-tooltip)); .tooltip(attr(data-tooltip));
} }
[data-tooltip-top] { [data-tooltip-top]{
.tooltipTop(attr(data-tooltip-top)); .tooltipTop(attr(data-tooltip-top));
} }
[data-tooltip-bottom] { [data-tooltip-bottom]{
.tooltipBottom(attr(data-tooltip-bottom)); .tooltipBottom(attr(data-tooltip-bottom));
} }
[data-tooltip-left] { [data-tooltip-left]{
.tooltipLeft(attr(data-tooltip-left)); .tooltipLeft(attr(data-tooltip-left));
} }
[data-tooltip-right] { [data-tooltip-right]{
.tooltipRight(attr(data-tooltip-right)); .tooltipRight(attr(data-tooltip-right));
} }
.tooltip(@content) { .tooltip(@content){
.tooltipBottom(@content); .tooltipBottom(@content);
} }
.tooltipTop(@content) { .tooltipTop(@content){
.tooltipBase(@content); .tooltipBase(@content);
&::before { &:before {
margin-bottom : -@arrowSize * 2; margin-bottom : -@arrowSize * 2;
border-top-color : @tooltipColor; border-top-color : @tooltipColor;
} }
&::after { margin-left : -18px; } &:after{ margin-left: -18px; }
&::before, &::after { &:before, &:after{
bottom : 100%; bottom : 100%;
left : 50%; left : 50%;
} }
&:hover::after, &:hover::before, &:focus::after, &:focus::before { &:hover:after, &:hover:before, &:focus:after, &:focus:before {
.transform(translateY(-(@arrowSize + 2))); .transform(translateY(-(@arrowSize + 2)));
} }
} }
.tooltipBottom(@content) { .tooltipBottom(@content){
.tooltipBase(@content); .tooltipBase(@content);
&::before { &:before {
margin-top : -@arrowSize * 2; margin-top : -@arrowSize * 2;
border-bottom-color : @tooltipColor; border-bottom-color : @tooltipColor;
} }
&::after { margin-left : -18px; } &:after{ margin-left: -18px; }
&::before, &::after { &:before, &:after{
top : 100%; top : 100%;
left : 50%; left : 50%;
} }
&:hover::after, &:hover::before, &:focus::after, &:focus::before { &:hover:after, &:hover:before, &:focus:after, &:focus:before {
.transform(translateY(@arrowSize + 2)); .transform(translateY(@arrowSize + 2));
} }
} }
.tooltipLeft(@content) { .tooltipLeft(@content){
.tooltipBase(@content); .tooltipBase(@content);
&::before { &:before {
margin-right : -@arrowSize * 2; margin-right : -@arrowSize * 2;
margin-bottom : -@arrowSize; margin-bottom : -@arrowSize;
border-left-color : @tooltipColor; border-left-color : @tooltipColor;
} }
&::after { margin-bottom : -14px;} &:after{ margin-bottom: -14px;}
&::before, &::after { &:before, &:after {
right : 100%; right : 100%;
bottom : 50%; bottom : 50%;
} }
&:hover::after, &:hover::before, &:focus::after, &:focus::before { &:hover:after, &:hover:before, &:focus:after, &:focus:before {
.transform(translateX(-(@arrowSize + 2))); .transform(translateX(-(@arrowSize + 2)));
} }
} }
.tooltipRight(@content) { .tooltipRight(@content){
.tooltipBase(@content); .tooltipBase(@content);
&::before { &:before {
margin-bottom : -@arrowSize; margin-bottom : -@arrowSize;
margin-left : -@arrowSize * 2; margin-left : -@arrowSize * 2;
border-right-color : @tooltipColor; border-right-color : @tooltipColor;
} }
&::after { margin-bottom : -14px;} &:after{ margin-bottom: -14px;}
&::before, &::after { &:before, &:after {
bottom : 50%; bottom : 50%;
left : 100%; left : 100%;
} }
&:hover::after, &:hover::before, &:focus::after, &:focus::before { &:hover:after, &:hover:before, &:focus:after, &:focus:before {
.transform(translateX(@arrowSize + 2)); .transform(translateX(@arrowSize + 2));
} }
} }
.tooltipShow(){ } .tooltipShow(){
.tooltipBase(@content) { }
.tooltipBase(@content){
//position: relative; //position: relative;
&::before, &::after { &:before, &:after{
.animateAll(); .animateAll();
position : absolute; position : absolute;
z-index : 1000000; z-index : 1000000;
pointer-events : none;
opacity : 0; opacity : 0;
pointer-events : none;
} }
//Arrow //Arrow
&::before { &:before{
z-index : 1000001;
content : ''; content : '';
z-index : 1000001;
background : transparent; background : transparent;
border : @arrowSize solid transparent; border : @arrowSize solid transparent;
} }
//Box //Box
&::after { &:after{
content : @content;
visibility : hidden; visibility : hidden;
padding : 8px 10px; padding : 8px 10px;
font-size : 12px;
line-height : 12px;
color : white;
white-space : nowrap;
content : @content;
background : @tooltipColor; background : @tooltipColor;
font-size : 12px;
color : white;
line-height : 12px;
white-space : nowrap;
} }
&:hover::before, &:hover::after { &:hover:before, &:hover:after {
visibility : visible; visibility : visible;
opacity : 1; opacity : 1;
} }

View File

@@ -4,17 +4,6 @@ require('jsdom-global')();
import { safeHTML } from '../../client/homebrew/brewRenderer/safeHTML'; import { safeHTML } from '../../client/homebrew/brewRenderer/safeHTML';
test('Exit if no document', function() {
const doc = document;
document = undefined;
const result = safeHTML('');
document = doc;
expect(result).toBe(null);
});
test('Javascript via href', function() { test('Javascript via href', function() {
const source = `<a href="javascript:alert('This is a JavaScript injection via href attribute')">Click me</a>`; const source = `<a href="javascript:alert('This is a JavaScript injection via href attribute')">Click me</a>`;
const rendered = safeHTML(source); const rendered = safeHTML(source);

View File

@@ -1,4 +1,4 @@
/* eslint-disable max-lines */
import Markdown from 'naturalcrit/markdown.js'; import Markdown from 'naturalcrit/markdown.js';

View File

@@ -1,4 +1,4 @@
/* eslint-disable max-lines */
import Markdown from 'naturalcrit/markdown.js'; import Markdown from 'naturalcrit/markdown.js';
@@ -92,12 +92,12 @@ describe('Multiline Definition Lists', ()=>{
test('Multiline Definition Term must have at least one non-empty Definition', function() { test('Multiline Definition Term must have at least one non-empty Definition', function() {
const source = 'Term 1\n::'; const source = 'Term 1\n::';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Term 1</p>\n<div class='blank'></div>\n<div class='blank'></div>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Term 1</p>\n<br>\n<br>`);
}); });
test('Multiline Definition List must have at least one non-newline character after ::', function() { test('Multiline Definition List must have at least one non-newline character after ::', function() {
const source = 'Term 1\n::\nDefinition 1\n\n'; const source = 'Term 1\n::\nDefinition 1\n\n';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Term 1</p>\n<div class='blank'></div>\n<div class='blank'></div>\n<p>Definition 1</p>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Term 1</p>\n<br>\n<br>\n<p>Definition 1</p>`);
}); });
}); });

View File

@@ -1,4 +1,4 @@
/* eslint-disable max-lines */
import Markdown from 'naturalcrit/markdown.js'; import Markdown from 'naturalcrit/markdown.js';
@@ -6,37 +6,37 @@ describe('Hard Breaks', ()=>{
test('Single Break', function() { test('Single Break', function() {
const source = ':\n\n'; const source = ':\n\n';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<br>`);
}); });
test('Double Break', function() { test('Double Break', function() {
const source = '::\n\n'; const source = '::\n\n';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>\n<div class='blank'></div>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<br>\n<br>`);
}); });
test('Triple Break', function() { test('Triple Break', function() {
const source = ':::\n\n'; const source = ':::\n\n';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<br>\n<br>\n<br>`);
}); });
test('Many Break', function() { test('Many Break', function() {
const source = '::::::::::\n\n'; const source = '::::::::::\n\n';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<br>\n<br>\n<br>\n<br>\n<br>\n<br>\n<br>\n<br>\n<br>\n<br>`);
}); });
test('Multiple sets of Breaks', function() { test('Multiple sets of Breaks', function() {
const source = ':::\n:::\n:::'; const source = ':::\n:::\n:::';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>\n<div class='blank'></div>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<br>\n<br>\n<br>\n<br>\n<br>\n<br>\n<br>\n<br>\n<br>`);
}); });
test('Break directly between two paragraphs', function() { test('Break directly between two paragraphs', function() {
const source = 'Line 1\n::\nLine 2'; const source = 'Line 1\n::\nLine 2';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Line 1</p>\n<div class='blank'></div>\n<div class='blank'></div>\n<p>Line 2</p>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Line 1</p>\n<br>\n<br>\n<p>Line 2</p>`);
}); });
test('Ignored inside a code block', function() { test('Ignored inside a code block', function() {

View File

@@ -1,24 +1,72 @@
/* eslint-disable max-lines */
import Markdown from 'naturalcrit/markdown.js'; import Markdown from 'naturalcrit/markdown.js';
describe('Non-Breaking Spaces Interactions', ()=>{ describe('Non-Breaking Spaces', ()=>{
test('Single Space', function() {
const source = ':>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;</p>`);
});
test('Double Space', function() {
const source = ':>>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;</p>`);
});
test('Triple Space', function() {
const source = ':>>>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;</p>`);
});
test('Many Space', function() {
const source = ':>>>>>>>>>>\n\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</p>`);
});
test('Multiple sets of Spaces', function() {
const source = ':>>>\n:>>>\n:>>>';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;\n&nbsp;&nbsp;&nbsp;\n&nbsp;&nbsp;&nbsp;</p>`);
});
test('Pair of inline Spaces', function() {
const source = ':>>:>>';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>&nbsp;&nbsp;&nbsp;&nbsp;</p>`);
});
test('Space directly between two paragraphs', function() {
const source = 'Line 1\n:>>\nLine 2';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<p>Line 1\n&nbsp;&nbsp;\nLine 2</p>`);
});
test('Ignored inside a code block', function() {
const source = '```\n\n:>\n\n```\n';
const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<pre><code>\n:&gt;\n</code></pre>`);
});
test('I am actually a single-line definition list!', function() { test('I am actually a single-line definition list!', function() {
const source = 'Term ::> Definition 1\n'; const source = 'Term ::> Definition 1\n';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt><dd>&gt; Definition 1</dd>\n</dl>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt><dd>> Definition 1</dd>\n</dl>`);
}); });
test('I am actually a definition list!', function() { test('I am actually a definition list!', function() {
const source = 'Term\n::> Definition 1\n'; const source = 'Term\n::> Definition 1\n';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>&gt; Definition 1</dd></dl>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>> Definition 1</dd></dl>`);
}); });
test('I am actually a two-term definition list!', function() { test('I am actually a two-term definition list!', function() {
const source = 'Term\n::> Definition 1\n::>> Definition 2'; const source = 'Term\n::> Definition 1\n::>> Definition 2';
const rendered = Markdown.render(source).trim(); const rendered = Markdown.render(source).trim();
expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>&gt; Definition 1</dd>\n<dd>&gt;&gt; Definition 2</dd></dl>`); expect(rendered, `Input:\n${source}`, { showPrefix: false }).toBe(`<dl><dt>Term</dt>\n<dd>> Definition 1</dd>\n<dd>>> Definition 2</dd></dl>`);
}); });
}); });

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