mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-01-23 16:33:05 +00:00
Compare commits
297 Commits
3.19.1
...
cleanupLoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
643e0ac650 | ||
|
|
5395412ac5 | ||
|
|
dc4610ea1b | ||
|
|
1e71e9e18a | ||
|
|
4203e90d09 | ||
|
|
dc94555c94 | ||
|
|
41aebf084b | ||
|
|
74e17e154f | ||
|
|
a944b23ca0 | ||
|
|
12052853db | ||
|
|
c0f67bef5a | ||
|
|
8f715a6615 | ||
|
|
1f51abaf10 | ||
|
|
c90a8c53a5 | ||
|
|
ac18f4bd1d | ||
|
|
7393aef806 | ||
|
|
2c4c4b8f92 | ||
|
|
c751d647d9 | ||
|
|
6057b35d19 | ||
|
|
521d42f32f | ||
|
|
e9f8302597 | ||
|
|
f429b1755d | ||
|
|
20e12ebcb5 | ||
|
|
ae51213c8c | ||
|
|
8f7ae35f08 | ||
|
|
44023f390c | ||
|
|
48b95712e2 | ||
|
|
16c28e16ce | ||
|
|
379b518c6b | ||
|
|
962dcbdbf6 | ||
|
|
400fa250ee | ||
|
|
e82921f81a | ||
|
|
18367526bd | ||
|
|
f0bb06e706 | ||
|
|
aff9a85769 | ||
|
|
e0379a0baa | ||
|
|
e8a0681015 | ||
|
|
3ed61ebe2c | ||
|
|
c2e51b0baa | ||
|
|
bc258f5239 | ||
|
|
e64fc83ea6 | ||
|
|
ee6d2ac35d | ||
|
|
f22f7196ca | ||
|
|
ba23763294 | ||
|
|
7f832a55db | ||
|
|
1c6a39363c | ||
|
|
bcca5fa97d | ||
|
|
51b91567f6 | ||
|
|
bfe6142b04 | ||
|
|
aef835dfe7 | ||
|
|
274fbcb29e | ||
|
|
eefda9fe45 | ||
|
|
900cf6aebb | ||
|
|
24db8f85ac | ||
|
|
82a8db129e | ||
|
|
6d4ad6af7d | ||
|
|
e793db7b37 | ||
|
|
ff5450ad8c | ||
|
|
4b753970c9 | ||
|
|
07495b0dea | ||
|
|
718dba3e4a | ||
|
|
c6ed67db08 | ||
|
|
c50c279ef3 | ||
|
|
cc246fb31a | ||
|
|
fb75bd46d0 | ||
|
|
c5071aa27e | ||
|
|
f0baa763ec | ||
|
|
3ec650557e | ||
|
|
242ff8712f | ||
|
|
31a8101df7 | ||
|
|
788324fe31 | ||
|
|
da8772daa7 | ||
|
|
87a36bb02d | ||
|
|
1459f6a320 | ||
|
|
a11fa72261 | ||
|
|
2663d86627 | ||
|
|
8d4ea7cfd8 | ||
|
|
b6818e963b | ||
|
|
dc1bc471aa | ||
|
|
5504c1b96b | ||
|
|
fd370c777d | ||
|
|
58277585e1 | ||
|
|
885c0105f3 | ||
|
|
52486495c8 | ||
|
|
328e071268 | ||
|
|
088ca9971c | ||
|
|
c99f59d42b | ||
|
|
cb3eb77c61 | ||
|
|
7163b1a287 | ||
|
|
08d228831d | ||
|
|
ad8bb34c93 | ||
|
|
02a7920b2c | ||
|
|
43c639246b | ||
|
|
c2e6150edf | ||
|
|
95a1d74644 | ||
|
|
1044aa74b0 | ||
|
|
8a0f350c47 | ||
|
|
6f2c397574 | ||
|
|
8706f91b58 | ||
|
|
1eb5b6d3a4 | ||
|
|
90f6e7ec37 | ||
|
|
90a81237ec | ||
|
|
883f59ff0d | ||
|
|
a75364c7f6 | ||
|
|
597ce7cb48 | ||
|
|
d94afa9c50 | ||
|
|
13de195a66 | ||
|
|
32f9a44acf | ||
|
|
bb32f9fe95 | ||
|
|
63f4f5712e | ||
|
|
ede7ad683a | ||
|
|
172c11646a | ||
|
|
bbeac49552 | ||
|
|
1aeded648e | ||
|
|
c1ebc68cd4 | ||
|
|
93b86632fc | ||
|
|
d01860d4de | ||
|
|
86ac11e512 | ||
|
|
9c336062c6 | ||
|
|
2cd47c46f6 | ||
|
|
8671404bdc | ||
|
|
601fc732b0 | ||
|
|
fb3ab47ab0 | ||
|
|
518a3434be | ||
|
|
d01f4fb77e | ||
|
|
6600d9344c | ||
|
|
0371635e11 | ||
|
|
53f6e48f8f | ||
|
|
da578c53a8 | ||
|
|
986bfdd00a | ||
|
|
15c04ef37e | ||
|
|
8cf55932a9 | ||
|
|
759dcb5833 | ||
|
|
83c3eacf83 | ||
|
|
8a788a6ebf | ||
|
|
7198c21229 | ||
|
|
6c3a5f193d | ||
|
|
f1ad1b9124 | ||
|
|
593a98db9a | ||
|
|
e25c3daad6 | ||
|
|
96b175e74d | ||
|
|
8924685c26 | ||
|
|
74c9d7b3f1 | ||
|
|
cd378cad0c | ||
|
|
ce304996f0 | ||
|
|
029c105ff1 | ||
|
|
1f81cc9af0 | ||
|
|
6ac6eae863 | ||
|
|
a47a1a25a4 | ||
|
|
0500ac305a | ||
|
|
e1a441b04a | ||
|
|
b98c297079 | ||
|
|
90dfc75ce9 | ||
|
|
dd46a059c5 | ||
|
|
2d881b8dc9 | ||
|
|
e023bfeef6 | ||
|
|
8b351925c1 | ||
|
|
5ddd631dfd | ||
|
|
4c5eef46a0 | ||
|
|
5ff6327c72 | ||
|
|
c993a1a8c9 | ||
|
|
b9372f17d9 | ||
|
|
6b7c57f0e4 | ||
|
|
6c5063a30d | ||
|
|
e20da7c67f | ||
|
|
3596eabbf5 | ||
|
|
fb4ca21cb4 | ||
|
|
99769c90f8 | ||
|
|
301c50cca9 | ||
|
|
320f1e120f | ||
|
|
cca9ebefdb | ||
|
|
aebc49c2d4 | ||
|
|
1eb226ea13 | ||
|
|
8049b5be9d | ||
|
|
a1ab27b57f | ||
|
|
a8dab28fcf | ||
|
|
253dbb358b | ||
|
|
719edd82c5 | ||
|
|
16d7b11b8d | ||
|
|
e2ed7b8600 | ||
|
|
63d957fdc6 | ||
|
|
7751c0e37b | ||
|
|
990bf80b59 | ||
|
|
f16598f238 | ||
|
|
4d014bf379 | ||
|
|
4856c803ed | ||
|
|
d9cd270f3b | ||
|
|
878ea1449d | ||
|
|
579e9e0ec5 | ||
|
|
f6629f2f9e | ||
|
|
b87c78474d | ||
|
|
d6a5a1f03c | ||
|
|
f04d6cdd1f | ||
|
|
4fd61ce92c | ||
|
|
958d282a58 | ||
|
|
7e56ae2019 | ||
|
|
ebca50ed4b | ||
|
|
bfd14757c2 | ||
|
|
3626ed5a31 | ||
|
|
d385bacdd6 | ||
|
|
cbbb2c0a7d | ||
|
|
fbe637ff82 | ||
|
|
82bd16c623 | ||
|
|
d1f13af67b | ||
|
|
b6c03e88b8 | ||
|
|
b587d17397 | ||
|
|
0a02f910f8 | ||
|
|
ddfa06e76b | ||
|
|
0c2b1fec04 | ||
|
|
6de7a64acd | ||
|
|
b9fe4c3901 | ||
|
|
5ae01862e5 | ||
|
|
398df7a061 | ||
|
|
443b0f6a37 | ||
|
|
544175b994 | ||
|
|
955602e7ee | ||
|
|
90e577dd3f | ||
|
|
828208aadb | ||
|
|
973e071e93 | ||
|
|
f9e7aa355d | ||
|
|
24dfd41714 | ||
|
|
638e54535d | ||
|
|
cbc6956221 | ||
|
|
248d2038ec | ||
|
|
5b66175b8c | ||
|
|
552aa7d41a | ||
|
|
b0a108b543 | ||
|
|
505d2840c0 | ||
|
|
41ff50fefe | ||
|
|
2fbcc84a50 | ||
|
|
45e4d27c0a | ||
|
|
77bf3ffc6f | ||
|
|
bc045ec6c9 | ||
|
|
6390ea076a | ||
|
|
6affcb587d | ||
|
|
7787afabff | ||
|
|
fb4a8e5cf1 | ||
|
|
8432a6e367 | ||
|
|
90ee08de42 | ||
|
|
40839b18e4 | ||
|
|
677c02cfa5 | ||
|
|
a7a8803e9d | ||
|
|
5fbc111db7 | ||
|
|
5edea7d0f4 | ||
|
|
d3a9d813c9 | ||
|
|
fc475b2a7e | ||
|
|
76b76b3bb6 | ||
|
|
22ef3cbebc | ||
|
|
9da8a17053 | ||
|
|
7cadbfbd7b | ||
|
|
98b9e86787 | ||
|
|
489b4b2694 | ||
|
|
8d279260c2 | ||
|
|
7c08c430d0 | ||
|
|
45689d119e | ||
|
|
c5805af935 | ||
|
|
b2c4bb7082 | ||
|
|
68460447dc | ||
|
|
440c7beff6 | ||
|
|
c7610cf0f8 | ||
|
|
7f3a818558 | ||
|
|
bc82afa5b2 | ||
|
|
abef250631 | ||
|
|
1794e96d50 | ||
|
|
25f25da499 | ||
|
|
aa15bdaacb | ||
|
|
7ba7991631 | ||
|
|
0e1ac26999 | ||
|
|
f49fed8c35 | ||
|
|
a8236fbab4 | ||
|
|
daf4eceedd | ||
|
|
a02361ee65 | ||
|
|
81e20f032e | ||
|
|
1d92b98568 | ||
|
|
0f4157d084 | ||
|
|
4dcc3749d8 | ||
|
|
8f058d56f2 | ||
|
|
99b90e0998 | ||
|
|
702ece6671 | ||
|
|
1008321957 | ||
|
|
b547486c48 | ||
|
|
6bb0b8001b | ||
|
|
6d0d6f08b5 | ||
|
|
77dcc9b433 | ||
|
|
88b70d340e | ||
|
|
ed05d8c754 | ||
|
|
077aaeb815 | ||
|
|
50d2a0d3a2 | ||
|
|
17f60ee159 | ||
|
|
e842599b22 | ||
|
|
5648e55774 | ||
|
|
a917937f12 | ||
|
|
b54448f830 | ||
|
|
b88480c9ba | ||
|
|
a8897b2813 | ||
|
|
cb139ae775 | ||
|
|
89a788ff9f |
@@ -49,7 +49,7 @@ Make an changes you need to `config/docker.json` then build the image. If it doe
|
||||
"web_port" : 8000,
|
||||
"enable_v3" : true,
|
||||
"mongodb_uri": "mongodb://172.17.0.2/homebrewery",
|
||||
"enable_themes" : true,
|
||||
"enable_themes" : true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -90,6 +90,13 @@ docker run --name homebrewery-mongodb -d --restart unless-stopped -v mongodata:/
|
||||
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v $(pwd)/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
|
||||
```
|
||||
|
||||
**NOTE:** If you are running from the Windows command line, this will not work as `$(pwd)` is not valid syntax. Use this command instead:
|
||||
```shell
|
||||
# Make sure you run this in the homebrewery directory
|
||||
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v %cd%/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
|
||||
```
|
||||
|
||||
|
||||
## Updating the Image
|
||||
|
||||
When Homebrewery code updates, your docker container will not automatically follow the changes. To do so you will need to rebuild your homebrewery image.
|
||||
@@ -117,3 +124,9 @@ docker-compose build homebrewery
|
||||
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v $(pwd)/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
|
||||
```
|
||||
|
||||
**NOTE:** If you are running from the Windows command line, this will not work as `$(pwd)` is not valid syntax. Use this command instead:
|
||||
```shell
|
||||
# Make sure you run this in the homebrewery directory
|
||||
docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v %cd%/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest
|
||||
```
|
||||
|
||||
|
||||
@@ -75,8 +75,9 @@ it using the two commands:
|
||||
1. `npm install`
|
||||
1. `npm start`
|
||||
|
||||
You should now be able to go to [http://localhost:8000](http://localhost:8000)
|
||||
in your browser and use The Homebrewery offline.
|
||||
When the Homebrewery server is started for the first time, it will modify the database to create the indexes required for better Homebrewery performance. This may take a few moments to complete for each index, dependent on how much content is in your local database - a brand new, empty database should be done in seconds.
|
||||
|
||||
On completion, you should be able to go to [http://localhost:8000](http://localhost:8000) in your browser and use The Homebrewery offline.
|
||||
|
||||
If you had any issue at all, here are some links that may be useful:
|
||||
- [Course](https://learn.mongodb.com/courses/m103-basic-cluster-administration) on cluster administration, useful for beginners
|
||||
@@ -145,3 +146,4 @@ your contribution to the project, please join our [gitter chat][gitter-url].
|
||||
[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
|
||||
|
||||
|
||||
|
||||
15
changelog.md
15
changelog.md
@@ -88,6 +88,21 @@ pre {
|
||||
## changelog
|
||||
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
|
||||
|
||||
@@ -7,15 +7,17 @@ import LockTools from './lockTools/lockTools.jsx';
|
||||
|
||||
const tabGroups = ['brew', 'notifications', 'authors', 'locks'];
|
||||
|
||||
const ADMIN_TAB = 'HB_adminPage_currentTab';
|
||||
|
||||
const Admin = ()=>{
|
||||
const [currentTab, setCurrentTab] = useState('');
|
||||
|
||||
useEffect(()=>{
|
||||
setCurrentTab(localStorage.getItem('hbAdminTab') || 'brew');
|
||||
setCurrentTab(localStorage.getItem(ADMIN_TAB) || 'brew');
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
localStorage.setItem('hbAdminTab', currentTab);
|
||||
localStorage.setItem(ADMIN_TAB, currentTab);
|
||||
}, [currentTab]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,8 @@ require('./splitPane.less');
|
||||
const React = require('react');
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const storageKey = 'naturalcrit-pane-split';
|
||||
const PANE_WIDTH_KEY = 'HB_editor_splitWidth';
|
||||
const LIVE_SCROLL_KEY = 'HB_editor_liveScroll';
|
||||
|
||||
const SplitPane = (props)=>{
|
||||
const {
|
||||
@@ -18,9 +19,9 @@ const SplitPane = (props)=>{
|
||||
const [liveScroll, setLiveScroll] = useState(false);
|
||||
|
||||
useEffect(()=>{
|
||||
const savedPos = window.localStorage.getItem(storageKey);
|
||||
const savedPos = window.localStorage.getItem(PANE_WIDTH_KEY);
|
||||
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2);
|
||||
setLiveScroll(window.localStorage.getItem('liveScroll') === 'true');
|
||||
setLiveScroll(window.localStorage.getItem(LIVE_SCROLL_KEY) === 'true');
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return ()=>window.removeEventListener('resize', handleResize);
|
||||
@@ -29,13 +30,13 @@ const SplitPane = (props)=>{
|
||||
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x)));
|
||||
|
||||
//when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position
|
||||
const handleResize = ()=>setDividerPos(limitPosition(window.localStorage.getItem(storageKey), 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)));
|
||||
const handleResize = ()=>setDividerPos(limitPosition(window.localStorage.getItem(PANE_WIDTH_KEY), 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)));
|
||||
|
||||
const handleUp =(e)=>{
|
||||
e.preventDefault();
|
||||
if(isDragging) {
|
||||
onDragFinish(dividerPos);
|
||||
window.localStorage.setItem(storageKey, dividerPos);
|
||||
window.localStorage.setItem(PANE_WIDTH_KEY, dividerPos);
|
||||
}
|
||||
setIsDragging(false);
|
||||
};
|
||||
@@ -52,7 +53,7 @@ const SplitPane = (props)=>{
|
||||
};
|
||||
|
||||
const liveScrollToggle = ()=>{
|
||||
window.localStorage.setItem('liveScroll', String(!liveScroll));
|
||||
window.localStorage.setItem(LIVE_SCROLL_KEY, String(!liveScroll));
|
||||
setLiveScroll(!liveScroll);
|
||||
};
|
||||
|
||||
@@ -20,8 +20,12 @@ import HeaderNav from './headerNav/headerNav.jsx';
|
||||
import { safeHTML } from './safeHTML.js';
|
||||
|
||||
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
|
||||
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
|
||||
const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m;
|
||||
const PAGE_HEIGHT = 1056;
|
||||
|
||||
const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
|
||||
|
||||
const INITIAL_CONTENT = dedent`
|
||||
<!DOCTYPE html><html><head>
|
||||
<link href="//fonts.googleapis.com/css?family=Open+Sans:400,300,600,700" rel="stylesheet" type="text/css" />
|
||||
@@ -37,8 +41,8 @@ const BrewPage = (props)=>{
|
||||
index : 0,
|
||||
...props
|
||||
};
|
||||
const pageRef = useRef(null);
|
||||
const cleanText = safeHTML(`${props.contents}\n<div class="columnSplit"></div>\n`);
|
||||
const pageRef = useRef(null);
|
||||
const cleanText = safeHTML(props.contents);
|
||||
|
||||
useEffect(()=>{
|
||||
if(!pageRef.current) return;
|
||||
@@ -120,7 +124,7 @@ const BrewRenderer = (props)=>{
|
||||
|
||||
//useEffect to store or gather toolbar state from storage
|
||||
useEffect(()=>{
|
||||
const toolbarState = JSON.parse(window.localStorage.getItem('hb_toolbarState'));
|
||||
const toolbarState = JSON.parse(window.localStorage.getItem(TOOLBAR_STATE_KEY));
|
||||
toolbarState && setDisplayOptions(toolbarState);
|
||||
}, []);
|
||||
|
||||
@@ -130,7 +134,7 @@ const BrewRenderer = (props)=>{
|
||||
const pagesRef = useRef(null);
|
||||
|
||||
if(props.renderer == 'legacy') {
|
||||
rawPages = props.text.split('\\page');
|
||||
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
|
||||
} else {
|
||||
rawPages = props.text.split(PAGEBREAK_REGEX_V3);
|
||||
}
|
||||
@@ -187,6 +191,7 @@ const BrewRenderer = (props)=>{
|
||||
let attributes = {};
|
||||
|
||||
if(props.renderer == 'legacy') {
|
||||
pageText.replace(COLUMNBREAK_REGEX_LEGACY, '```\n````\n'); // Allow Legacy brews to use `\column(break)`
|
||||
const html = MarkdownLegacy.render(pageText);
|
||||
|
||||
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
|
||||
@@ -281,7 +286,7 @@ const BrewRenderer = (props)=>{
|
||||
|
||||
const handleDisplayOptionsChange = (newDisplayOptions)=>{
|
||||
setDisplayOptions(newDisplayOptions);
|
||||
localStorage.setItem('hb_toolbarState', JSON.stringify(newDisplayOptions));
|
||||
localStorage.setItem(TOOLBAR_STATE_KEY, JSON.stringify(newDisplayOptions));
|
||||
};
|
||||
|
||||
const pagesStyle = {
|
||||
@@ -290,12 +295,6 @@ const BrewRenderer = (props)=>{
|
||||
rowGap : `${displayOptions.rowGap}px`
|
||||
};
|
||||
|
||||
const styleObject = {};
|
||||
|
||||
if(global.config.deployment) {
|
||||
styleObject.backgroundImage = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='40px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${global.config.deployment}</text></svg>")`;
|
||||
}
|
||||
|
||||
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
|
||||
renderedPages = useMemo(()=>renderPages(), [props.text, displayOptions]);
|
||||
|
||||
@@ -324,10 +323,9 @@ const BrewRenderer = (props)=>{
|
||||
contentDidMount={frameDidMount}
|
||||
onClick={()=>{emitClick();}}
|
||||
>
|
||||
<div className={`brewRenderer ${global.config.deployment && 'deployment'}`}
|
||||
<div className='brewRenderer'
|
||||
onKeyDown={handleControlKeys}
|
||||
tabIndex={-1}
|
||||
style={ styleObject }
|
||||
>
|
||||
|
||||
{/* Apply CSS from Style tab and render pages from Markdown tab */}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
overflow-y : scroll;
|
||||
will-change : transform;
|
||||
&:has(.facing, .flow) { padding : 60px 30px; }
|
||||
&.deployment { background-color : darkred; }
|
||||
:where(.pages) {
|
||||
&.facing {
|
||||
display : grid;
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anch
|
||||
const MAX_ZOOM = 300;
|
||||
const MIN_ZOOM = 10;
|
||||
|
||||
const TOOLBAR_VISIBILITY = 'HB_renderer_toolbarVisibility';
|
||||
|
||||
const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages, headerState, setHeaderState })=>{
|
||||
|
||||
const [pageNum, setPageNum] = useState(1);
|
||||
@@ -21,8 +23,8 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
||||
}, [visiblePages]);
|
||||
|
||||
useEffect(()=>{
|
||||
const Visibility = localStorage.getItem('hb_toolbarVisibility');
|
||||
if (Visibility) setToolsVisible(Visibility === 'true');
|
||||
const Visibility = localStorage.getItem(TOOLBAR_VISIBILITY);
|
||||
if(Visibility) setToolsVisible(Visibility === 'true');
|
||||
|
||||
}, []);
|
||||
|
||||
@@ -100,7 +102,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
||||
<div className='toggleButton'>
|
||||
<button title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{
|
||||
setToolsVisible(!toolsVisible);
|
||||
localStorage.setItem('hb_toolbarVisibility', !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>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const CodeEditor = require('naturalcrit/codeEditor/codeEditor.jsx');
|
||||
const SnippetBar = require('./snippetbar/snippetbar.jsx');
|
||||
const MetadataEditor = require('./metadataEditor/metadataEditor.jsx');
|
||||
|
||||
const EDITOR_THEME_KEY = 'HOMEBREWERY-EDITOR-THEME';
|
||||
const EDITOR_THEME_KEY = 'HB_editor_theme';
|
||||
|
||||
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
|
||||
const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/;
|
||||
@@ -40,11 +40,8 @@ const Editor = createClass({
|
||||
style : ''
|
||||
},
|
||||
|
||||
onTextChange : ()=>{},
|
||||
onStyleChange : ()=>{},
|
||||
onMetaChange : ()=>{},
|
||||
onSnipChange : ()=>{},
|
||||
reportError : ()=>{},
|
||||
onBrewChange : ()=>{},
|
||||
reportError : ()=>{},
|
||||
|
||||
onCursorPageChange : ()=>{},
|
||||
onViewPageChange : ()=>{},
|
||||
@@ -143,7 +140,7 @@ const Editor = createClass({
|
||||
|
||||
handleViewChange : function(newView){
|
||||
this.props.setMoveArrows(newView === 'text');
|
||||
|
||||
|
||||
this.setState({
|
||||
view : newView
|
||||
}, ()=>{
|
||||
@@ -438,7 +435,7 @@ const Editor = createClass({
|
||||
language='gfm'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.text}
|
||||
onChange={this.props.onTextChange}
|
||||
onChange={this.props.onBrewChange('text')}
|
||||
editorTheme={this.state.editorTheme}
|
||||
rerenderParent={this.rerenderParent}
|
||||
style={{ height: `calc(100% - ${this.state.snippetbarHeight}px)` }} />
|
||||
@@ -451,7 +448,7 @@ const Editor = createClass({
|
||||
language='css'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
|
||||
onChange={this.props.onStyleChange}
|
||||
onChange={this.props.onBrewChange('style')}
|
||||
enableFolding={true}
|
||||
editorTheme={this.state.editorTheme}
|
||||
rerenderParent={this.rerenderParent}
|
||||
@@ -467,7 +464,7 @@ const Editor = createClass({
|
||||
<MetadataEditor
|
||||
metadata={this.props.brew}
|
||||
themeBundle={this.props.themeBundle}
|
||||
onChange={this.props.onMetaChange}
|
||||
onChange={this.props.onBrewChange('metadata')}
|
||||
reportError={this.props.reportError}
|
||||
userThemes={this.props.userThemes}/>
|
||||
</>;
|
||||
@@ -481,7 +478,7 @@ const Editor = createClass({
|
||||
language='gfm'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.snippets}
|
||||
onChange={this.props.onSnipChange}
|
||||
onChange={this.props.onBrewChange('snippets')}
|
||||
enableFolding={true}
|
||||
editorTheme={this.state.editorTheme}
|
||||
rerenderParent={this.rerenderParent}
|
||||
|
||||
@@ -1,95 +1,87 @@
|
||||
//╔===--------------- Polyfills --------------===╗//
|
||||
import 'core-js/es/string/to-well-formed.js';
|
||||
//╚===--------------- ---------------===╝//
|
||||
/* eslint-disable camelcase */
|
||||
import 'core-js/es/string/to-well-formed.js'; //Polyfill for older browsers
|
||||
import './homebrew.less';
|
||||
import React from 'react';
|
||||
import { StaticRouter as Router, Route, Routes, useParams, useSearchParams } from 'react-router';
|
||||
|
||||
require('./homebrew.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const { StaticRouter:Router } = require('react-router');
|
||||
const { Route, Routes, useParams, useSearchParams } = require('react-router');
|
||||
import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js';
|
||||
|
||||
const HomePage = require('./pages/homePage/homePage.jsx');
|
||||
const EditPage = require('./pages/editPage/editPage.jsx');
|
||||
const UserPage = require('./pages/userPage/userPage.jsx');
|
||||
const SharePage = require('./pages/sharePage/sharePage.jsx');
|
||||
const NewPage = require('./pages/newPage/newPage.jsx');
|
||||
const ErrorPage = require('./pages/errorPage/errorPage.jsx');
|
||||
const VaultPage = require('./pages/vaultPage/vaultPage.jsx');
|
||||
const AccountPage = require('./pages/accountPage/accountPage.jsx');
|
||||
import HomePage from './pages/homePage/homePage.jsx';
|
||||
import EditPage from './pages/editPage/editPage.jsx';
|
||||
import UserPage from './pages/userPage/userPage.jsx';
|
||||
import SharePage from './pages/sharePage/sharePage.jsx';
|
||||
import NewPage from './pages/newPage/newPage.jsx';
|
||||
import ErrorPage from './pages/errorPage/errorPage.jsx';
|
||||
import VaultPage from './pages/vaultPage/vaultPage.jsx';
|
||||
import AccountPage from './pages/accountPage/accountPage.jsx';
|
||||
|
||||
const WithRoute = (props)=>{
|
||||
const WithRoute = ({ el: Element, ...rest })=>{
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryParams = {};
|
||||
for (const [key, value] of searchParams?.entries() || []) {
|
||||
queryParams[key] = value;
|
||||
}
|
||||
const Element = props.el;
|
||||
const allProps = {
|
||||
...props,
|
||||
...params,
|
||||
query : queryParams,
|
||||
el : undefined
|
||||
};
|
||||
return <Element {...allProps} />;
|
||||
const queryParams = Object.fromEntries(searchParams?.entries() || []);
|
||||
return <Element {...rest} {...params} query={queryParams} />;
|
||||
};
|
||||
|
||||
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 : ''
|
||||
}
|
||||
};
|
||||
},
|
||||
const Homebrew = (props)=>{
|
||||
const {
|
||||
url = '',
|
||||
version = '0.0.0',
|
||||
account = null,
|
||||
enable_v3 = false,
|
||||
enable_themes,
|
||||
config,
|
||||
brew = {
|
||||
title : '',
|
||||
text : '',
|
||||
shareId : null,
|
||||
editId : null,
|
||||
createdAt : null,
|
||||
updatedAt : null,
|
||||
lang : ''
|
||||
},
|
||||
userThemes,
|
||||
brews
|
||||
} = props;
|
||||
|
||||
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;
|
||||
global.account = account;
|
||||
global.version = version;
|
||||
global.enable_v3 = enable_v3;
|
||||
global.enable_themes = enable_themes;
|
||||
global.config = config;
|
||||
|
||||
return {};
|
||||
},
|
||||
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();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
});
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = Homebrew;
|
||||
@@ -1,12 +1,14 @@
|
||||
@import 'naturalcrit/styles/core.less';
|
||||
.homebrew {
|
||||
height : 100%;
|
||||
background-color:@steel;
|
||||
&.deployment { background-color : darkred; }
|
||||
|
||||
.sitePage {
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
height : 100%;
|
||||
overflow-y : hidden;
|
||||
background-color : @steel;
|
||||
.content {
|
||||
position : relative;
|
||||
flex : auto;
|
||||
|
||||
@@ -1,157 +1,138 @@
|
||||
require('./error-navitem.less');
|
||||
const React = require('react');
|
||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||
const createClass = require('create-react-class');
|
||||
|
||||
const ErrorNavItem = createClass({
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
error : '',
|
||||
parent : null
|
||||
};
|
||||
},
|
||||
render : function() {
|
||||
const clearError = ()=>{
|
||||
const state = {
|
||||
error : null
|
||||
};
|
||||
if(this.props.parent.state.isSaving) {
|
||||
state.isSaving = false;
|
||||
}
|
||||
this.props.parent.setState(state);
|
||||
};
|
||||
const ErrorNavItem = ({error = '', clearError})=>{
|
||||
const response = error.response;
|
||||
const errorCode = error.code
|
||||
const status = response?.status;
|
||||
const HBErrorCode = response?.body?.HBErrorCode;
|
||||
const message = response?.body?.message;
|
||||
|
||||
const error = this.props.error;
|
||||
const response = error.response;
|
||||
const status = response?.status;
|
||||
const errorCode = error.code
|
||||
const HBErrorCode = response?.body?.HBErrorCode;
|
||||
const message = response?.body?.message;
|
||||
let errMsg = '';
|
||||
try {
|
||||
errMsg += `${error.toString()}\n\n`;
|
||||
errMsg += `\`\`\`\n${error.stack}\n`;
|
||||
errMsg += `${JSON.stringify(response?.error, null, ' ')}\n\`\`\``;
|
||||
console.log(errMsg);
|
||||
} catch (e){}
|
||||
|
||||
if(status === 409) {
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
||||
Oops!
|
||||
<div className='errorContainer' onClick={clearError}>
|
||||
{message ?? 'Conflict: please refresh to get latest changes'}
|
||||
</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
if(status === 412) {
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
||||
Oops!
|
||||
<div className='errorContainer' onClick={clearError}>
|
||||
{message ?? 'Your client is out of date. Please save your changes elsewhere and refresh.'}
|
||||
</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
if(HBErrorCode === '04') {
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
||||
Oops!
|
||||
<div className='errorContainer' onClick={clearError}>
|
||||
You are no longer signed in as an author of
|
||||
this brew! Were you signed out from a different
|
||||
window? Visit our log in page, then try again!
|
||||
<br></br>
|
||||
<a target='_blank' rel='noopener noreferrer'
|
||||
href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
|
||||
<div className='confirm'>
|
||||
Sign In
|
||||
</div>
|
||||
</a>
|
||||
<div className='deny'>
|
||||
Not Now
|
||||
</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(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>;
|
||||
}
|
||||
let errMsg = '';
|
||||
try {
|
||||
errMsg += `${error.toString()}\n\n`;
|
||||
errMsg += `\`\`\`\n${error.stack}\n`;
|
||||
errMsg += `${JSON.stringify(response?.error, null, ' ')}\n\`\`\``;
|
||||
console.log(errMsg);
|
||||
} catch (e){}
|
||||
|
||||
if(status === 409) {
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
||||
Oops!
|
||||
<div className='errorContainer'>
|
||||
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 className='errorContainer' onClick={clearError}>
|
||||
{message ?? 'Conflict: please refresh to get latest changes'}
|
||||
</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
});
|
||||
|
||||
if(status === 412) {
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
||||
Oops!
|
||||
<div className='errorContainer' onClick={clearError}>
|
||||
{message ?? 'Your client is out of date. Please save your changes elsewhere and refresh.'}
|
||||
</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
if(HBErrorCode === '04') {
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
||||
Oops!
|
||||
<div className='errorContainer' onClick={clearError}>
|
||||
You are no longer signed in as an author of
|
||||
this brew! Were you signed out from a different
|
||||
window? Visit our log in page, then try again!
|
||||
<br></br>
|
||||
<a target='_blank' rel='noopener noreferrer'
|
||||
href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
|
||||
<div className='confirm'>
|
||||
Sign In
|
||||
</div>
|
||||
</a>
|
||||
<div className='deny'>
|
||||
Not Now
|
||||
</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(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;
|
||||
|
||||
@@ -5,33 +5,45 @@ const { splitTextStyleAndMetadata } = require('../../../shared/helpers.js'); //
|
||||
|
||||
const BREWKEY = 'homebrewery-new';
|
||||
const STYLEKEY = 'homebrewery-new-style';
|
||||
const METAKEY = 'homebrewery-new-meta';
|
||||
const METAKEY = 'homebrewery-new-meta';
|
||||
|
||||
const NewBrew = ()=>{
|
||||
const handleFileChange = (e)=>{
|
||||
const file = e.target.files[0];
|
||||
if(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e)=>{
|
||||
const fileContent = e.target.result;
|
||||
const newBrew = {
|
||||
text : fileContent,
|
||||
style : ''
|
||||
};
|
||||
if(fileContent.startsWith('```metadata')) {
|
||||
splitTextStyleAndMetadata(newBrew); // Modify newBrew directly
|
||||
localStorage.setItem(BREWKEY, newBrew.text);
|
||||
localStorage.setItem(STYLEKEY, newBrew.style);
|
||||
localStorage.setItem(METAKEY, JSON.stringify(_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])));
|
||||
window.location.href = '/new';
|
||||
} else {
|
||||
alert('This file is invalid, please, enter a valid file');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
if(!file) return;
|
||||
|
||||
const currentNew = localStorage.getItem(BREWKEY);
|
||||
if(currentNew && !confirm(
|
||||
`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?`
|
||||
)) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e)=>{
|
||||
const fileContent = e.target.result;
|
||||
const newBrew = { text: fileContent, style: '' };
|
||||
|
||||
if(fileContent.startsWith('```metadata')) {
|
||||
splitTextStyleAndMetadata(newBrew);
|
||||
localStorage.setItem(BREWKEY, newBrew.text);
|
||||
localStorage.setItem(STYLEKEY, newBrew.style);
|
||||
localStorage.setItem(METAKEY, JSON.stringify(
|
||||
_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])
|
||||
));
|
||||
window.location.href = '/new';
|
||||
return;
|
||||
}
|
||||
|
||||
const type = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
alert(`This file is invalid: ${!type ? "Missing file extension" :`.${type} files are not supported`}. Only .txt files exported from the Homebrewery are allowed.`);
|
||||
|
||||
|
||||
console.log(file);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Nav.dropdown>
|
||||
<Nav.item
|
||||
|
||||
@@ -5,8 +5,8 @@ const Moment = require('moment');
|
||||
|
||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||
|
||||
const EDIT_KEY = 'homebrewery-recently-edited';
|
||||
const VIEW_KEY = 'homebrewery-recently-viewed';
|
||||
const EDIT_KEY = 'HB_nav_recentlyEdited';
|
||||
const VIEW_KEY = 'HB_nav_recentlyViewed';
|
||||
|
||||
|
||||
const RecentItems = createClass({
|
||||
|
||||
35
client/homebrew/navbar/share.navitem.jsx
Normal file
35
client/homebrew/navbar/share.navitem.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
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>
|
||||
);
|
||||
@@ -13,7 +13,7 @@ const AccountPage = (props)=>{
|
||||
// initialize save location from local storage based on user id
|
||||
React.useEffect(()=>{
|
||||
if(!saveLocation && accountDetails.username) {
|
||||
SAVEKEY = `HOMEBREWERY-DEFAULT-SAVE-LOCATION-${accountDetails.username}`;
|
||||
SAVEKEY = `HB_editor_defaultSave_${accountDetails.username}`;
|
||||
// if no SAVEKEY in local storage, default save location to Google Drive if user has Google account.
|
||||
let saveLocation = window.localStorage.getItem(SAVEKEY);
|
||||
saveLocation = saveLocation ?? (accountDetails.googleId ? 'GOOGLE-DRIVE' : 'HOMEBREWERY');
|
||||
|
||||
@@ -7,7 +7,9 @@ const moment = require('moment');
|
||||
|
||||
const BrewItem = require('./brewItem/brewItem.jsx');
|
||||
|
||||
const USERPAGE_KEY_PREFIX = 'HOMEBREWERY-LISTPAGE';
|
||||
const USERPAGE_SORT_DIR = 'HB_listPage_sortDir';
|
||||
const USERPAGE_SORT_TYPE = 'HB_listPage_sortType';
|
||||
const USERPAGE_GROUP_VISIBILITY_PREFIX = 'HB_listPage_visibility_group';
|
||||
|
||||
const DEFAULT_SORT_TYPE = 'alpha';
|
||||
const DEFAULT_SORT_DIR = 'asc';
|
||||
@@ -50,12 +52,12 @@ const ListPage = createClass({
|
||||
|
||||
// LOAD FROM LOCAL STORAGE
|
||||
if(typeof window !== 'undefined') {
|
||||
const newSortType = (this.state.sortType ?? (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-SORTTYPE`) || DEFAULT_SORT_TYPE));
|
||||
const newSortDir = (this.state.sortDir ?? (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-SORTDIR`) || DEFAULT_SORT_DIR));
|
||||
const newSortType = (this.state.sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE));
|
||||
const newSortDir = (this.state.sortDir ?? (localStorage.getItem(USERPAGE_SORT_DIR) || DEFAULT_SORT_DIR));
|
||||
this.updateUrl(this.state.filterString, newSortType, newSortDir);
|
||||
|
||||
const brewCollection = this.props.brewCollection.map((brewGroup)=>{
|
||||
brewGroup.visible = (localStorage.getItem(`${USERPAGE_KEY_PREFIX}-VISIBILITY-${brewGroup.class}`) ?? 'true')=='true';
|
||||
brewGroup.visible = (localStorage.getItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`) ?? 'true')=='true';
|
||||
return brewGroup;
|
||||
});
|
||||
|
||||
@@ -73,10 +75,10 @@ const ListPage = createClass({
|
||||
|
||||
saveToLocalStorage : function() {
|
||||
this.state.brewCollection.map((brewGroup)=>{
|
||||
localStorage.setItem(`${USERPAGE_KEY_PREFIX}-VISIBILITY-${brewGroup.class}`, `${brewGroup.visible}`);
|
||||
localStorage.setItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`, `${brewGroup.visible}`);
|
||||
});
|
||||
localStorage.setItem(`${USERPAGE_KEY_PREFIX}-SORTTYPE`, this.state.sortType);
|
||||
localStorage.setItem(`${USERPAGE_KEY_PREFIX}-SORTDIR`, this.state.sortDir);
|
||||
localStorage.setItem(USERPAGE_SORT_TYPE, this.state.sortType);
|
||||
localStorage.setItem(USERPAGE_SORT_DIR, this.state.sortDir);
|
||||
},
|
||||
|
||||
renderBrews : function(brews){
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
&::before {
|
||||
margin-right : 5px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-weight : 900;
|
||||
content : '\f00c';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,520 +1,415 @@
|
||||
/* eslint-disable max-lines */
|
||||
require('./editPage.less');
|
||||
const React = require('react');
|
||||
const _ = require('lodash');
|
||||
const createClass = require('create-react-class');
|
||||
import {makePatches, applyPatches, stringifyPatches, parsePatches} from '@sanity/diff-match-patch';
|
||||
import { md5 } from 'hash-wasm';
|
||||
import './editPage.less';
|
||||
|
||||
import request from '../../utils/request-middleware.js';
|
||||
const { Meta } = require('vitreum/headtags');
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from 'naturalcrit/markdown.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||
const Navbar = require('../../navbar/navbar.jsx');
|
||||
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
|
||||
|
||||
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
|
||||
const HelpNavItem = require('../../navbar/help.navitem.jsx');
|
||||
const PrintNavItem = require('../../navbar/print.navitem.jsx');
|
||||
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
|
||||
const Account = require('../../navbar/account.navitem.jsx');
|
||||
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
|
||||
const VaultNavItem = require('../../navbar/vault.navitem.jsx');
|
||||
import SplitPane from 'client/components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
|
||||
const Editor = require('../../editor/editor.jsx');
|
||||
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
|
||||
import Nav from 'naturalcrit/nav/nav.jsx';
|
||||
import Navbar from '../../navbar/navbar.jsx';
|
||||
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';
|
||||
|
||||
const LockNotification = require('./lockNotification/lockNotification.jsx');
|
||||
|
||||
import Markdown from 'naturalcrit/markdown.js';
|
||||
|
||||
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
|
||||
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
|
||||
// Page specific imports
|
||||
import { Meta } from 'vitreum/headtags';
|
||||
import { md5 } from 'hash-wasm';
|
||||
import { gzipSync, strToU8 } from 'fflate';
|
||||
import { makePatches, stringifyPatches } from '@sanity/diff-match-patch';
|
||||
|
||||
import ShareNavItem from '../../navbar/share.navitem.jsx';
|
||||
import LockNotification from './lockNotification/lockNotification.jsx';
|
||||
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
|
||||
|
||||
const googleDriveIcon = require('../../googleDrive.svg');
|
||||
import googleDriveIcon from '../../googleDrive.svg';
|
||||
|
||||
const SAVE_TIMEOUT = 10000;
|
||||
const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes
|
||||
const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds
|
||||
|
||||
const EditPage = createClass({
|
||||
displayName : 'EditPage',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brew : DEFAULT_BREW_LOAD
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
brew : this.props.brew,
|
||||
isSaving : false,
|
||||
unsavedChanges : 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 AUTOSAVE_KEY = 'HB_editor_autoSaveOn';
|
||||
const BREWKEY = 'HB_newPage_content';
|
||||
const STYLEKEY = 'HB_newPage_style';
|
||||
const SNIPKEY = 'HB_newPage_snippets';
|
||||
const METAKEY = 'HB_newPage_meta';
|
||||
|
||||
editor : React.createRef(null),
|
||||
savedBrew : null,
|
||||
const useLocalStorage = false;
|
||||
const neverSaved = false;
|
||||
|
||||
componentDidMount : function(){
|
||||
this.setState({
|
||||
url : window.location.href
|
||||
});
|
||||
const EditPage = (props)=>{
|
||||
props = {
|
||||
brew : DEFAULT_BREW_LOAD,
|
||||
...props
|
||||
};
|
||||
|
||||
this.savedBrew = JSON.parse(JSON.stringify(this.props.brew)); //Deep copy
|
||||
const [currentBrew , setCurrentBrew ] = useState(props.brew);
|
||||
const [isSaving , setIsSaving ] = useState(false);
|
||||
const [lastSavedTime , setLastSavedTime ] = useState(new Date());
|
||||
const [saveGoogle , setSaveGoogle ] = useState(!!props.brew.googleId);
|
||||
const [error , setError ] = useState(null);
|
||||
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
|
||||
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
const [themeBundle , setThemeBundle ] = useState({});
|
||||
const [unsavedChanges , setUnsavedChanges ] = useState(false);
|
||||
const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed);
|
||||
const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false);
|
||||
const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false);
|
||||
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true);
|
||||
const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true);
|
||||
|
||||
this.setState({ autoSave: JSON.parse(localStorage.getItem('AUTOSAVE_ON')) ?? true }, ()=>{
|
||||
if(this.state.autoSave){
|
||||
this.trySave();
|
||||
} else {
|
||||
this.setState({ autoSaveWarning: true });
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
const saveTimeout = useRef(null);
|
||||
const warnUnsavedTimeout = useRef(null);
|
||||
const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
|
||||
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
|
||||
|
||||
useEffect(()=>{
|
||||
const autoSavePref = JSON.parse(localStorage.getItem(AUTOSAVE_KEY) ?? true);
|
||||
setAutoSaveEnabled(autoSavePref);
|
||||
setWarnUnsavedChanges(!autoSavePref);
|
||||
setHTMLErrors(Markdown.validate(currentBrew.text));
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySaveRef.current(true);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = ()=>{
|
||||
if(this.state.isSaving || this.state.unsavedChanges){
|
||||
if(unsavedChangesRef.current)
|
||||
return 'You have unsaved changes!';
|
||||
}
|
||||
};
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
window.onBeforeUnload = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
this.setState((prevState)=>({
|
||||
htmlErrors : Markdown.validate(prevState.brew.text)
|
||||
}));
|
||||
useEffect(()=>{
|
||||
trySaveRef.current = trySave;
|
||||
unsavedChangesRef.current = unsavedChanges;
|
||||
});
|
||||
|
||||
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
document.addEventListener('keydown', this.handleControlKeys);
|
||||
},
|
||||
componentWillUnmount : function() {
|
||||
window.onbeforeunload = function(){};
|
||||
document.removeEventListener('keydown', this.handleControlKeys);
|
||||
},
|
||||
componentDidUpdate : function(){
|
||||
const hasChange = this.hasChanges();
|
||||
if(this.state.unsavedChanges != hasChange){
|
||||
this.setState({
|
||||
unsavedChanges : 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
|
||||
}));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
handleControlKeys : function(e){
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
const S_KEY = 83;
|
||||
const P_KEY = 80;
|
||||
if(e.keyCode == S_KEY) this.trySave(true);
|
||||
if(e.keyCode == P_KEY) printCurrentBrew();
|
||||
if(e.keyCode == P_KEY || e.keyCode == S_KEY){
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({
|
||||
...prevBrew,
|
||||
style : newData.style,
|
||||
text : newData.text,
|
||||
snippets : newData.snippets
|
||||
}));
|
||||
|
||||
handleSplitMove : function(){
|
||||
this.editor.current.update();
|
||||
},
|
||||
const resetWarnUnsavedTimer = ()=>{
|
||||
setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings
|
||||
};
|
||||
|
||||
handleEditorViewPageChange : function(pageNumber){
|
||||
this.setState({ currentEditorViewPageNum: pageNumber });
|
||||
},
|
||||
|
||||
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();});
|
||||
},
|
||||
|
||||
handleSnipChange : function(snippet){
|
||||
//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(snippet);
|
||||
|
||||
this.setState((prevState)=>({
|
||||
brew : { ...prevState.brew, snippets: snippet },
|
||||
unsavedChanges : true,
|
||||
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.state.isSaving)
|
||||
return;
|
||||
|
||||
if(immediate) {
|
||||
this.debounceSave();
|
||||
this.debounceSave.flush();
|
||||
return;
|
||||
}
|
||||
|
||||
if(this.hasChanges())
|
||||
this.debounceSave();
|
||||
else
|
||||
this.debounceSave.cancel();
|
||||
},
|
||||
|
||||
handleGoogleClick : function(){
|
||||
const handleGoogleClick = ()=>{
|
||||
if(!global.account?.googleId) {
|
||||
this.setState({
|
||||
alertLoginToTransfer : true
|
||||
});
|
||||
setAlertLoginToTransfer(true);
|
||||
return;
|
||||
}
|
||||
this.setState((prevState)=>({
|
||||
confirmGoogleTransfer : !prevState.confirmGoogleTransfer
|
||||
}));
|
||||
this.setState({
|
||||
error : null
|
||||
});
|
||||
},
|
||||
|
||||
closeAlerts : function(event){
|
||||
event.stopPropagation(); //Only handle click once so alert doesn't reopen
|
||||
this.setState({
|
||||
alertTrashedGoogleBrew : false,
|
||||
alertLoginToTransfer : false,
|
||||
confirmGoogleTransfer : false
|
||||
});
|
||||
},
|
||||
setConfirmGoogleTransfer((prev)=>!prev);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
toggleGoogleStorage : function(){
|
||||
this.setState((prevState)=>({
|
||||
saveGoogle : !prevState.saveGoogle,
|
||||
error : null
|
||||
}), ()=>this.trySave(true));
|
||||
},
|
||||
const closeAlerts = (e)=>{
|
||||
e.stopPropagation(); //Only handle click once so alert doesn't reopen
|
||||
setAlertTrashedGoogleBrew(false);
|
||||
setAlertLoginToTransfer(false);
|
||||
setConfirmGoogleTransfer(false);
|
||||
};
|
||||
|
||||
save : async function(){
|
||||
if(this.debounceSave && this.debounceSave.cancel) this.debounceSave.cancel();
|
||||
const toggleGoogleStorage = ()=>{
|
||||
setSaveGoogle((prev)=>!prev);
|
||||
setError(null);
|
||||
trySave(true);
|
||||
};
|
||||
|
||||
this.setState((prevState)=>({
|
||||
isSaving : true,
|
||||
error : null,
|
||||
htmlErrors : Markdown.validate(prevState.brew.text)
|
||||
}));
|
||||
const trySave = (immediate = false, hasChanges = true)=>{
|
||||
clearTimeout(saveTimeout.current);
|
||||
if(isSaving) return;
|
||||
if(!hasChanges && !immediate) return;
|
||||
const newTimeout = immediate ? 0 : SAVE_TIMEOUT;
|
||||
|
||||
await updateHistory(this.state.brew).catch(console.error);
|
||||
saveTimeout.current = setTimeout(async ()=>{
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
await save(currentBrew, saveGoogle)
|
||||
.catch((err)=>{
|
||||
setError(err);
|
||||
});
|
||||
setIsSaving(false);
|
||||
setLastSavedTime(new Date());
|
||||
if(!autoSaveEnabled) resetWarnUnsavedTimer();
|
||||
}, newTimeout);
|
||||
};
|
||||
|
||||
const save = async (brew, saveToGoogle)=>{
|
||||
setHTMLErrors(Markdown.validate(brew.text));
|
||||
|
||||
await updateHistory(brew).catch(console.error);
|
||||
await versionHistoryGarbageCollection().catch(console.error);
|
||||
|
||||
const preSaveSnapshot = { ...this.state.brew }
|
||||
|
||||
//Prepare content to send to server
|
||||
const brew = { ...this.state.brew };
|
||||
brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
|
||||
brew.patches = makePatches(this.savedBrew.text, brew.text);
|
||||
brew.hash = await md5(this.savedBrew.text);
|
||||
brew.text = undefined;
|
||||
brew.textBin = undefined;
|
||||
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 transfer = saveToGoogle === _.isNil(brew.googleId);
|
||||
const params = transfer ? `?${saveToGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : '';
|
||||
|
||||
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
|
||||
const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`;
|
||||
const res = await request
|
||||
.put(`/api/update/${brew.editId}${params}`)
|
||||
.send(brew)
|
||||
.put(`/api/update/${brewToSave.editId}${params}`)
|
||||
.set('Content-Encoding', 'gzip')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(compressedBrew)
|
||||
.catch((err)=>{
|
||||
console.log('Error Updating Local Brew');
|
||||
this.setState({ error: err });
|
||||
console.error('Error Updating Local Brew');
|
||||
setError(err);
|
||||
});
|
||||
if(!res) return;
|
||||
|
||||
this.savedBrew = {
|
||||
...preSaveSnapshot,
|
||||
googleId : res.body.googleId ? res.body.googleId : null,
|
||||
editId : res.body.editId,
|
||||
const updatedFields = {
|
||||
googleId : res.body.googleId ?? null,
|
||||
editId : res.body.editId,
|
||||
shareId : res.body.shareId,
|
||||
version : res.body.version
|
||||
};
|
||||
|
||||
this.setState((prevState) => ({
|
||||
brew: {
|
||||
...prevState.brew,
|
||||
googleId : res.body.googleId ? res.body.googleId : null,
|
||||
editId : res.body.editId,
|
||||
shareId : res.body.shareId,
|
||||
version : res.body.version
|
||||
},
|
||||
isSaving : false,
|
||||
unsavedTime : new Date()
|
||||
}), ()=>{
|
||||
this.setState({ unsavedChanges : this.hasChanges() });
|
||||
});
|
||||
lastSavedBrew.current = {
|
||||
...brew,
|
||||
...updatedFields
|
||||
};
|
||||
|
||||
history.replaceState(null, null, `/edit/${this.savedBrew.editId}`);
|
||||
},
|
||||
setCurrentBrew((prevBrew)=>({
|
||||
...prevBrew,
|
||||
...updatedFields
|
||||
}));
|
||||
|
||||
renderGoogleDriveIcon : function(){
|
||||
return <Nav.item className='googleDriveStorage' onClick={this.handleGoogleClick}>
|
||||
<img src={googleDriveIcon} className={this.state.saveGoogle ? '' : 'inactive'} alt='Google Drive icon'/>
|
||||
history.replaceState(null, null, `/edit/${res.body.editId}`);
|
||||
};
|
||||
|
||||
{this.state.confirmGoogleTransfer &&
|
||||
<div className='errorContainer' onClick={this.closeAlerts}>
|
||||
{ this.state.saveGoogle
|
||||
? `Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?`
|
||||
: `Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?`
|
||||
}
|
||||
const renderGoogleDriveIcon = ()=>(
|
||||
<Nav.item className='googleDriveStorage' onClick={handleGoogleClick}>
|
||||
<img src={googleDriveIcon} className={saveGoogle ? '' : 'inactive'} alt='Google Drive icon' />
|
||||
|
||||
{confirmGoogleTransfer && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
{saveGoogle
|
||||
? 'Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?'
|
||||
: 'Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?'}
|
||||
<br />
|
||||
<div className='confirm' onClick={this.toggleGoogleStorage}>
|
||||
Yes
|
||||
</div>
|
||||
<div className='deny'>
|
||||
No
|
||||
</div>
|
||||
<div className='confirm' onClick={toggleGoogleStorage}> Yes </div>
|
||||
<div className='deny'> No </div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
|
||||
{this.state.alertLoginToTransfer &&
|
||||
<div className='errorContainer' onClick={this.closeAlerts}>
|
||||
You must be signed in to a Google account to transfer
|
||||
between the homebrewery and Google Drive!
|
||||
<a target='_blank' rel='noopener noreferrer'
|
||||
href={`https://www.naturalcrit.com/login?redirect=${this.state.url}`}>
|
||||
<div className='confirm'>
|
||||
Sign In
|
||||
</div>
|
||||
{alertLoginToTransfer && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
You must be signed in to a Google account to transfer between the homebrewery and Google Drive!
|
||||
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
|
||||
<div className='confirm'> Sign In </div>
|
||||
</a>
|
||||
<div className='deny'>
|
||||
Not Now
|
||||
</div>
|
||||
<div className='deny'> Not Now </div>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
|
||||
{this.state.alertTrashedGoogleBrew &&
|
||||
<div className='errorContainer' onClick={this.closeAlerts}>
|
||||
This brew is currently in your Trash folder on Google Drive!<br />If you want to keep it, make sure to move it before it is deleted permanently!<br />
|
||||
<div className='confirm'>
|
||||
OK
|
||||
</div>
|
||||
{alertTrashedGoogleBrew && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
This brew is currently in your Trash folder on Google Drive!<br />
|
||||
If you want to keep it, make sure to move it before it is deleted permanently!<br />
|
||||
<div className='confirm'> OK </div>
|
||||
</div>
|
||||
}
|
||||
</Nav.item>;
|
||||
},
|
||||
|
||||
renderSaveButton : function(){
|
||||
)}
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
// #1 - Currently saving, show SAVING
|
||||
if(this.state.isSaving){
|
||||
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(this.state.unsavedChanges && this.state.autoSaveWarning){
|
||||
this.setAutosaveWarning();
|
||||
const elapsedTime = Math.round((new Date() - this.state.unsavedTime) / 1000 / 60);
|
||||
const text = elapsedTime == 0 ? 'Autosave is OFF.' : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
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>
|
||||
Reminder...
|
||||
<div className='errorContainer'>{text}</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
// #3 - Unsaved changes exist, click to save, show SAVE NOW
|
||||
// Use trySave(true) instead of save() to use debounced save function
|
||||
if(this.state.unsavedChanges){
|
||||
return <Nav.item className='save' onClick={()=>this.trySave(true)} color='blue' icon='fas fa-save'>Save Now</Nav.item>;
|
||||
}
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={()=>trySave(true)} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
|
||||
if(this.state.autoSave){
|
||||
return <Nav.item className='save saved'>auto-saved.</Nav.item>;
|
||||
}
|
||||
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>;
|
||||
},
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
handleAutoSave : function(){
|
||||
if(this.warningTimer) clearTimeout(this.warningTimer);
|
||||
this.setState((prevState)=>({
|
||||
autoSave : !prevState.autoSave,
|
||||
autoSaveWarning : prevState.autoSave
|
||||
}), ()=>{
|
||||
localStorage.setItem('AUTOSAVE_ON', JSON.stringify(this.state.autoSave));
|
||||
});
|
||||
},
|
||||
const toggleAutoSave = ()=>{
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
clearTimeout(saveTimeout.current);
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
|
||||
setAutoSaveEnabled(!autoSaveEnabled);
|
||||
setWarnUnsavedChanges(autoSaveEnabled);
|
||||
};
|
||||
|
||||
setAutosaveWarning : function(){
|
||||
setTimeout(()=>this.setState({ autoSaveWarning: false }), 4000); // 4 seconds to display
|
||||
this.warningTimer = setTimeout(()=>{this.setState({ autoSaveWarning: true });}, 900000); // 15 minutes between warnings
|
||||
this.warningTimer;
|
||||
},
|
||||
const renderAutoSaveButton = ()=>(
|
||||
<Nav.item onClick={toggleAutoSave}>
|
||||
Autosave <i className={autoSaveEnabled ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
errorReported : function(error) {
|
||||
this.setState({
|
||||
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 clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const renderNavbar = ()=>{
|
||||
return <Navbar>
|
||||
<Nav.section>
|
||||
<Nav.item className='brewTitle'>{this.state.brew.title}</Nav.item>
|
||||
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
|
||||
</Nav.section>
|
||||
|
||||
<Nav.section>
|
||||
{this.renderGoogleDriveIcon()}
|
||||
{this.state.error ?
|
||||
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
|
||||
<Nav.dropdown className='save-menu'>
|
||||
{this.renderSaveButton()}
|
||||
{this.renderAutoSaveButton()}
|
||||
</Nav.dropdown>
|
||||
}
|
||||
<NewBrew />
|
||||
<HelpNavItem/>
|
||||
<Nav.dropdown>
|
||||
<Nav.item color='teal' icon='fas fa-share-alt'>
|
||||
share
|
||||
</Nav.item>
|
||||
<Nav.item color='blue' href={`/share/${shareLink}`}>
|
||||
view
|
||||
</Nav.item>
|
||||
<Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${shareLink}`);}}>
|
||||
copy url
|
||||
</Nav.item>
|
||||
<Nav.item color='blue' href={this.getRedditLink()} newTab={true} rel='noopener noreferrer'>
|
||||
post to reddit
|
||||
</Nav.item>
|
||||
</Nav.dropdown>
|
||||
{renderGoogleDriveIcon()}
|
||||
{error
|
||||
? <ErrorNavItem error={error} clearError={clearError} />
|
||||
: <Nav.dropdown className='save-menu'>
|
||||
{renderSaveButton()}
|
||||
{renderAutoSaveButton()}
|
||||
</Nav.dropdown>}
|
||||
<NewBrewItem />
|
||||
<PrintNavItem />
|
||||
<HelpNavItem />
|
||||
<VaultNavItem />
|
||||
<RecentNavItem brew={this.state.brew} storageKey='edit' />
|
||||
<Account />
|
||||
<ShareNavItem brew={currentBrew} />
|
||||
<RecentNavItem brew={currentBrew} storageKey='edit' />
|
||||
<AccountNavItem/>
|
||||
</Nav.section>
|
||||
|
||||
</Navbar>;
|
||||
},
|
||||
};
|
||||
|
||||
render : function(){
|
||||
return <div className='editPage sitePage'>
|
||||
return (
|
||||
<div className='editPage sitePage'>
|
||||
<Meta name='robots' content='noindex, nofollow' />
|
||||
{this.renderNavbar()}
|
||||
|
||||
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} reviewRequested={this.props.brew.lock.reviewRequested} />}
|
||||
{renderNavbar()}
|
||||
|
||||
{currentBrew.lock && <LockNotification shareId={currentBrew.shareId} message={currentBrew.lock.editMessage} reviewRequested={currentBrew.lock.reviewRequested}/>}
|
||||
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||
<SplitPane onDragFinish={handleSplitMove}>
|
||||
<Editor
|
||||
ref={this.editor}
|
||||
brew={this.state.brew}
|
||||
onTextChange={this.handleTextChange}
|
||||
onStyleChange={this.handleStyleChange}
|
||||
onSnipChange={this.handleSnipChange}
|
||||
onMetaChange={this.handleMetaChange}
|
||||
reportError={this.errorReported}
|
||||
renderer={this.state.brew.renderer}
|
||||
userThemes={this.props.userThemes}
|
||||
themeBundle={this.state.themeBundle}
|
||||
updateBrew={this.updateBrew}
|
||||
onCursorPageChange={this.handleEditorCursorPageChange}
|
||||
onViewPageChange={this.handleEditorViewPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
ref={editorRef}
|
||||
brew={currentBrew}
|
||||
onBrewChange={handleBrewChange}
|
||||
reportError={setError}
|
||||
renderer={currentBrew.renderer}
|
||||
userThemes={props.userThemes}
|
||||
themeBundle={themeBundle}
|
||||
updateBrew={updateBrew}
|
||||
onCursorPageChange={setCurrentEditorCursorPageNum}
|
||||
onViewPageChange={setCurrentEditorViewPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={this.state.brew.text}
|
||||
style={this.state.brew.style}
|
||||
renderer={this.state.brew.renderer}
|
||||
theme={this.state.brew.theme}
|
||||
themeBundle={this.state.themeBundle}
|
||||
errors={this.state.htmlErrors}
|
||||
lang={this.state.brew.lang}
|
||||
onPageChange={this.handleBrewRendererPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
theme={currentBrew.theme}
|
||||
themeBundle={themeBundle}
|
||||
errors={HTMLErrors}
|
||||
lang={currentBrew.lang}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = EditPage;
|
||||
|
||||
@@ -176,6 +176,26 @@ const errorIndex = (props)=>{
|
||||
|
||||
If the selected brew is your document, you may designate it as a theme by adding the \`theme:meta\` tag.`,
|
||||
|
||||
// ID validation error
|
||||
'11' : dedent`
|
||||
## No Homebrewery document could be found.
|
||||
|
||||
The server could not locate the Homebrewery document. The Brew ID failed the validation check.
|
||||
|
||||
:
|
||||
|
||||
**Brew ID:** ${props.brew.brewId}`,
|
||||
|
||||
// Google ID validation error
|
||||
'12' : dedent`
|
||||
## No Google document could be found.
|
||||
|
||||
The server could not locate the Google document. The Google ID failed the validation check.
|
||||
|
||||
:
|
||||
|
||||
**Brew ID:** ${props.brew.brewId}`,
|
||||
|
||||
//account page when account is not defined
|
||||
'50' : dedent`
|
||||
## You are not signed in
|
||||
|
||||
@@ -1,141 +1,224 @@
|
||||
require('./homePage.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const cx = require('classnames');
|
||||
import request from '../../utils/request-middleware.js';
|
||||
const { Meta } = require('vitreum/headtags');
|
||||
/* eslint-disable max-lines */
|
||||
import './homePage.less';
|
||||
|
||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||
const Navbar = require('../../navbar/navbar.jsx');
|
||||
const NewBrewItem = require('../../navbar/newbrew.navitem.jsx');
|
||||
const HelpNavItem = require('../../navbar/help.navitem.jsx');
|
||||
const VaultNavItem = require('../../navbar/vault.navitem.jsx');
|
||||
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
|
||||
const AccountNavItem = require('../../navbar/account.navitem.jsx');
|
||||
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
|
||||
const { fetchThemeBundle } = require('../../../../shared/helpers.js');
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from 'naturalcrit/markdown.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
|
||||
const Editor = require('../../editor/editor.jsx');
|
||||
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
|
||||
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
|
||||
|
||||
const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
|
||||
import SplitPane from 'client/components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
const HomePage = createClass({
|
||||
displayName : 'HomePage',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brew : DEFAULT_BREW,
|
||||
ver : '0.0.0'
|
||||
import Nav from 'naturalcrit/nav/nav.jsx';
|
||||
import Navbar from '../../navbar/navbar.jsx';
|
||||
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
|
||||
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();
|
||||
}
|
||||
};
|
||||
},
|
||||
getInitialState : function() {
|
||||
return {
|
||||
brew : this.props.brew,
|
||||
welcomeText : this.props.brew.text,
|
||||
error : undefined,
|
||||
currentEditorViewPageNum : 1,
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
themeBundle : {}
|
||||
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
};
|
||||
},
|
||||
}, []);
|
||||
|
||||
editor : React.createRef(null),
|
||||
|
||||
componentDidMount : function() {
|
||||
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
|
||||
},
|
||||
|
||||
handleSave : function(){
|
||||
const save = ()=>{
|
||||
request.post('/api')
|
||||
.send(this.state.brew)
|
||||
.send(currentBrew)
|
||||
.end((err, res)=>{
|
||||
if(err) {
|
||||
this.setState({ error: err });
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
const brew = res.body;
|
||||
window.location = `/edit/${brew.editId}`;
|
||||
const saved = res.body;
|
||||
window.location = `/edit/${saved.editId}`;
|
||||
});
|
||||
},
|
||||
handleSplitMove : function(){
|
||||
this.editor.current.update();
|
||||
},
|
||||
};
|
||||
|
||||
handleEditorViewPageChange : function(pageNumber){
|
||||
this.setState({ currentEditorViewPageNum: pageNumber });
|
||||
},
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
handleEditorCursorPageChange : function(pageNumber){
|
||||
this.setState({ currentEditorCursorPageNum: pageNumber });
|
||||
},
|
||||
if(autoSaveEnabled) trySave(false, hasChange);
|
||||
}, [currentBrew]);
|
||||
|
||||
handleBrewRendererPageChange : function(pageNumber){
|
||||
this.setState({ currentBrewRendererPageNum: pageNumber });
|
||||
},
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current.update();
|
||||
};
|
||||
|
||||
handleTextChange : function(text){
|
||||
this.setState((prevState)=>({
|
||||
brew : { ...prevState.brew, text: text },
|
||||
}));
|
||||
},
|
||||
renderNavbar : function(){
|
||||
return <Navbar ver={this.props.ver}>
|
||||
const handleBrewChange = (field) => (value, subfield) => { //'text', 'style', 'snippets', 'metadata'
|
||||
if (subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(Markdown.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew(prev => ({ ...prev, ...value }));
|
||||
else setCurrentBrew(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
// #1 - Currently saving, show SAVING
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
||||
// if(unsavedChanges && warnUnsavedChanges) {
|
||||
// resetWarnUnsavedTimer();
|
||||
// const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||
// const text = elapsedTime === 0
|
||||
// ? 'Autosave is OFF.'
|
||||
// : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
|
||||
// return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
|
||||
// Reminder...
|
||||
// <div className='errorContainer'>{text}</div>
|
||||
// </Nav.item>;
|
||||
// }
|
||||
|
||||
// #3 - Unsaved changes exist, click to save, show SAVE NOW
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={save} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
|
||||
if(autoSaveEnabled)
|
||||
return <Nav.item className='save saved'>auto-saved</Nav.item>;
|
||||
|
||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||
if(neverSaved)
|
||||
return <Nav.item className='save neverSaved'>save now</Nav.item>;
|
||||
|
||||
// DEFAULT - No unsaved changes, show SAVED
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const renderNavbar = ()=>{
|
||||
return <Navbar ver={props.ver}>
|
||||
<Nav.section>
|
||||
{this.state.error ?
|
||||
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
|
||||
null
|
||||
}
|
||||
{error
|
||||
? <ErrorNavItem error={error} clearError={clearError} />
|
||||
: renderSaveButton()}
|
||||
<NewBrewItem />
|
||||
<PrintNavItem />
|
||||
<HelpNavItem />
|
||||
<VaultNavItem />
|
||||
<RecentNavItem />
|
||||
<AccountNavItem />
|
||||
</Nav.section>
|
||||
</Navbar>;
|
||||
},
|
||||
};
|
||||
|
||||
render : function(){
|
||||
return <div className='homePage sitePage'>
|
||||
return (
|
||||
<div className='homePage sitePage'>
|
||||
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
|
||||
{this.renderNavbar()}
|
||||
{renderNavbar()}
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||
<SplitPane onDragFinish={handleSplitMove}>
|
||||
<Editor
|
||||
ref={this.editor}
|
||||
brew={this.state.brew}
|
||||
onTextChange={this.handleTextChange}
|
||||
renderer={this.state.brew.renderer}
|
||||
ref={editorRef}
|
||||
brew={currentBrew}
|
||||
onBrewChange={handleBrewChange}
|
||||
renderer={currentBrew.renderer}
|
||||
showEditButtons={false}
|
||||
themeBundle={this.state.themeBundle}
|
||||
onCursorPageChange={this.handleEditorCursorPageChange}
|
||||
onViewPageChange={this.handleEditorViewPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
themeBundle={themeBundle}
|
||||
onCursorPageChange={setCurrentEditorCursorPageNum}
|
||||
onViewPageChange={setCurrentEditorViewPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={this.state.brew.text}
|
||||
style={this.state.brew.style}
|
||||
renderer={this.state.brew.renderer}
|
||||
onPageChange={this.handleBrewRendererPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
themeBundle={this.state.themeBundle}
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
themeBundle={themeBundle}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
<div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}>
|
||||
<div className={`floatingSaveButton${unsavedChanges ? ' show' : ''}`} onClick={save}>
|
||||
Save current <i className='fas fa-save' />
|
||||
</div>
|
||||
|
||||
<a href='/new' className='floatingNewButton'>
|
||||
Create your own <i className='fas fa-magic' />
|
||||
</a>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
module.exports = HomePage;
|
||||
|
||||
@@ -34,7 +34,13 @@
|
||||
}
|
||||
|
||||
.navItem.save {
|
||||
.fadeInRight();
|
||||
.transition(opacity);
|
||||
background-color : @orange;
|
||||
&:hover { background-color : @green; }
|
||||
&.neverSaved {
|
||||
.fadeOutRight();
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,275 +1,270 @@
|
||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
||||
require('./newPage.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
import request from '../../utils/request-middleware.js';
|
||||
/* eslint-disable max-lines */
|
||||
import './newPage.less';
|
||||
|
||||
import Markdown from 'naturalcrit/markdown.js';
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from 'naturalcrit/markdown.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||
const PrintNavItem = require('../../navbar/print.navitem.jsx');
|
||||
const Navbar = require('../../navbar/navbar.jsx');
|
||||
const AccountNavItem = require('../../navbar/account.navitem.jsx');
|
||||
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
|
||||
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
|
||||
const HelpNavItem = require('../../navbar/help.navitem.jsx');
|
||||
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
|
||||
|
||||
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
|
||||
const Editor = require('../../editor/editor.jsx');
|
||||
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
|
||||
import SplitPane from 'client/components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js');
|
||||
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
|
||||
import Nav from 'naturalcrit/nav/nav.jsx';
|
||||
import Navbar from '../../navbar/navbar.jsx';
|
||||
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';
|
||||
|
||||
const BREWKEY = 'homebrewery-new';
|
||||
const STYLEKEY = 'homebrewery-new-style';
|
||||
const METAKEY = 'homebrewery-new-meta';
|
||||
let SAVEKEY;
|
||||
// Page specific imports
|
||||
import { Meta } from 'vitreum/headtags';
|
||||
|
||||
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 NewPage = createClass({
|
||||
displayName : 'NewPage',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brew : DEFAULT_BREW
|
||||
const useLocalStorage = true;
|
||||
const neverSaved = true;
|
||||
|
||||
const NewPage = (props) => {
|
||||
props = {
|
||||
brew: DEFAULT_BREW,
|
||||
...props
|
||||
};
|
||||
|
||||
const [currentBrew , setCurrentBrew ] = useState(props.brew);
|
||||
const [isSaving , setIsSaving ] = useState(false);
|
||||
const [saveGoogle , setSaveGoogle ] = useState(global.account?.googleId ? true : false);
|
||||
const [error , setError ] = useState(null);
|
||||
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
|
||||
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
const [themeBundle , setThemeBundle ] = useState({});
|
||||
const [unsavedChanges , setUnsavedChanges ] = useState(false);
|
||||
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(false);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState : function() {
|
||||
const brew = this.props.brew;
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
|
||||
return {
|
||||
brew : brew,
|
||||
isSaving : false,
|
||||
saveGoogle : (global.account && global.account.googleId ? true : false),
|
||||
error : null,
|
||||
htmlErrors : Markdown.validate(brew.text),
|
||||
currentEditorViewPageNum : 1,
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
themeBundle : {}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
};
|
||||
},
|
||||
}, []);
|
||||
|
||||
editor : React.createRef(null),
|
||||
|
||||
componentDidMount : function() {
|
||||
document.addEventListener('keydown', this.handleControlKeys);
|
||||
|
||||
const brew = this.state.brew;
|
||||
|
||||
if(!this.props.brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser
|
||||
const loadBrew = ()=>{
|
||||
const brew = { ...currentBrew };
|
||||
if(!brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser
|
||||
const brewStorage = localStorage.getItem(BREWKEY);
|
||||
const styleStorage = localStorage.getItem(STYLEKEY);
|
||||
const metaStorage = JSON.parse(localStorage.getItem(METAKEY));
|
||||
const metaStorage = JSON.parse(localStorage.getItem(METAKEY));
|
||||
|
||||
brew.text = brewStorage ?? brew.text;
|
||||
brew.style = styleStorage ?? brew.style;
|
||||
// brew.title = metaStorage?.title || this.state.brew.title;
|
||||
// brew.description = metaStorage?.description || this.state.brew.description;
|
||||
brew.text = brewStorage ?? brew.text;
|
||||
brew.style = styleStorage ?? brew.style;
|
||||
brew.renderer = metaStorage?.renderer ?? brew.renderer;
|
||||
brew.theme = metaStorage?.theme ?? brew.theme;
|
||||
brew.lang = metaStorage?.lang ?? brew.lang;
|
||||
}
|
||||
|
||||
SAVEKEY = `HOMEBREWERY-DEFAULT-SAVE-LOCATION-${global.account?.username || ''}`;
|
||||
const SAVEKEY = `${SAVEKEYPREFIX}${global.account?.username}`;
|
||||
const saveStorage = localStorage.getItem(SAVEKEY) || 'HOMEBREWERY';
|
||||
|
||||
this.setState({
|
||||
brew : brew,
|
||||
saveGoogle : (saveStorage == 'GOOGLE-DRIVE' && this.state.saveGoogle)
|
||||
});
|
||||
|
||||
fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme);
|
||||
setCurrentBrew(brew);
|
||||
lastSavedBrew.current = brew;
|
||||
setSaveGoogle(saveStorage == 'GOOGLE-DRIVE' && saveGoogle);
|
||||
|
||||
localStorage.setItem(BREWKEY, brew.text);
|
||||
if(brew.style)
|
||||
localStorage.setItem(STYLEKEY, brew.style);
|
||||
localStorage.setItem(METAKEY, JSON.stringify({ 'renderer': brew.renderer, 'theme': brew.theme, 'lang': brew.lang }));
|
||||
if(window.location.pathname != '/new') {
|
||||
localStorage.setItem(METAKEY, JSON.stringify({ renderer: brew.renderer, theme: brew.theme, lang: brew.lang }));
|
||||
if(window.location.pathname !== '/new')
|
||||
window.history.replaceState({}, window.location.title, '/new/');
|
||||
}
|
||||
},
|
||||
componentWillUnmount : function() {
|
||||
document.removeEventListener('keydown', this.handleControlKeys);
|
||||
},
|
||||
};
|
||||
|
||||
handleControlKeys : function(e){
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
const S_KEY = 83;
|
||||
const P_KEY = 80;
|
||||
if(e.keyCode == S_KEY) this.save();
|
||||
if(e.keyCode == P_KEY) printCurrentBrew();
|
||||
if(e.keyCode == P_KEY || e.keyCode == S_KEY){
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
handleSplitMove : function(){
|
||||
this.editor.current.update();
|
||||
},
|
||||
if(autoSaveEnabled) trySave(false, hasChange);
|
||||
}, [currentBrew]);
|
||||
|
||||
handleEditorViewPageChange : function(pageNumber){
|
||||
this.setState({ currentEditorViewPageNum: pageNumber });
|
||||
},
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current.update();
|
||||
};
|
||||
|
||||
handleEditorCursorPageChange : function(pageNumber){
|
||||
this.setState({ currentEditorCursorPageNum: pageNumber });
|
||||
},
|
||||
const handleBrewChange = (field) => (value, subfield) => { //'text', 'style', 'snippets', 'metadata'
|
||||
if (subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
handleBrewRendererPageChange : function(pageNumber){
|
||||
this.setState({ currentBrewRendererPageNum: pageNumber });
|
||||
},
|
||||
//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));
|
||||
|
||||
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);
|
||||
if(field == 'metadata') setCurrentBrew(prev => ({ ...prev, ...value }));
|
||||
else setCurrentBrew(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
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);
|
||||
},
|
||||
|
||||
handleSnipChange : function(snippet){
|
||||
//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(snippet);
|
||||
|
||||
this.setState((prevState)=>({
|
||||
brew : { ...prevState.brew, snippets: snippet },
|
||||
htmlErrors : htmlErrors,
|
||||
}), ()=>{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 },
|
||||
}), ()=>{
|
||||
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
|
||||
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
|
||||
}));
|
||||
});
|
||||
;
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
let updatedBrew = { ...currentBrew };
|
||||
splitTextStyleAndMetadata(updatedBrew);
|
||||
|
||||
const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^\\page$/gm;
|
||||
updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1;
|
||||
|
||||
brew.pageCount=((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1;
|
||||
const res = await request
|
||||
.post(`/api${this.state.saveGoogle ? '?saveToGoogle=true' : ''}`)
|
||||
.send(brew)
|
||||
.catch((err)=>{
|
||||
this.setState({ isSaving: false, error: err });
|
||||
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
|
||||
.send(updatedBrew)
|
||||
.catch((err) => {
|
||||
setIsSaving(false);
|
||||
setError(err);
|
||||
});
|
||||
if(!res) return;
|
||||
|
||||
brew = res.body;
|
||||
setIsSaving(false)
|
||||
if (!res) return;
|
||||
|
||||
const savedBrew = res.body;
|
||||
|
||||
localStorage.removeItem(BREWKEY);
|
||||
localStorage.removeItem(STYLEKEY);
|
||||
localStorage.removeItem(METAKEY);
|
||||
window.location = `/edit/${brew.editId}`;
|
||||
},
|
||||
window.location = `/edit/${savedBrew.editId}`;
|
||||
};
|
||||
|
||||
renderSaveButton : function(){
|
||||
if(this.state.isSaving){
|
||||
return <Nav.item icon='fas fa-spinner fa-spin' className='save'>
|
||||
save...
|
||||
</Nav.item>;
|
||||
} else {
|
||||
return <Nav.item icon='fas fa-save' className='save' onClick={this.save}>
|
||||
save
|
||||
</Nav.item>;
|
||||
}
|
||||
},
|
||||
const renderSaveButton = ()=>{
|
||||
// #1 - Currently saving, show SAVING
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
renderNavbar : function(){
|
||||
return <Navbar>
|
||||
// #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 = () => (
|
||||
<Navbar>
|
||||
<Nav.section>
|
||||
<Nav.item className='brewTitle'>{this.state.brew.title}</Nav.item>
|
||||
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
|
||||
</Nav.section>
|
||||
|
||||
<Nav.section>
|
||||
{this.state.error ?
|
||||
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
|
||||
this.renderSaveButton()
|
||||
}
|
||||
{error
|
||||
? <ErrorNavItem error={error} clearError={clearError} />
|
||||
: renderSaveButton()}
|
||||
<NewBrewItem />
|
||||
<PrintNavItem />
|
||||
<HelpNavItem />
|
||||
<VaultNavItem />
|
||||
<RecentNavItem />
|
||||
<AccountNavItem />
|
||||
</Nav.section>
|
||||
</Navbar>;
|
||||
},
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
render : function(){
|
||||
return <div className='newPage sitePage'>
|
||||
{this.renderNavbar()}
|
||||
return (
|
||||
<div className='newPage sitePage'>
|
||||
{renderNavbar()}
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||
<SplitPane onDragFinish={handleSplitMove}>
|
||||
<Editor
|
||||
ref={this.editor}
|
||||
brew={this.state.brew}
|
||||
onTextChange={this.handleTextChange}
|
||||
onStyleChange={this.handleStyleChange}
|
||||
onMetaChange={this.handleMetaChange}
|
||||
onSnipChange={this.handleSnipChange}
|
||||
renderer={this.state.brew.renderer}
|
||||
userThemes={this.props.userThemes}
|
||||
themeBundle={this.state.themeBundle}
|
||||
onCursorPageChange={this.handleEditorCursorPageChange}
|
||||
onViewPageChange={this.handleEditorViewPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
ref={editorRef}
|
||||
brew={currentBrew}
|
||||
onBrewChange={handleBrewChange}
|
||||
renderer={currentBrew.renderer}
|
||||
userThemes={props.userThemes}
|
||||
themeBundle={themeBundle}
|
||||
onCursorPageChange={setCurrentEditorCursorPageNum}
|
||||
onViewPageChange={setCurrentEditorViewPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={this.state.brew.text}
|
||||
style={this.state.brew.style}
|
||||
renderer={this.state.brew.renderer}
|
||||
theme={this.state.brew.theme}
|
||||
themeBundle={this.state.themeBundle}
|
||||
errors={this.state.htmlErrors}
|
||||
lang={this.state.brew.lang}
|
||||
onPageChange={this.handleBrewRendererPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
theme={currentBrew.theme}
|
||||
themeBundle={themeBundle}
|
||||
errors={HTMLErrors}
|
||||
lang={currentBrew.lang}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = NewPage;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
.newPage {
|
||||
.navItem.save {
|
||||
.fadeInRight();
|
||||
.transition(opacity);
|
||||
background-color : @orange;
|
||||
&:hover { background-color : @green; }
|
||||
&.neverSaved {
|
||||
.fadeOutRight();
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,11 @@ const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpe
|
||||
const SharePage = (props)=>{
|
||||
const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props;
|
||||
|
||||
const [state, setState] = useState({
|
||||
themeBundle : {},
|
||||
currentBrewRendererPageNum : 1,
|
||||
});
|
||||
const [themeBundle, setThemeBundle] = useState({});
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
|
||||
const handleBrewRendererPageChange = useCallback((pageNumber)=>{
|
||||
setState((prevState)=>({
|
||||
currentBrewRendererPageNum : pageNumber,
|
||||
...prevState }));
|
||||
setCurrentBrewRendererPageNum(pageNumber);
|
||||
}, []);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
@@ -40,11 +36,7 @@ const SharePage = (props)=>{
|
||||
|
||||
useEffect(()=>{
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
fetchThemeBundle(
|
||||
{ setState },
|
||||
brew.renderer,
|
||||
brew.theme
|
||||
);
|
||||
fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme);
|
||||
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
@@ -114,9 +106,9 @@ const SharePage = (props)=>{
|
||||
lang={brew.lang}
|
||||
renderer={brew.renderer}
|
||||
theme={brew.theme}
|
||||
themeBundle={state.themeBundle}
|
||||
themeBundle={themeBundle}
|
||||
onPageChange={handleBrewRendererPageChange}
|
||||
currentBrewRendererPageNum={state.currentBrewRendererPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -39,10 +39,14 @@ const UserPage = (props)=>{
|
||||
}] : [])
|
||||
];
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const navItems = (
|
||||
<Navbar>
|
||||
<Nav.section>
|
||||
{error && (<ErrorNavItem error={error} parent={null}></ErrorNavItem>)}
|
||||
{error && (<ErrorNavItem error={error} clearError={clearError}></ErrorNavItem>)}
|
||||
<NewBrew />
|
||||
<HelpNavItem />
|
||||
<VaultNavitem />
|
||||
|
||||
@@ -12,7 +12,7 @@ const Account = require('../../navbar/account.navitem.jsx');
|
||||
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
|
||||
const HelpNavItem = require('../../navbar/help.navitem.jsx');
|
||||
const BrewItem = require('../basePages/listPage/brewItem/brewItem.jsx');
|
||||
const SplitPane = require('../../../../shared/naturalcrit/splitPane/splitPane.jsx');
|
||||
const SplitPane = require('client/components/splitPane/splitPane.jsx');
|
||||
const ErrorIndex = require('../errorPage/errors/errorIndex.js');
|
||||
|
||||
import request from '../../utils/request-middleware.js';
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
.vaultPage {
|
||||
height : 100%;
|
||||
overflow-y : hidden;
|
||||
background-color : #2C3E50;
|
||||
|
||||
*:not(input) { user-select : none; }
|
||||
|
||||
.form {
|
||||
background:white;
|
||||
}
|
||||
|
||||
:where(.content .dataGroup) {
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
background : white;
|
||||
|
||||
&.form .brewLookup {
|
||||
position : relative;
|
||||
@@ -171,7 +173,6 @@
|
||||
max-height : 100%;
|
||||
padding : 70px 50px;
|
||||
overflow-y : scroll;
|
||||
background-color : #2C3E50;
|
||||
container-type : inline-size;
|
||||
|
||||
h3 { font-size : 25px; }
|
||||
|
||||
74
client/homebrew/utils/request-middleware.spec.js
Normal file
74
client/homebrew/utils/request-middleware.spec.js
Normal file
@@ -0,0 +1,74 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,22 @@
|
||||
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 };
|
||||
@@ -42,6 +42,7 @@ function parseBrewForStorage(brew, slot = 0) {
|
||||
title : brew.title,
|
||||
text : brew.text,
|
||||
style : brew.style,
|
||||
snippets : brew.snippets,
|
||||
version : brew.version,
|
||||
shareId : brew.shareId,
|
||||
savedAt : brew?.savedAt || new Date(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"development": true,
|
||||
"host" : "homebrewery.local.naturalcrit.com:8000",
|
||||
"naturalcrit_url" : "local.naturalcrit.com:8010",
|
||||
"secret" : "secret",
|
||||
|
||||
1472
package-lock.json
generated
1472
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "homebrewery",
|
||||
"description": "Create authentic looking D&D homebrews using only markdown",
|
||||
"version": "3.19.1",
|
||||
"version": "3.19.3",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": "^10.8.x",
|
||||
@@ -72,7 +72,7 @@
|
||||
"lines": 50
|
||||
},
|
||||
"server/homebrew.api.js": {
|
||||
"statements": 70,
|
||||
"statements": 60,
|
||||
"branches": 50,
|
||||
"functions": 65,
|
||||
"lines": 70
|
||||
@@ -88,13 +88,14 @@
|
||||
"@babel/preset-env": "^7.28.0",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"@dmsnell/diff-match-patch": "^1.1.0",
|
||||
"@googleapis/drive": "^13.0.1",
|
||||
"@sanity/diff-match-patch": "^3.2.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"classnames": "^2.5.1",
|
||||
"codemirror": "^5.65.6",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"core-js": "^3.43.0",
|
||||
"core-js": "^3.44.0",
|
||||
"cors": "^2.8.5",
|
||||
"create-react-class": "^15.7.0",
|
||||
"dedent-tabs": "^0.10.3",
|
||||
@@ -102,6 +103,7 @@
|
||||
"express": "^5.1.0",
|
||||
"express-async-handler": "^1.2.0",
|
||||
"express-static-gzip": "3.0.0",
|
||||
"fflate": "^0.8.2",
|
||||
"fs-extra": "11.3.0",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
@@ -120,7 +122,7 @@
|
||||
"marked-subsuper-text": "^1.0.3",
|
||||
"markedLegacy": "npm:marked@^0.3.19",
|
||||
"moment": "^2.30.1",
|
||||
"mongoose": "^8.16.1",
|
||||
"mongoose": "^8.16.3",
|
||||
"nanoid": "5.1.5",
|
||||
"nconf": "^0.13.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -134,19 +136,19 @@
|
||||
"written-number": "^0.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/stylelint-plugin": "^3.1.3",
|
||||
"@stylistic/stylelint-plugin": "^4.0.0",
|
||||
"babel-plugin-transform-import-meta": "^2.3.3",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-plugin-jest": "^29.0.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^16.3.0",
|
||||
"jest": "^30.0.4",
|
||||
"jest": "^30.1.3",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"jsdom-global": "^3.0.2",
|
||||
"postcss-less": "^6.0.0",
|
||||
"stylelint": "^16.21.1",
|
||||
"stylelint-config-recess-order": "^7.1.0",
|
||||
"stylelint-config-recommended": "^16.0.0",
|
||||
"supertest": "^7.1.1"
|
||||
"stylelint": "^16.24.0",
|
||||
"stylelint-config-recess-order": "^7.3.0",
|
||||
"stylelint-config-recommended": "^17.0.0",
|
||||
"supertest": "^7.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"codemirror/addon/hint/show-hint.js",
|
||||
"moment",
|
||||
"superagent",
|
||||
"@sanity/diff-match-patch"
|
||||
"@sanity/diff-match-patch",
|
||||
"fflate"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res,
|
||||
title : req.brew.title || 'Untitled Brew',
|
||||
description : req.brew.description || 'No description.',
|
||||
image : req.brew.thumbnail || defaultMetaTags.image,
|
||||
locale : req.brew.lang,
|
||||
type : 'article'
|
||||
};
|
||||
|
||||
@@ -486,8 +487,8 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
|
||||
const query = { authors: req.account.username, googleId: { $exists: false } };
|
||||
const mongoCount = await HomebrewModel.countDocuments(query)
|
||||
.catch((err)=>{
|
||||
mongoCount = 0;
|
||||
console.log(err);
|
||||
return 0;
|
||||
});
|
||||
|
||||
data.accountDetails = {
|
||||
|
||||
@@ -27,7 +27,10 @@ const disconnect = async ()=>{
|
||||
};
|
||||
|
||||
const connect = async (config)=>{
|
||||
return await Mongoose.connect(getMongoDBURL(config), { retryWrites: false })
|
||||
return await Mongoose.connect(getMongoDBURL(config), {
|
||||
retryWrites : false,
|
||||
autoIndex : (config.get('local_environments').includes(config.get('node_env')))
|
||||
})
|
||||
.catch((error)=>handleConnectionError(error));
|
||||
};
|
||||
|
||||
|
||||
66
server/forcessl.mw.spec.js
Normal file
66
server/forcessl.mw.spec.js
Normal file
@@ -0,0 +1,66 @@
|
||||
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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { nanoid } from 'nanoid';
|
||||
import {makePatches, applyPatches, stringifyPatches, parsePatch} from '@sanity/diff-match-patch';
|
||||
import { md5 } from 'hash-wasm';
|
||||
import { splitTextStyleAndMetadata,
|
||||
brewSnippetsToJSON } from '../shared/helpers.js';
|
||||
brewSnippetsToJSON, debugTextMismatch } from '../shared/helpers.js';
|
||||
import checkClientVersion from './middleware/check-client-version.js';
|
||||
|
||||
|
||||
@@ -48,6 +48,20 @@ const api = {
|
||||
}
|
||||
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 };
|
||||
},
|
||||
//Get array of any of this user's brews tagged with `meta:theme`
|
||||
@@ -341,22 +355,44 @@ const api = {
|
||||
const brewFromServer = req.brew;
|
||||
splitTextStyleAndMetadata(brewFromServer);
|
||||
|
||||
if(brewFromServer?.version !== brewFromClient?.version){
|
||||
console.log(`Version mismatch on brew ${brewFromClient.editId}`);
|
||||
|
||||
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.` }));
|
||||
}
|
||||
|
||||
brewFromServer.text = brewFromServer.text.normalize('NFC');
|
||||
brewFromServer.hash = await md5(brewFromServer.text);
|
||||
|
||||
if((brewFromServer?.version !== brewFromClient?.version) || (brewFromServer?.hash !== brewFromClient?.hash)) {
|
||||
if(brewFromClient?.version !== brewFromClient?.version)
|
||||
console.log(`Version mismatch on brew ${brewFromClient.editId}`);
|
||||
if(brewFromServer?.hash !== brewFromClient?.hash) {
|
||||
console.log(`Hash mismatch on brew ${brewFromClient.editId}`);
|
||||
}
|
||||
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 = applyPatches(brewFromClient.patches, brewFromServer.text)[0];
|
||||
brew.text = api.mergeBrewText(brew);
|
||||
|
||||
const googleId = brew.googleId;
|
||||
@@ -501,4 +537,4 @@ router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew));
|
||||
router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew));
|
||||
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
|
||||
|
||||
export default api;
|
||||
export default api;
|
||||
|
||||
@@ -99,18 +99,87 @@ describe('Tests for api', ()=>{
|
||||
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', ()=>{
|
||||
const { id, googleId } = api.getId({
|
||||
params : {
|
||||
id : 'abcdefgh'
|
||||
id : 'abcdefghijkl'
|
||||
},
|
||||
body : {
|
||||
googleId : '12345'
|
||||
googleId : '123456789012345678901234567890123'
|
||||
}
|
||||
});
|
||||
|
||||
expect(id).toEqual('abcdefgh');
|
||||
expect(googleId).toEqual('12345');
|
||||
expect(id).toEqual('abcdefghijkl');
|
||||
expect(googleId).toEqual('123456789012345678901234567890123');
|
||||
});
|
||||
|
||||
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', ()=>{
|
||||
@@ -1052,4 +1121,83 @@ brew`);
|
||||
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
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,29 +7,29 @@ import zlib from 'zlib';
|
||||
const HomebrewSchema = mongoose.Schema({
|
||||
shareId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
|
||||
editId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
|
||||
googleId : { type: String },
|
||||
title : { type: String, default: '' },
|
||||
googleId : { type: String, index: true },
|
||||
title : { type: String, default: '', index: true },
|
||||
text : { type: String, default: '' },
|
||||
textBin : { type: Buffer },
|
||||
pageCount : { type: Number, default: 1 },
|
||||
pageCount : { type: Number, default: 1, index: true },
|
||||
|
||||
description : { type: String, default: '' },
|
||||
tags : [String],
|
||||
tags : { type: [String], index: true },
|
||||
systems : [String],
|
||||
lang : { type: String, default: 'en' },
|
||||
renderer : { type: String, default: '' },
|
||||
authors : [String],
|
||||
lang : { type: String, default: 'en', index: true },
|
||||
renderer : { type: String, default: '', index: true },
|
||||
authors : { type: [String], index: true },
|
||||
invitedAuthors : [String],
|
||||
published : { type: Boolean, default: false },
|
||||
thumbnail : { type: String, default: '' },
|
||||
published : { type: Boolean, default: false, index: true },
|
||||
thumbnail : { type: String, default: '', index: true },
|
||||
|
||||
createdAt : { type: Date, default: Date.now },
|
||||
updatedAt : { type: Date, default: Date.now },
|
||||
lastViewed : { type: Date, default: Date.now },
|
||||
createdAt : { type: Date, default: Date.now, index: true },
|
||||
updatedAt : { type: Date, default: Date.now, index: true },
|
||||
lastViewed : { type: Date, default: Date.now, index: true },
|
||||
views : { type: Number, default: 0 },
|
||||
version : { type: Number, default: 1 },
|
||||
version : { type: Number, default: 1, index: true },
|
||||
|
||||
lock : { type: Object }
|
||||
lock : { type: Object, index: true }
|
||||
}, { versionKey: false });
|
||||
|
||||
HomebrewSchema.statics.increaseView = async function(query) {
|
||||
@@ -43,6 +43,8 @@ HomebrewSchema.statics.increaseView = async function(query) {
|
||||
return brew;
|
||||
};
|
||||
|
||||
// STATIC FUNCTIONS
|
||||
|
||||
HomebrewSchema.statics.get = async function(query, fields=null){
|
||||
const brew = await Homebrew.findOne(query, fields).orFail()
|
||||
.catch((error)=>{throw 'Can not find brew';});
|
||||
@@ -63,6 +65,15 @@ HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, f
|
||||
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);
|
||||
|
||||
export {
|
||||
|
||||
@@ -5,21 +5,16 @@ import config from './config.js';
|
||||
const generateAccessToken = (account)=>{
|
||||
const payload = account;
|
||||
|
||||
// When the token was issued
|
||||
payload.issued = (new Date());
|
||||
// Which service issued 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
|
||||
payload.issued = (new Date()); // When the token was issued
|
||||
payload.issuer = config.get('authentication_token_issuer'); // Which service issued the Token
|
||||
payload.audience = config.get('authentication_token_audience'); // Which service is the token intended for
|
||||
const secret = config.get('authentication_token_secret'); // The signing key for signing the token
|
||||
|
||||
delete payload.password;
|
||||
delete payload._id;
|
||||
|
||||
const secret = config.get('authentication_token_secret');
|
||||
|
||||
const token = jwt.encode(payload, secret);
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
export default generateAccessToken;
|
||||
export default generateAccessToken;
|
||||
|
||||
27
server/token.spec.js
Normal file
27
server/token.spec.js
Normal file
@@ -0,0 +1,27 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -116,32 +116,62 @@ const printCurrentBrew = ()=>{
|
||||
}
|
||||
};
|
||||
|
||||
const fetchThemeBundle = async (obj, renderer, theme)=>{
|
||||
const fetchThemeBundle = async (setError, setThemeBundle, renderer, theme)=>{
|
||||
if(!renderer || !theme) return;
|
||||
const res = await request
|
||||
.get(`/api/theme/${renderer}/${theme}`)
|
||||
.catch((err)=>{
|
||||
obj.setState({ error: err });
|
||||
setError(err)
|
||||
});
|
||||
if(!res) {
|
||||
obj.setState((prevState)=>({
|
||||
...prevState,
|
||||
themeBundle : {}
|
||||
}));
|
||||
setThemeBundle({});
|
||||
return;
|
||||
}
|
||||
const themeBundle = res.body;
|
||||
themeBundle.joinedStyles = themeBundle.styles.map((style)=>`<style>${style}</style>`).join('\n\n');
|
||||
obj.setState((prevState)=>({
|
||||
...prevState,
|
||||
themeBundle : themeBundle,
|
||||
error : null
|
||||
}));
|
||||
setThemeBundle(themeBundle);
|
||||
setError(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 {
|
||||
splitTextStyleAndMetadata,
|
||||
printCurrentBrew,
|
||||
fetchThemeBundle,
|
||||
brewSnippetsToJSON
|
||||
brewSnippetsToJSON,
|
||||
debugTextMismatch
|
||||
};
|
||||
|
||||
@@ -38,15 +38,6 @@
|
||||
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 {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
|
||||
//}
|
||||
|
||||
@@ -185,7 +185,7 @@ const mustacheSpans = {
|
||||
start(src) { return src.match(/{{[^{]/)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
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);
|
||||
if(match) {
|
||||
//Find closing delimiter
|
||||
@@ -242,7 +242,7 @@ const mustacheDivs = {
|
||||
start(src) { return src.match(/\n *{{[^{]/m)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
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);
|
||||
if(match) {
|
||||
//Find closing delimiter
|
||||
@@ -297,7 +297,7 @@ const mustacheInjectInline = {
|
||||
level : 'inline',
|
||||
start(src) { return src.match(/ *{[^{\n]/)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/g;
|
||||
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1}/g;
|
||||
const match = inlineRegex.exec(src);
|
||||
if(match) {
|
||||
const lastToken = tokens[tokens.length - 1];
|
||||
@@ -343,7 +343,7 @@ const mustacheInjectBlock = {
|
||||
level : 'block',
|
||||
start(src) { return src.match(/\n *{[^{\n]/m)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-()#%=?. ]*"|[\w\-()#%.]*)|[^"=':{}\s]*)*))\1}/ym;
|
||||
const inlineRegex = /^ *{(?=((?:[:=](?:"['\w,\-+*/()#%=?. ]*"|[\w\-+*/()#%.]*)|[^"=':{}\s]*)*))\1}/ym;
|
||||
const match = inlineRegex.exec(src);
|
||||
if(match) {
|
||||
const lastToken = tokens[tokens.length - 1];
|
||||
|
||||
@@ -4,6 +4,17 @@ require('jsdom-global')();
|
||||
|
||||
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() {
|
||||
const source = `<a href="javascript:alert('This is a JavaScript injection via href attribute')">Click me</a>`;
|
||||
const rendered = safeHTML(source);
|
||||
|
||||
@@ -611,3 +611,17 @@ h6,
|
||||
}
|
||||
.toc.wide li { break-inside : auto; }
|
||||
}
|
||||
|
||||
|
||||
/**********************************
|
||||
Firefox endruns
|
||||
**********************************/
|
||||
|
||||
@supports (-moz-user-select: none) { // This section will only apply to Firefox; it's the only browser that supports `-mos-xyz...`
|
||||
.page {
|
||||
blockquote, table {
|
||||
page-break-inside: auto;
|
||||
break-inside: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
themes/V3/UnearthedArcana/dropdownPreview.png
Normal file
BIN
themes/V3/UnearthedArcana/dropdownPreview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
BIN
themes/V3/UnearthedArcana/dropdownTexture.png
Normal file
BIN
themes/V3/UnearthedArcana/dropdownTexture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 236 B |
6
themes/V3/UnearthedArcana/settings.json
Normal file
6
themes/V3/UnearthedArcana/settings.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name" : "UnearthedArcana",
|
||||
"renderer" : "V3",
|
||||
"baseTheme" : false,
|
||||
"baseSnippets" : false
|
||||
}
|
||||
38
themes/V3/UnearthedArcana/style.less
Normal file
38
themes/V3/UnearthedArcana/style.less
Normal file
@@ -0,0 +1,38 @@
|
||||
@import (less) './themes/fonts/5e/fonts.less';
|
||||
@import (less) './themes/assets/assets.less';
|
||||
|
||||
:root {
|
||||
//Colors
|
||||
--HB_Color_Background : #FFFFFF; // White
|
||||
--HB_Color_WatercolorStain : #000000; // Black
|
||||
}
|
||||
|
||||
.page {
|
||||
font-family: Cambria,Georgia,serif;
|
||||
font-size: 14px;
|
||||
h1, h2, h3, h4 {
|
||||
font-variant: small-caps;
|
||||
font-weight: normal;
|
||||
}
|
||||
h1 {
|
||||
column-span: all;
|
||||
-webkit-column-span: all;
|
||||
font-size: 40px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 26px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
border-bottom: 2px solid black;
|
||||
}
|
||||
h4 {
|
||||
font-size: 18px;
|
||||
}
|
||||
h5 {
|
||||
font-size: 16px;
|
||||
}
|
||||
h6 {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,13 @@
|
||||
"baseTheme": "Blank",
|
||||
"baseSnippets": "5ePHB",
|
||||
"path": "Journal"
|
||||
},
|
||||
"UnearthedArcana": {
|
||||
"name": "UnearthedArcana",
|
||||
"renderer": "V3",
|
||||
"baseTheme": false,
|
||||
"baseSnippets": false,
|
||||
"path": "UnearthedArcana"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user