mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-03-22 08:58:11 +00:00
52
client/admin/admin.jsx
Normal file
52
client/admin/admin.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import './admin.less';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import BrewUtils from './brewUtils/brewUtils.jsx';
|
||||
import NotificationUtils from './notificationUtils/notificationUtils.jsx';
|
||||
import AuthorUtils from './authorUtils/authorUtils.jsx';
|
||||
import LockTools from './lockTools/lockTools.jsx';
|
||||
|
||||
const tabGroups = ['brew', 'notifications', 'authors', 'locks'];
|
||||
|
||||
const ADMIN_TAB = 'HB_adminPage_currentTab';
|
||||
|
||||
const Admin = ()=>{
|
||||
const [currentTab, setCurrentTab] = useState('');
|
||||
|
||||
useEffect(()=>{
|
||||
setCurrentTab(localStorage.getItem(ADMIN_TAB) || 'brew');
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
localStorage.setItem(ADMIN_TAB, currentTab);
|
||||
}, [currentTab]);
|
||||
|
||||
return (
|
||||
<div className='admin'>
|
||||
<header>
|
||||
<div className='container'>
|
||||
<i className='fas fa-rocket' />
|
||||
The Homebrewery Admin Page
|
||||
<a href='/'>back to homepage</a>
|
||||
</div>
|
||||
</header>
|
||||
<main className='container'>
|
||||
<nav className='tabs'>
|
||||
{tabGroups.map((tab, idx)=>(
|
||||
<button
|
||||
className={tab === currentTab ? 'active' : ''}
|
||||
key={idx}
|
||||
onClick={()=>setCurrentTab(tab)}>
|
||||
{tab.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{currentTab === 'brew' && <BrewUtils />}
|
||||
{currentTab === 'notifications' && <NotificationUtils />}
|
||||
{currentTab === 'authors' && <AuthorUtils />}
|
||||
{currentTab === 'locks' && <LockTools />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Admin;
|
||||
136
client/admin/admin.less
Normal file
136
client/admin/admin.less
Normal file
@@ -0,0 +1,136 @@
|
||||
@import './shared/naturalcrit/styles/reset.less';
|
||||
@import './shared/naturalcrit/styles/elements.less';
|
||||
@import './shared/naturalcrit/styles/animations.less';
|
||||
@import './shared/naturalcrit/styles/colors.less';
|
||||
@import './shared/naturalcrit/styles/tooltip.less';
|
||||
@import './themes/fonts/iconFonts/fontAwesome.less';
|
||||
|
||||
html,body, #reactContainer, .naturalCrit { min-height : 100%; }
|
||||
|
||||
@sidebarWidth : 250px;
|
||||
|
||||
body {
|
||||
height : 100%;
|
||||
padding : 0;
|
||||
margin : 0;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-weight : 100;
|
||||
color : #4B5055;
|
||||
background-color : #EEEEEE;
|
||||
text-rendering : optimizeLegibility;
|
||||
}
|
||||
|
||||
:where(.admin) {
|
||||
padding-bottom : 50px;
|
||||
header {
|
||||
padding : 20px 0px;
|
||||
margin-bottom : 30px;
|
||||
font-size : 2em;
|
||||
color : white;
|
||||
background-color : @red;
|
||||
i { margin-right : 30px; }
|
||||
a { float : right; }
|
||||
}
|
||||
|
||||
hr { margin : 30px 0px; }
|
||||
|
||||
:where(.container) {
|
||||
input {
|
||||
height : 33px;
|
||||
padding : 0px 10px;
|
||||
margin-bottom : 20px;
|
||||
font-family : monospace;
|
||||
}
|
||||
|
||||
button {
|
||||
height : 37px;
|
||||
vertical-align : middle;
|
||||
}
|
||||
|
||||
dl {
|
||||
display : grid;
|
||||
grid-template-columns : 120px 1fr;
|
||||
row-gap : 10px;
|
||||
align-items : center;
|
||||
justify-items : start;
|
||||
padding-top : 0.5em;
|
||||
dt {
|
||||
float : left;
|
||||
clear : left;
|
||||
height : fit-content;
|
||||
font-weight : 900;
|
||||
text-align : right;
|
||||
&::after { content : ' : '; }
|
||||
}
|
||||
dd { height : fit-content; }
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
margin-right : 3px;
|
||||
margin-left : 3px;
|
||||
color : black;
|
||||
background-color : #EEEEEE;
|
||||
border : 1px solid #444444;
|
||||
border-radius : 5px;
|
||||
&:hover {
|
||||
color : #EEEEEE;
|
||||
background-color : #444444;
|
||||
}
|
||||
&.active {
|
||||
margin-right : 2px;
|
||||
margin-left : 2px;
|
||||
text-decoration : underline;
|
||||
background-color : #CCCCCC;
|
||||
border : 2px solid #444444;
|
||||
}
|
||||
}
|
||||
|
||||
.notificationUtils {
|
||||
display : flex;
|
||||
gap : 50px;
|
||||
justify-content : space-between;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
padding : 10px;
|
||||
|
||||
tr {
|
||||
border-bottom : 1px solid;
|
||||
&:last-of-type { border : none; }
|
||||
&:nth-child(even) { background : #DDDDDD; }
|
||||
}
|
||||
|
||||
thead {
|
||||
background : rgb(193,236,230);
|
||||
border-bottom : 2px solid;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding : 5px 10px;
|
||||
vertical-align : middle;
|
||||
text-align : center;
|
||||
border-right : 1px solid;
|
||||
|
||||
&:last-child { border-right : none; }
|
||||
}
|
||||
|
||||
th { font-weight : 900; }
|
||||
|
||||
td {
|
||||
&:first-child {
|
||||
font-weight : 900;
|
||||
text-align : left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
float : right;
|
||||
padding : 10px;
|
||||
margin-block : 10px;
|
||||
font-weight : 900;
|
||||
color : white;
|
||||
background : rgb(178, 54, 54);
|
||||
}
|
||||
}
|
||||
87
client/admin/authorUtils/authorLookup/authorLookup.jsx
Normal file
87
client/admin/authorUtils/authorLookup/authorLookup.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import './authorLookup.less';
|
||||
|
||||
import React from 'react';
|
||||
import request from 'superagent';
|
||||
|
||||
const authorLookup = ()=>{
|
||||
const [author, setAuthor] = React.useState('');
|
||||
const [searching, setSearching] = React.useState(false);
|
||||
const [results, setResults] = React.useState([]);
|
||||
|
||||
const lookup = async ()=>{
|
||||
if(!author) return;
|
||||
|
||||
setSearching(true);
|
||||
setResults([]);
|
||||
|
||||
const brews = await request.get(`/admin/user/list/${author}`);
|
||||
setResults(brews.body);
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const renderResults = ()=>{
|
||||
if(results.length == 0) return <>
|
||||
<h2>Results</h2>
|
||||
<p>None found.</p>
|
||||
</>;
|
||||
|
||||
return <>
|
||||
<h2>{`Results - ${results.length} brews` }</h2>
|
||||
<table className='resultsTable'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Share</th>
|
||||
<th>Edit</th>
|
||||
<th>Last Update</th>
|
||||
<th>Storage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results
|
||||
.sort((a, b)=>{ // Sort brews from most recently updated
|
||||
if(a.updatedAt > b.updatedAt) return -1;
|
||||
return 1;
|
||||
})
|
||||
.map((brew, idx)=>{
|
||||
return <tr key={idx}>
|
||||
<td><strong>{brew.title}</strong></td>
|
||||
<td><a href={`/share/${brew.shareId}`}>{brew.shareId}</a></td>
|
||||
<td>{brew.editId}</td>
|
||||
<td style={{ width: '200px' }}>{brew.updatedAt}</td>
|
||||
<td>{brew.googleId ? 'Google' : 'Homebrewery'}</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>;
|
||||
};
|
||||
|
||||
const handleKeyPress = (evt)=>{
|
||||
if(evt.key === 'Enter') return lookup();
|
||||
};
|
||||
|
||||
const handleChange = (evt)=>{
|
||||
setAuthor(evt.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='authorLookup'>
|
||||
<div className='authorLookupInputs'>
|
||||
<h2>Author Lookup</h2>
|
||||
<label className='field'>
|
||||
Author Name:
|
||||
<input className='fieldInput' value={author} onKeyDown={handleKeyPress} onChange={handleChange} />
|
||||
<button onClick={lookup}>
|
||||
<i className={`fas ${searching ? 'fa-spin fa-spinner' : 'fa-search'}`} />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<div className='authorLookupResults'>
|
||||
{renderResults()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default authorLookup;
|
||||
29
client/admin/authorUtils/authorLookup/authorLookup.less
Normal file
29
client/admin/authorUtils/authorLookup/authorLookup.less
Normal file
@@ -0,0 +1,29 @@
|
||||
.authorLookup {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
|
||||
.field {
|
||||
display : flex;
|
||||
gap : 5px;
|
||||
align-items : center;
|
||||
justify-items : stretch;
|
||||
width : 100%;
|
||||
margin-bottom : 20px;
|
||||
|
||||
|
||||
input {
|
||||
height : 33px;
|
||||
padding : 0px 10px;
|
||||
margin-bottom : unset;
|
||||
font-family : monospace;
|
||||
}
|
||||
|
||||
button {
|
||||
width : 50px;
|
||||
|
||||
i { margin-right : 10px; }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
13
client/admin/authorUtils/authorUtils.jsx
Normal file
13
client/admin/authorUtils/authorUtils.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
import AuthorLookup from './authorLookup/authorLookup.jsx';
|
||||
|
||||
const authorUtils = ()=>{
|
||||
return (
|
||||
<section className='authorUtils'>
|
||||
<AuthorLookup />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default authorUtils;
|
||||
71
client/admin/brewUtils/brewCleanup/brewCleanup.jsx
Normal file
71
client/admin/brewUtils/brewCleanup/brewCleanup.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import request from 'superagent';
|
||||
|
||||
const BrewCleanup = createReactClass({
|
||||
displayName : 'BrewCleanup',
|
||||
getDefaultProps(){
|
||||
return {};
|
||||
},
|
||||
getInitialState() {
|
||||
return {
|
||||
count : 0,
|
||||
|
||||
pending : false,
|
||||
primed : false,
|
||||
err : null
|
||||
};
|
||||
},
|
||||
prime(){
|
||||
this.setState({ pending: true });
|
||||
|
||||
request.get('/admin/cleanup')
|
||||
.then((res)=>this.setState({ count: res.body.count, primed: true }))
|
||||
.catch((err)=>this.setState({ error: err }))
|
||||
.finally(()=>this.setState({ pending: false }));
|
||||
},
|
||||
cleanup(){
|
||||
this.setState({ pending: true });
|
||||
|
||||
request.post('/admin/cleanup')
|
||||
.then((res)=>this.setState({ count: res.body.count }))
|
||||
.catch((err)=>this.setState({ error: err }))
|
||||
.finally(()=>this.setState({ pending: false, primed: false }));
|
||||
},
|
||||
renderPrimed(){
|
||||
if(!this.state.primed) return;
|
||||
|
||||
if(!this.state.count){
|
||||
return <div className='result noBrews'>No Matching Brews found.</div>;
|
||||
}
|
||||
return <div className='result'>
|
||||
<button onClick={this.cleanup} className='remove'>
|
||||
{this.state.pending
|
||||
? <i className='fas fa-spin fa-spinner' />
|
||||
: <span><i className='fas fa-times' /> Remove</span>
|
||||
}
|
||||
</button>
|
||||
<span>Found {this.state.count} Brews that could be removed. </span>
|
||||
</div>;
|
||||
},
|
||||
render(){
|
||||
return <div className='brewUtil brewCleanup'>
|
||||
<h2> Brew Cleanup </h2>
|
||||
<p>Removes very short brews to tidy up the database</p>
|
||||
|
||||
<button onClick={this.prime} className='query'>
|
||||
{this.state.pending
|
||||
? <i className='fas fa-spin fa-spinner' />
|
||||
: 'Query Brews'
|
||||
}
|
||||
</button>
|
||||
{this.renderPrimed()}
|
||||
|
||||
{this.state.error
|
||||
&& <div className='error noBrews'>{this.state.error.toString()}</div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default BrewCleanup;
|
||||
88
client/admin/brewUtils/brewCompress/brewCompress.jsx
Normal file
88
client/admin/brewUtils/brewCompress/brewCompress.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import request from 'superagent';
|
||||
|
||||
const BrewCompress = createReactClass({
|
||||
displayName : 'BrewCompress',
|
||||
getDefaultProps(){
|
||||
return {};
|
||||
},
|
||||
getInitialState() {
|
||||
return {
|
||||
count : 0,
|
||||
batchRange : 0,
|
||||
|
||||
pending : false,
|
||||
primed : false,
|
||||
err : null,
|
||||
ids : null
|
||||
};
|
||||
},
|
||||
prime(){
|
||||
this.setState({ pending: true });
|
||||
|
||||
request.get('/admin/finduncompressed')
|
||||
.then((res)=>this.setState({ count: res.body.count, primed: true, ids: res.body.ids }))
|
||||
.catch((err)=>this.setState({ error: err }))
|
||||
.finally(()=>this.setState({ pending: false }));
|
||||
},
|
||||
cleanup(){
|
||||
const brews = this.state.ids;
|
||||
const compressBatches = ()=>{
|
||||
if(brews.length == 0){
|
||||
this.setState({ pending: false, primed: false });
|
||||
return;
|
||||
}
|
||||
const batch = brews.splice(0, 1000); // Process brews in batches of 1000
|
||||
this.setState({ batchRange: this.state.count - brews.length });
|
||||
batch.forEach((id, idx)=>{
|
||||
request.put(`/admin/compress/${id}`)
|
||||
.catch((err)=>this.setState({ error: err }));
|
||||
});
|
||||
setTimeout(compressBatches, 10000); //Wait 10 seconds between batches
|
||||
};
|
||||
|
||||
this.setState({ pending: true });
|
||||
|
||||
compressBatches();
|
||||
},
|
||||
renderPrimed(){
|
||||
if(!this.state.primed) return;
|
||||
|
||||
if(!this.state.count){
|
||||
return <div className='result noBrews'>No Matching Brews found.</div>;
|
||||
}
|
||||
return <div className='result'>
|
||||
<button onClick={this.cleanup} className='remove'>
|
||||
{this.state.pending
|
||||
? <i className='fas fa-spin fa-spinner' />
|
||||
: <span><i className='fas fa-compress' /> compress </span>
|
||||
}
|
||||
</button>
|
||||
{this.state.pending
|
||||
? <span>Compressing {this.state.batchRange} brews. </span>
|
||||
: <span>Found {this.state.count} Brews that could be compressed. </span>
|
||||
}
|
||||
</div>;
|
||||
},
|
||||
render(){
|
||||
return <div className='brewUtil brewCompress'>
|
||||
<h2> Brew Compression </h2>
|
||||
<p>Compresses the text in brews to binary</p>
|
||||
|
||||
<button onClick={this.prime} className='query'>
|
||||
{this.state.pending
|
||||
? <i className='fas fa-spin fa-spinner' />
|
||||
: 'Query Brews'
|
||||
}
|
||||
</button>
|
||||
{this.renderPrimed()}
|
||||
|
||||
{this.state.error
|
||||
&& <div className='error'>{this.state.error.toString()}</div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default BrewCompress;
|
||||
112
client/admin/brewUtils/brewLookup/brewLookup.jsx
Normal file
112
client/admin/brewUtils/brewLookup/brewLookup.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import request from 'superagent';
|
||||
import cx from 'classnames';
|
||||
|
||||
import Moment from 'moment';
|
||||
|
||||
const BrewLookup = createReactClass({
|
||||
getDefaultProps() {
|
||||
return {};
|
||||
},
|
||||
getInitialState() {
|
||||
return {
|
||||
query : '',
|
||||
foundBrew : null,
|
||||
searching : false,
|
||||
error : null,
|
||||
scriptCount : 0
|
||||
};
|
||||
},
|
||||
handleChange(e){
|
||||
this.setState({ query: e.target.value });
|
||||
},
|
||||
lookup(){
|
||||
this.setState({ searching: true, error: null, scriptCount: 0 });
|
||||
|
||||
request.get(`/admin/lookup/${this.state.query}`)
|
||||
.then((res)=>{
|
||||
const foundBrew = res.body;
|
||||
const scriptCheck = foundBrew?.text.match(/(<\/?s)cript/g);
|
||||
this.setState({
|
||||
foundBrew : foundBrew,
|
||||
scriptCount : scriptCheck?.length || 0,
|
||||
});
|
||||
})
|
||||
.catch((err)=>this.setState({ error: err }))
|
||||
.finally(()=>{
|
||||
this.setState({
|
||||
searching : false
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async cleanScript(){
|
||||
if(!this.state.foundBrew?.shareId) return;
|
||||
|
||||
await request.put(`/admin/clean/script/${this.state.foundBrew.shareId}`)
|
||||
.catch((err)=>{ this.setState({ error: err }); return; });
|
||||
|
||||
this.lookup();
|
||||
},
|
||||
|
||||
renderFoundBrew(){
|
||||
const brew = this.state.foundBrew;
|
||||
return <div className='result'>
|
||||
<dl>
|
||||
<dt>Title</dt>
|
||||
<dd>{brew.title}</dd>
|
||||
|
||||
<dt>Authors</dt>
|
||||
<dd>{brew.authors.join(', ')}</dd>
|
||||
|
||||
<dt>Edit Link</dt>
|
||||
<dd><a href={`/edit/${brew.editId}`} target='_blank' rel='noopener noreferrer'>/edit/{brew.editId}</a></dd>
|
||||
|
||||
<dt>Share Link</dt>
|
||||
<dd><a href={`/share/${brew.shareId}`} target='_blank' rel='noopener noreferrer'>/share/{brew.shareId}</a></dd>
|
||||
|
||||
<dt>Created Time</dt>
|
||||
<dd>{brew.createdAt ? Moment(brew.createdAt).toLocaleString() : 'No creation date'}</dd>
|
||||
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{Moment(brew.updatedAt).fromNow()}</dd>
|
||||
|
||||
<dt>Num of Views</dt>
|
||||
<dd>{brew.views}</dd>
|
||||
|
||||
<dt>SCRIPT tags detected</dt>
|
||||
<dd>{this.state.scriptCount}</dd>
|
||||
</dl>
|
||||
{this.state.scriptCount > 0 &&
|
||||
<div className='cleanButton'>
|
||||
<button onClick={this.cleanScript}>CLEAN BREW</button>
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
},
|
||||
|
||||
render(){
|
||||
return <div className='brewUtil brewLookup'>
|
||||
<h2>Brew Lookup</h2>
|
||||
<input type='text' value={this.state.query} onChange={this.handleChange} placeholder='edit or share id' />
|
||||
<button onClick={this.lookup}>
|
||||
<i className={cx('fas', {
|
||||
'fa-search' : !this.state.searching,
|
||||
'fa-spin fa-spinner' : this.state.searching,
|
||||
})} />
|
||||
</button>
|
||||
|
||||
{this.state.error
|
||||
&& <div className='error'>{this.state.error.toString()}</div>
|
||||
}
|
||||
|
||||
{this.state.foundBrew
|
||||
? this.renderFoundBrew()
|
||||
: <div className='result noBrew'>No brew found.</div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default BrewLookup;
|
||||
22
client/admin/brewUtils/brewUtils.jsx
Normal file
22
client/admin/brewUtils/brewUtils.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import './brewUtils.less';
|
||||
|
||||
import BrewCleanup from './brewCleanup/brewCleanup.jsx';
|
||||
import BrewLookup from './brewLookup/brewLookup.jsx';
|
||||
import BrewCompress from './brewCompress/brewCompress.jsx';
|
||||
import Stats from './stats/stats.jsx';
|
||||
|
||||
const BrewUtils = ()=>{
|
||||
return (
|
||||
<>
|
||||
<Stats />
|
||||
<hr />
|
||||
<BrewLookup />
|
||||
<hr />
|
||||
<BrewCleanup />
|
||||
<hr />
|
||||
<BrewCompress />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default BrewUtils;
|
||||
31
client/admin/brewUtils/brewUtils.less
Normal file
31
client/admin/brewUtils/brewUtils.less
Normal file
@@ -0,0 +1,31 @@
|
||||
@import '../../../shared/naturalcrit/styles/colors.less';
|
||||
|
||||
.brewUtil {
|
||||
.result {
|
||||
margin-top : 20px;
|
||||
button {
|
||||
margin-right : 10px;
|
||||
background-color : @red;
|
||||
}
|
||||
}
|
||||
.cleanButton {
|
||||
display : inline-block;
|
||||
width : 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
position : relative;
|
||||
|
||||
.pending {
|
||||
position : absolute;
|
||||
top : 0.5em;
|
||||
left : 100px;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
&:has(.pending) { opacity : 0.5; }
|
||||
|
||||
dl { grid-template-columns : 200px 250px; }
|
||||
}
|
||||
45
client/admin/brewUtils/stats/stats.jsx
Normal file
45
client/admin/brewUtils/stats/stats.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import request from 'superagent';
|
||||
|
||||
const Stats = createReactClass({
|
||||
displayName : 'Stats',
|
||||
getDefaultProps(){
|
||||
return {};
|
||||
},
|
||||
getInitialState(){
|
||||
return {
|
||||
stats : {
|
||||
totalBrews : 0,
|
||||
totalPublishedBrews : 0
|
||||
},
|
||||
fetching : false
|
||||
};
|
||||
},
|
||||
componentDidMount(){
|
||||
this.fetchStats();
|
||||
},
|
||||
fetchStats(){
|
||||
this.setState({ fetching: true });
|
||||
request.get('/admin/stats')
|
||||
.then((res)=>this.setState({ stats: res.body }))
|
||||
.finally(()=>this.setState({ fetching: false }));
|
||||
},
|
||||
render(){
|
||||
return <div className='brewUtil stats'>
|
||||
<h2> Stats </h2>
|
||||
<dl>
|
||||
<dt>Total Brew Count</dt>
|
||||
<dd>{this.state.stats.totalBrews}</dd>
|
||||
<dt>Total Brews Published</dt>
|
||||
<dd>{this.state.stats.totalPublishedBrews}</dd>
|
||||
</dl>
|
||||
|
||||
{this.state.fetching
|
||||
&& <div className='pending'><i className='fas fa-spin fa-spinner' /></div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default Stats;
|
||||
342
client/admin/lockTools/lockTools.jsx
Normal file
342
client/admin/lockTools/lockTools.jsx
Normal file
@@ -0,0 +1,342 @@
|
||||
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
|
||||
import './lockTools.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
import request from '../../homebrew/utils/request-middleware.js';
|
||||
|
||||
const LockTools = createReactClass({
|
||||
displayName : 'LockTools',
|
||||
getInitialState : function() {
|
||||
return {
|
||||
fetching : false,
|
||||
reviewCount : 0
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount : function() {
|
||||
this.updateReviewCount();
|
||||
},
|
||||
|
||||
updateReviewCount : async function() {
|
||||
const newCount = await request.get('/api/lock/count')
|
||||
.then((res)=>{return res.body?.count || 'Unknown';});
|
||||
if(newCount != this.state.reviewCount){
|
||||
this.setState({
|
||||
reviewCount : newCount
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateLockData : function(lock){
|
||||
this.setState({
|
||||
lock : lock
|
||||
});
|
||||
},
|
||||
|
||||
render : function() {
|
||||
return <div className='lockTools'>
|
||||
<h2>Lock Count</h2>
|
||||
<p>Number of brews currently locked: {this.state.reviewCount}</p>
|
||||
<button onClick={this.updateReviewCount}>REFRESH</button>
|
||||
<hr />
|
||||
<LockTable title='Locked Brews' text='Total Locked Brews' resultName='lockedDocuments' fetchURL='/api/locks' propertyNames={['shareId', 'title']} loadBrew={this.updateLockData} ></LockTable>
|
||||
<hr />
|
||||
<LockTable title='Brews Awaiting Review' text='Total Reviews Waiting' resultName='reviewDocuments' fetchURL='/api/lock/reviews' propertyNames={['shareId', 'title']} loadBrew={this.updateLockData} ></LockTable>
|
||||
<hr />
|
||||
<LockBrew key={this.state.lock?.key || 0} lock={this.state.lock}></LockBrew>
|
||||
<hr />
|
||||
<div style={{ columns: 2 }}>
|
||||
<LockLookup title='Unlock Brew' fetchURL='/api/unlock' updateFn={this.updateReviewCount}></LockLookup>
|
||||
<LockLookup title='Clear Review Request' fetchURL='/api/lock/review/remove'></LockLookup>
|
||||
</div>
|
||||
<hr />
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
const LockBrew = createReactClass({
|
||||
displayName : 'LockBrew',
|
||||
getInitialState : function() {
|
||||
// Default values
|
||||
return {
|
||||
brewId : this.props.lock?.shareId || '',
|
||||
code : this.props.lock?.code || 455,
|
||||
editMessage : this.props.lock?.editMessage || '',
|
||||
shareMessage : this.props.lock?.shareMessage || 'This Brew has been locked.',
|
||||
result : {},
|
||||
overwrite : false,
|
||||
};
|
||||
},
|
||||
|
||||
handleChange : function(e, varName) {
|
||||
const output = {};
|
||||
output[varName] = e.target.value;
|
||||
this.setState(output);
|
||||
},
|
||||
|
||||
submit : function(e){
|
||||
e.preventDefault();
|
||||
if(!this.state.editMessage) return;
|
||||
const newLock = {
|
||||
overwrite : this.state.overwrite,
|
||||
code : parseInt(this.state.code) || 100,
|
||||
editMessage : this.state.editMessage,
|
||||
shareMessage : this.state.shareMessage,
|
||||
applied : new Date
|
||||
};
|
||||
|
||||
request.post(`/api/lock/${this.state.brewId}`)
|
||||
.send(newLock)
|
||||
.set('Content-Type', 'application/json')
|
||||
.then((response)=>{
|
||||
this.setState({ result: response.body });
|
||||
})
|
||||
.catch((err)=>{
|
||||
this.setState({ result: err.response.body });
|
||||
});
|
||||
},
|
||||
|
||||
renderInput : function (name) {
|
||||
return <input type='text' name={name} value={this.state[name]} onChange={(e)=>this.handleChange(e, name)} autoComplete='off' required/>;
|
||||
},
|
||||
|
||||
renderResult : function(){
|
||||
return <>
|
||||
<h3>Result:</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{Object.keys(this.state.result).map((key, idx)=>{
|
||||
return <tr key={`${idx}-row`}>
|
||||
<td key={`${idx}-key`}>{key}</td>
|
||||
<td key={`${idx}-value`}>{this.state.result[key].toString()}
|
||||
</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>;
|
||||
},
|
||||
|
||||
render : function() {
|
||||
return <div className='lockBrew'>
|
||||
<div className='lockForm'>
|
||||
<h2>Lock Brew</h2>
|
||||
<form onSubmit={this.submit}>
|
||||
<label>
|
||||
ID:
|
||||
{this.renderInput('brewId')}
|
||||
</label>
|
||||
<br />
|
||||
<label>
|
||||
Error Code:
|
||||
{this.renderInput('code')}
|
||||
</label>
|
||||
<br />
|
||||
<label>
|
||||
Private Message:
|
||||
{this.renderInput('editMessage')}
|
||||
</label>
|
||||
<br />
|
||||
<label>
|
||||
Public Message:
|
||||
{this.renderInput('shareMessage')}
|
||||
</label>
|
||||
<br />
|
||||
<label className='checkbox'>
|
||||
Overwrite
|
||||
<input name='overwrite' className='checkbox' type='checkbox' value={this.state.overwrite} onClick={()=>{return this.setState((prevState)=>{return { overwrite: !prevState.overwrite };});}} />
|
||||
</label>
|
||||
<label>
|
||||
<input type='submit' />
|
||||
</label>
|
||||
</form>
|
||||
{this.state.result && this.renderResult()}
|
||||
</div>
|
||||
<div className='lockSuggestions'>
|
||||
<h2>Suggestions</h2>
|
||||
<div className='lockCodes'>
|
||||
<h3>Codes</h3>
|
||||
<ul>
|
||||
<li>455 - Generic Lock</li>
|
||||
<li>456 - Copyright issues</li>
|
||||
<li>457 - Confidential Information Leakage</li>
|
||||
<li>458 - Sensitive Personal Information</li>
|
||||
<li>459 - Defamation or Libel</li>
|
||||
<li>460 - Hate Speech or Discrimination</li>
|
||||
<li>461 - Illegal Activities</li>
|
||||
<li>462 - Malware or Phishing</li>
|
||||
<li>463 - Plagiarism</li>
|
||||
<li>465 - Misrepresentation</li>
|
||||
<li>466 - Inappropriate Content</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className='lockMessages'>
|
||||
<h3>Messages</h3>
|
||||
<ul>
|
||||
<li><b>Private Message:</b> This is the private message that is ONLY displayed to the authors of the locked brew. This message MUST specify exactly what actions must be taken in order to have the brew unlocked.</li>
|
||||
<li><b>Public Message:</b> This is the public message that is displayed to the EVERYONE that attempts to view the locked brew.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
const LockTable = createReactClass({
|
||||
displayName : 'LockTable',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
title : '',
|
||||
text : '',
|
||||
fetchURL : '/api/locks',
|
||||
resultName : '',
|
||||
propertyNames : ['shareId'],
|
||||
loadBrew : ()=>{}
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
result : '',
|
||||
error : '',
|
||||
searching : false
|
||||
};
|
||||
},
|
||||
|
||||
lockKey : React.createRef(0),
|
||||
|
||||
clickFn : function (){
|
||||
this.setState({ searching: true, error: null });
|
||||
|
||||
request.get(this.props.fetchURL)
|
||||
.then((res)=>this.setState({ result: res.body }))
|
||||
.catch((err)=>this.setState({ result: err.response.body }))
|
||||
.finally(()=>{
|
||||
this.setState({ searching: false });
|
||||
});
|
||||
},
|
||||
|
||||
updateBrewLockData : function (lockData){
|
||||
this.lockKey.current++;
|
||||
const brewData = {
|
||||
key : this.lockKey.current,
|
||||
shareId : lockData.shareId,
|
||||
code : lockData.lock.code,
|
||||
editMessage : lockData.lock.editMessage,
|
||||
shareMessage : lockData.lock.shareMessage
|
||||
};
|
||||
this.props.loadBrew(brewData);
|
||||
},
|
||||
|
||||
render : function () {
|
||||
return <>
|
||||
<div className='brewsAwaitingReview'>
|
||||
<div className='brewBlock'>
|
||||
<h2>{this.props.title}</h2>
|
||||
<button onClick={this.clickFn}>
|
||||
REFRESH
|
||||
<i className={`fas ${!this.state.searching ? 'fa-search' : 'fa-spin fa-spinner'}`} />
|
||||
</button>
|
||||
</div>
|
||||
{this.state.result[this.props.resultName] &&
|
||||
<>
|
||||
<p>{this.props.text}: {this.state.result[this.props.resultName].length}</p>
|
||||
<table className='lockTable'>
|
||||
<thead>
|
||||
<tr>
|
||||
{this.props.propertyNames.map((name, idx)=>{
|
||||
return <th key={idx}>{name}</th>;
|
||||
})}
|
||||
<th>clip</th>
|
||||
<th>load</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{this.state.result[this.props.resultName].map((result, resultIdx)=>{
|
||||
return <tr className='row' key={`${resultIdx}-row`}>
|
||||
{this.props.propertyNames.map((name, nameIdx)=>{
|
||||
return <td key={`${resultIdx}-${nameIdx}`}>
|
||||
{result[name].toString()}
|
||||
</td>;
|
||||
})}
|
||||
<td className='icon' title='Copy ID to Clipboard' onClick={()=>{navigator.clipboard.writeText(result.shareId.toString());}}><i className='fa-regular fa-clipboard'></i></td>
|
||||
<td className='icon' title='View Lock details' onClick={()=>{this.updateBrewLockData(result);}}><i className='fa-regular fa-circle-down'></i></td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
});
|
||||
|
||||
const LockLookup = createReactClass({
|
||||
displayName : 'LockLookup',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
fetchURL : '/api/lookup'
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
query : '',
|
||||
result : '',
|
||||
error : '',
|
||||
searching : false
|
||||
};
|
||||
},
|
||||
|
||||
handleChange(e){
|
||||
this.setState({ query: e.target.value });
|
||||
},
|
||||
|
||||
clickFn(){
|
||||
this.setState({ searching: true, error: null });
|
||||
|
||||
request.put(`${this.props.fetchURL}/${this.state.query}`)
|
||||
.then((res)=>this.setState({ result: res.body }))
|
||||
.catch((err)=>this.setState({ result: err.response.body }))
|
||||
.finally(()=>{
|
||||
this.setState({ searching: false });
|
||||
});
|
||||
},
|
||||
|
||||
renderResult : function(){
|
||||
return <div className='lockLookup'>
|
||||
<h3>Result:</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{Object.keys(this.state.result).map((key, idx)=>{
|
||||
return <tr key={`${idx}-row`}>
|
||||
<td key={`${idx}-key`}>{key}</td>
|
||||
<td key={`${idx}-value`}>{this.state.result[key].toString()}
|
||||
</td>
|
||||
</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>;
|
||||
},
|
||||
|
||||
render : function() {
|
||||
return <div className='brewLookup'>
|
||||
<h2>{this.props.title}</h2>
|
||||
<input type='text' value={this.state.query} onChange={this.handleChange} placeholder='share id' />
|
||||
<button onClick={this.clickFn}>
|
||||
<i className={`fas ${!this.state.searching ? 'fa-search' : 'fa-spin fa-spinner'}`} />
|
||||
</button>
|
||||
|
||||
{this.state.error
|
||||
&& <div className='error'>{this.state.error.toString()}</div>
|
||||
}
|
||||
|
||||
{this.state.result && this.renderResult()}
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default LockTools;
|
||||
66
client/admin/lockTools/lockTools.less
Normal file
66
client/admin/lockTools/lockTools.less
Normal file
@@ -0,0 +1,66 @@
|
||||
.lockTools {
|
||||
.lockBrew {
|
||||
columns : 2;
|
||||
|
||||
.lockForm {
|
||||
break-inside : avoid;
|
||||
|
||||
label {
|
||||
display : inline-block;
|
||||
width : 100%;
|
||||
line-height : 2.25em;
|
||||
text-align : right;
|
||||
input {
|
||||
float : right;
|
||||
width : 65%;
|
||||
margin-left : 10px;
|
||||
}
|
||||
&.checkbox {
|
||||
line-height: 1.5em;
|
||||
input {
|
||||
width : 1.5em;
|
||||
height : 1.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lockSuggestions {
|
||||
line-height : 1.2em;
|
||||
break-inside : avoid;
|
||||
columns : 2;
|
||||
h2 { column-span : all; }
|
||||
h3 { margin-top : 0px; }
|
||||
b { font-weight : 600; }
|
||||
|
||||
.lockCodes { break-inside : avoid; }
|
||||
}
|
||||
}
|
||||
|
||||
.lockTable {
|
||||
cursor : default;
|
||||
break-inside : avoid;
|
||||
.row:hover {
|
||||
color : #000000;
|
||||
background-color : #CCCCCC;
|
||||
}
|
||||
.icon {
|
||||
cursor : pointer;
|
||||
&:hover { text-shadow : 0px 0px 6px black; }
|
||||
}
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding : 4px 10px;
|
||||
text-align : center;
|
||||
}
|
||||
table, td { border : 1px solid #333333; }
|
||||
|
||||
.brewLookup {
|
||||
min-height : 175px;
|
||||
break-inside : avoid;
|
||||
h2 { margin-top : 0px; }
|
||||
}
|
||||
|
||||
button i { padding-left : 5px; }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import './notificationAdd.less';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import request from 'superagent';
|
||||
|
||||
const NotificationAdd = ()=>{
|
||||
const [notificationResult, setNotificationResult] = useState(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const dismissKeyRef = useRef(null);
|
||||
const titleRef = useRef(null);
|
||||
const textRef = useRef(null);
|
||||
const startAtRef = useRef(null);
|
||||
const stopAtRef = useRef(null);
|
||||
|
||||
const saveNotification = async ()=>{
|
||||
const dismissKey = dismissKeyRef.current.value;
|
||||
const title = titleRef.current.value;
|
||||
const text = textRef.current.value;
|
||||
const startAt = new Date(startAtRef.current.value);
|
||||
const stopAt = new Date(stopAtRef.current.value);
|
||||
|
||||
// Basic validation
|
||||
if(!dismissKey || !title || !text || isNaN(startAt.getTime()) || isNaN(stopAt.getTime())) {
|
||||
setError('All fields are required');
|
||||
return;
|
||||
}
|
||||
if(startAt >= stopAt) {
|
||||
setError('End date must be after the start date!');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
dismissKey,
|
||||
title,
|
||||
text,
|
||||
startAt : startAt?.toISOString() ?? '',
|
||||
stopAt : stopAt?.toISOString() ?? '',
|
||||
};
|
||||
|
||||
try {
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
const response = await request.post('/admin/notification/add').send(data);
|
||||
console.log(response.body);
|
||||
|
||||
// Reset form fields
|
||||
dismissKeyRef.current.value = '';
|
||||
titleRef.current.value = '';
|
||||
textRef.current.value = '';
|
||||
|
||||
setNotificationResult('Notification successfully created.');
|
||||
setSearching(false);
|
||||
} catch (err) {
|
||||
console.log(err.response.body.message);
|
||||
setError(`Error saving notification: ${err.response.body.message}`);
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='notificationAdd'>
|
||||
<h2>Add Notification</h2>
|
||||
|
||||
<label className='field'>
|
||||
Dismiss Key:
|
||||
<input className='fieldInput' type='text' ref={dismissKeyRef} required
|
||||
placeholder='dismiss_notif_drive'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className='field'>
|
||||
Title:
|
||||
<input className='fieldInput' type='text' ref={titleRef} required
|
||||
placeholder='Stop using Google Drive as image host'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className='field'>
|
||||
Text:
|
||||
<textarea className='fieldInput' type='text' ref={textRef} required
|
||||
placeholder='Google Drive is not an image hosting site, you should not use it as such.'
|
||||
>
|
||||
</textarea>
|
||||
</label>
|
||||
|
||||
<label className='field'>
|
||||
Start Date:
|
||||
<input type='date' className='fieldInput' ref={startAtRef} required/>
|
||||
</label>
|
||||
|
||||
<label className='field'>
|
||||
End Date:
|
||||
<input type='date' className='fieldInput' ref={stopAtRef} required/>
|
||||
</label>
|
||||
|
||||
<div className='notificationResult'>{notificationResult}</div>
|
||||
|
||||
<button className='notificationSave' onClick={saveNotification} disabled={searching}>
|
||||
<i className={`fas ${searching ? 'fa-spin fa-spinner' : 'fa-save'}`}/>
|
||||
Save Notification
|
||||
</button>
|
||||
{error && <div className='error'>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationAdd;
|
||||
@@ -0,0 +1,38 @@
|
||||
.notificationAdd {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
width : 500px;
|
||||
|
||||
.field {
|
||||
display : grid;
|
||||
grid-template-columns : 120px 200px;
|
||||
align-items : center;
|
||||
justify-items : stretch;
|
||||
width : 100%;
|
||||
margin-bottom : 20px;
|
||||
|
||||
input {
|
||||
height : 33px;
|
||||
padding : 0px 10px;
|
||||
margin-bottom : unset;
|
||||
font-family : monospace;
|
||||
|
||||
&[type='date'] { width : 14ch; }
|
||||
}
|
||||
|
||||
textarea {
|
||||
width : 50ch;
|
||||
min-height : 7em;
|
||||
max-height : 20em;
|
||||
padding : 10px;
|
||||
resize : vertical;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
width : 200px;
|
||||
|
||||
i { margin-right : 10px; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import './notificationLookup.less';
|
||||
import React, { useState } from 'react';
|
||||
import request from 'superagent';
|
||||
import Moment from 'moment';
|
||||
|
||||
const NotificationDetail = ({ notification, onDelete })=>(
|
||||
<>
|
||||
<dl>
|
||||
<dt>Key</dt>
|
||||
<dd>{notification.dismissKey}</dd>
|
||||
|
||||
<dt>Title</dt>
|
||||
<dd>{notification.title || 'No Title'}</dd>
|
||||
|
||||
<dt>Created</dt>
|
||||
<dd>{Moment(notification.createdAt).format('LLLL')}</dd>
|
||||
|
||||
<dt>Start</dt>
|
||||
<dd>{Moment(notification.startAt).format('LLLL') || 'No Start Time'}</dd>
|
||||
|
||||
<dt>Stop</dt>
|
||||
<dd>{Moment(notification.stopAt).format('LLLL') || 'No End Time'}</dd>
|
||||
|
||||
<dt>Text</dt>
|
||||
<dd>{notification.text || 'No Text'}</dd>
|
||||
</dl>
|
||||
<button onClick={()=>onDelete(notification.dismissKey)}>DELETE</button>
|
||||
</>
|
||||
);
|
||||
|
||||
const NotificationLookup = ()=>{
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
const lookupAll = async ()=>{
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await request.get('/admin/notification/all');
|
||||
setNotifications(res.body || []);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setError(`Error looking up notifications: ${err.response.body.message}`);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNotification = async (dismissKey)=>{
|
||||
if(!dismissKey) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Really delete notification ${dismissKey}?`
|
||||
);
|
||||
if(!confirmed) {
|
||||
console.log('Delete notification cancelled');
|
||||
return;
|
||||
}
|
||||
console.log('Delete notification confirm');
|
||||
try {
|
||||
await request.delete(`/admin/notification/delete/${dismissKey}`);
|
||||
lookupAll();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setError(`Error deleting notification: ${err.response.body.message}`);
|
||||
};
|
||||
};
|
||||
|
||||
const renderNotificationsList = ()=>{
|
||||
if(error)
|
||||
return <div className='error'>{error}</div>;
|
||||
|
||||
if(notifications.length === 0)
|
||||
return <div className='noNotification'>No notifications available.</div>;
|
||||
|
||||
return (
|
||||
<ul className='notificationList'>
|
||||
{notifications.map((notification)=>(
|
||||
<li key={notification.dismissKey} >
|
||||
<details>
|
||||
<summary>{notification.title || 'No Title'}</summary>
|
||||
<NotificationDetail notification={notification} onDelete={deleteNotification} />
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='notificationLookup'>
|
||||
<h2>Check all Notifications</h2>
|
||||
<button onClick={lookupAll}>
|
||||
<i className={`fas ${searching ? 'fa-spin fa-spinner' : 'fa-search'}`} />
|
||||
</button>
|
||||
{renderNotificationsList()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationLookup;
|
||||
@@ -0,0 +1,35 @@
|
||||
.notificationLookup {
|
||||
width : 450px;
|
||||
height : fit-content;
|
||||
|
||||
.noNotification { margin-block : 20px; }
|
||||
.notificationList {
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
max-height : 500px;
|
||||
margin-block : 20px;
|
||||
overflow : auto;
|
||||
border : 1px solid;
|
||||
border-radius : 5px;
|
||||
|
||||
li {
|
||||
padding : 10px;
|
||||
background : #CCCCCC;
|
||||
|
||||
&:nth-child(even) { background : #DDDDDD; }
|
||||
&:first-child {
|
||||
border-top-left-radius : 5px;
|
||||
border-top-right-radius : 5px;
|
||||
}
|
||||
&:last-child {
|
||||
border-bottom-right-radius : 5px;
|
||||
border-bottom-left-radius : 5px;
|
||||
}
|
||||
|
||||
summary {
|
||||
font-size : 20px;
|
||||
font-weight : 900;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
client/admin/notificationUtils/notificationUtils.jsx
Normal file
14
client/admin/notificationUtils/notificationUtils.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import NotificationLookup from './notificationLookup/notificationLookup.jsx';
|
||||
import NotificationAdd from './notificationAdd/notificationAdd.jsx';
|
||||
|
||||
const NotificationUtils = ()=>{
|
||||
return (
|
||||
<section className='notificationUtils'>
|
||||
<NotificationAdd />
|
||||
<NotificationLookup />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationUtils;
|
||||
96
client/components/Anchored.jsx
Normal file
96
client/components/Anchored.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect, cloneElement, Children } from 'react';
|
||||
import './Anchored.less';
|
||||
|
||||
// Anchored is a wrapper component that must have as children an <AnchoredTrigger> and a <AnchoredBox> component.
|
||||
// AnchoredTrigger must have a unique `id` prop, which is passed up to Anchored, saved in state on mount, and
|
||||
// then passed down through props into AnchoredBox. The `id` is used for the CSS Anchor Positioning properties.
|
||||
// **The Anchor Positioning API is not available in Firefox yet**
|
||||
// So in Firefox the positioning isn't perfect but is likely sufficient, and FF team seems to be working on the API quickly.
|
||||
|
||||
// When Anchor Positioning is added to Firefox, this can also be rewritten using the Popover API-- add the `popover` attribute
|
||||
// to the container div, which will render the container in the *top level* and give it better interactions like
|
||||
// click outside to dismiss. **Do not** add without Anchor, though, because positioning is very limited with the `popover`
|
||||
// attribute.
|
||||
|
||||
|
||||
const Anchored = ({ children })=>{
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [anchorId, setAnchorId] = useState(null);
|
||||
const boxRef = useRef(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
// promote trigger id to Anchored id (to pass it back down to the box as "anchorId")
|
||||
useEffect(()=>{
|
||||
if(triggerRef.current){
|
||||
setAnchorId(triggerRef.current.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// close box on outside click or Escape key
|
||||
useEffect(()=>{
|
||||
const handleClickOutside = (evt)=>{
|
||||
if(
|
||||
boxRef.current &&
|
||||
!boxRef.current.contains(evt.target) &&
|
||||
triggerRef.current &&
|
||||
!triggerRef.current.contains(evt.target)
|
||||
) {
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscapeKey = (evt)=>{
|
||||
if(evt.key === 'Escape') setVisible(false);
|
||||
};
|
||||
|
||||
window.addEventListener('click', handleClickOutside);
|
||||
window.addEventListener('keydown', handleEscapeKey);
|
||||
|
||||
return ()=>{
|
||||
window.removeEventListener('click', handleClickOutside);
|
||||
window.removeEventListener('keydown', handleEscapeKey);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleVisibility = ()=>setVisible((prev)=>!prev);
|
||||
|
||||
// Map children to inject necessary props
|
||||
const mappedChildren = Children.map(children, (child)=>{
|
||||
if(child.type === AnchoredTrigger) {
|
||||
return cloneElement(child, { ref: triggerRef, toggleVisibility, visible });
|
||||
}
|
||||
if(child.type === AnchoredBox) {
|
||||
return cloneElement(child, { ref: boxRef, visible, anchorId });
|
||||
}
|
||||
return child;
|
||||
});
|
||||
|
||||
return <>{mappedChildren}</>;
|
||||
};
|
||||
|
||||
// forward ref for AnchoredTrigger
|
||||
const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
|
||||
<button
|
||||
ref={ref}
|
||||
className={`anchored-trigger${visible ? ' active' : ''} ${className}`}
|
||||
onClick={toggleVisibility}
|
||||
style={{ anchorName: `--${props.id}` }} // setting anchor properties here allows greater recyclability.
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
));
|
||||
|
||||
// forward ref for AnchoredBox
|
||||
const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
|
||||
<div
|
||||
ref={ref}
|
||||
className={`anchored-box${visible ? ' active' : ''} ${className}`}
|
||||
style={{ positionAnchor: `--${anchorId}` }} // setting anchor properties here allows greater recyclability.
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
export { Anchored, AnchoredTrigger, AnchoredBox };
|
||||
11
client/components/Anchored.less
Normal file
11
client/components/Anchored.less
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
|
||||
.anchored-box {
|
||||
position : absolute;
|
||||
visibility : hidden;
|
||||
justify-self : anchor-center;
|
||||
@supports (inset-block-start: anchor(bottom)) {
|
||||
inset-block-start : anchor(bottom);
|
||||
}
|
||||
&.active { visibility : visible; }
|
||||
}
|
||||
84
client/components/codeEditor/autocompleteEmoji.js
Normal file
84
client/components/codeEditor/autocompleteEmoji.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import diceFont from '../../../themes/fonts/iconFonts/diceFont.js';
|
||||
import elderberryInn from '../../../themes/fonts/iconFonts/elderberryInn.js';
|
||||
import fontAwesome from '../../../themes/fonts/iconFonts/fontAwesome.js';
|
||||
import gameIcons from '../../../themes/fonts/iconFonts/gameIcons.js';
|
||||
|
||||
const emojis = {
|
||||
...diceFont,
|
||||
...elderberryInn,
|
||||
...fontAwesome,
|
||||
...gameIcons
|
||||
};
|
||||
|
||||
const showAutocompleteEmoji = function(CodeMirror, editor) {
|
||||
CodeMirror.commands.autocomplete = function(editor) {
|
||||
editor.showHint({
|
||||
completeSingle : false,
|
||||
hint : function(editor) {
|
||||
const cursor = editor.getCursor();
|
||||
const line = cursor.line;
|
||||
const lineContent = editor.getLine(line);
|
||||
const start = lineContent.lastIndexOf(':', cursor.ch - 1) + 1;
|
||||
const end = cursor.ch;
|
||||
const currentWord = lineContent.slice(start, end);
|
||||
|
||||
|
||||
const list = Object.keys(emojis).filter(function(emoji) {
|
||||
return emoji.toLowerCase().indexOf(currentWord.toLowerCase()) >= 0;
|
||||
}).sort((a, b)=>{
|
||||
const lowerA = a.replace(/\d+/g, function(match) { // Temporarily convert any numbers in emoji string
|
||||
return match.padStart(4, '0'); // to 4-digits, left-padded with 0's, to aid in
|
||||
}).toLowerCase(); // sorting numbers, i.e., "d6, d10, d20", not "d10, d20, d6"
|
||||
const lowerB = b.replace(/\d+/g, function(match) { // Also make lowercase for case-insensitive alpha sorting
|
||||
return match.padStart(4, '0');
|
||||
}).toLowerCase();
|
||||
|
||||
if(lowerA < lowerB)
|
||||
return -1;
|
||||
return 1;
|
||||
}).map(function(emoji) {
|
||||
return {
|
||||
text : `${emoji}:`, // Text to output to editor when option is selected
|
||||
render : function(element, self, data) { // How to display the option in the dropdown
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = `<i class="emojiPreview ${emojis[emoji]}"></i> ${emoji}`;
|
||||
element.appendChild(div);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
list : list.length ? list : [],
|
||||
from : CodeMirror.Pos(line, start),
|
||||
to : CodeMirror.Pos(line, end)
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
editor.on('inputRead', function(instance, change) {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
|
||||
// Get the text from the start of the line to the cursor
|
||||
const textToCursor = line.slice(0, cursor.ch);
|
||||
|
||||
// Do not autosuggest emojis in curly span/div/injector properties
|
||||
if(line.includes('{')) {
|
||||
const curlyToCursor = textToCursor.slice(textToCursor.indexOf(`{`));
|
||||
const curlySpanRegex = /{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1$/g;
|
||||
|
||||
if(curlySpanRegex.test(curlyToCursor))
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the text ends with ':xyz'
|
||||
if(/:[^\s:]+$/.test(textToCursor)) {
|
||||
CodeMirror.commands.autocomplete(editor);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
showAutocompleteEmoji
|
||||
};
|
||||
48
client/components/codeEditor/close-tag.js
Normal file
48
client/components/codeEditor/close-tag.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const autoCloseCurlyBraces = function(CodeMirror, cm, typingClosingBrace) {
|
||||
const ranges = cm.listSelections(), replacements = [];
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
if(!ranges[i].empty()) return CodeMirror.Pass;
|
||||
const pos = ranges[i].head, line = cm.getLine(pos.line), tok = cm.getTokenAt(pos);
|
||||
if(!typingClosingBrace && (tok.type == 'string' || tok.string.charAt(0) != '{' || tok.start != pos.ch - 1))
|
||||
return CodeMirror.Pass;
|
||||
else if(typingClosingBrace) {
|
||||
let hasUnclosedBraces = false, index = -1;
|
||||
do {
|
||||
index = line.indexOf('{{', index + 1);
|
||||
if(index !== -1 && line.indexOf('}}', index + 1) === -1) {
|
||||
hasUnclosedBraces = true;
|
||||
break;
|
||||
}
|
||||
} while (index !== -1);
|
||||
if(!hasUnclosedBraces) return CodeMirror.Pass;
|
||||
}
|
||||
|
||||
replacements[i] = typingClosingBrace ? {
|
||||
text : '}}',
|
||||
newPos : CodeMirror.Pos(pos.line, pos.ch + 2)
|
||||
} : {
|
||||
text : '{}}',
|
||||
newPos : CodeMirror.Pos(pos.line, pos.ch + 1)
|
||||
};
|
||||
}
|
||||
|
||||
for (let i = ranges.length - 1; i >= 0; i--) {
|
||||
const info = replacements[i];
|
||||
cm.replaceRange(info.text, ranges[i].head, ranges[i].anchor, '+insert');
|
||||
const sel = cm.listSelections().slice(0);
|
||||
sel[i] = {
|
||||
head : info.newPos,
|
||||
anchor : info.newPos
|
||||
};
|
||||
cm.setSelections(sel);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
autoCloseCurlyBraces : function(CodeMirror, codeMirror) {
|
||||
const map = { name: 'autoCloseCurlyBraces' };
|
||||
map[`'{'`] = function(cm) { return autoCloseCurlyBraces(CodeMirror, cm); };
|
||||
map[`'}'`] = function(cm) { return autoCloseCurlyBraces(CodeMirror, cm, true); };
|
||||
codeMirror?.addKeyMap(map);
|
||||
}
|
||||
};
|
||||
458
client/components/codeEditor/codeEditor.jsx
Normal file
458
client/components/codeEditor/codeEditor.jsx
Normal file
@@ -0,0 +1,458 @@
|
||||
/* eslint-disable max-lines */
|
||||
import './codeEditor.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import closeTag from './close-tag';
|
||||
import autoCompleteEmoji from './autocompleteEmoji';
|
||||
let CodeMirror;
|
||||
|
||||
const CodeEditor = createReactClass({
|
||||
displayName : 'CodeEditor',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
language : '',
|
||||
value : '',
|
||||
wrap : true,
|
||||
onChange : ()=>{},
|
||||
enableFolding : true,
|
||||
editorTheme : 'default'
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
docs : {}
|
||||
};
|
||||
},
|
||||
|
||||
editor : React.createRef(null),
|
||||
|
||||
async componentDidMount() {
|
||||
CodeMirror = (await import('codemirror')).default;
|
||||
this.CodeMirror = CodeMirror;
|
||||
|
||||
await import('codemirror/mode/gfm/gfm.js');
|
||||
await import('codemirror/mode/css/css.js');
|
||||
await import('codemirror/mode/javascript/javascript.js');
|
||||
|
||||
// addons
|
||||
await import('codemirror/addon/fold/foldcode.js');
|
||||
await import('codemirror/addon/fold/foldgutter.js');
|
||||
await import('codemirror/addon/fold/xml-fold.js');
|
||||
await import('codemirror/addon/search/search.js');
|
||||
await import('codemirror/addon/search/searchcursor.js');
|
||||
await import('codemirror/addon/search/jump-to-line.js');
|
||||
await import('codemirror/addon/search/match-highlighter.js');
|
||||
await import('codemirror/addon/search/matchesonscrollbar.js');
|
||||
await import('codemirror/addon/dialog/dialog.js');
|
||||
await import('codemirror/addon/scroll/scrollpastend.js');
|
||||
await import('codemirror/addon/edit/closetag.js');
|
||||
await import('codemirror/addon/hint/show-hint.js');
|
||||
// import 'codemirror/addon/selection/active-line.js';
|
||||
// import 'codemirror/addon/edit/trailingspace.js';
|
||||
|
||||
|
||||
// register helpers dynamically as well
|
||||
const foldPagesCode = (await import('./fold-pages')).default;
|
||||
const foldCSSCode = (await import('./fold-css')).default;
|
||||
foldPagesCode.registerHomebreweryHelper(CodeMirror);
|
||||
foldCSSCode.registerHomebreweryHelper(CodeMirror);
|
||||
|
||||
this.buildEditor();
|
||||
const newDoc = CodeMirror?.Doc(this.props.value, this.props.language);
|
||||
this.codeMirror?.swapDoc(newDoc);
|
||||
},
|
||||
|
||||
|
||||
componentDidUpdate : function(prevProps) {
|
||||
if(prevProps.view !== this.props.view){ //view changed; swap documents
|
||||
let newDoc;
|
||||
|
||||
if(!this.state.docs[this.props.view]) {
|
||||
newDoc = CodeMirror?.Doc(this.props.value, this.props.language);
|
||||
} else {
|
||||
newDoc = this.state.docs[this.props.view];
|
||||
}
|
||||
|
||||
const oldDoc = { [prevProps.view]: this.codeMirror?.swapDoc(newDoc) };
|
||||
|
||||
this.setState((prevState)=>({
|
||||
docs : _.merge({}, prevState.docs, oldDoc)
|
||||
}));
|
||||
|
||||
this.props.rerenderParent();
|
||||
} else if(this.codeMirror?.getValue() != this.props.value) { //update editor contents if brew.text is changed from outside
|
||||
this.codeMirror?.setValue(this.props.value);
|
||||
}
|
||||
|
||||
if(this.props.enableFolding) {
|
||||
this.codeMirror?.setOption('foldOptions', this.foldOptions(this.codeMirror));
|
||||
} else {
|
||||
this.codeMirror?.setOption('foldOptions', false);
|
||||
}
|
||||
|
||||
if(prevProps.editorTheme !== this.props.editorTheme){
|
||||
this.codeMirror?.setOption('theme', this.props.editorTheme);
|
||||
}
|
||||
},
|
||||
|
||||
buildEditor : function() {
|
||||
this.codeMirror = CodeMirror(this.editor.current, {
|
||||
lineNumbers : true,
|
||||
lineWrapping : this.props.wrap,
|
||||
indentWithTabs : false,
|
||||
tabSize : 2,
|
||||
smartIndent : false,
|
||||
historyEventDelay : 250,
|
||||
scrollPastEnd : true,
|
||||
extraKeys : {
|
||||
'Tab' : this.indent,
|
||||
'Shift-Tab' : this.dedent,
|
||||
'Ctrl-B' : this.makeBold,
|
||||
'Cmd-B' : this.makeBold,
|
||||
'Shift-Ctrl-=' : this.makeSuper,
|
||||
'Shift-Cmd-=' : this.makeSuper,
|
||||
'Ctrl-=' : this.makeSub,
|
||||
'Cmd-=' : this.makeSub,
|
||||
'Ctrl-I' : this.makeItalic,
|
||||
'Cmd-I' : this.makeItalic,
|
||||
'Ctrl-U' : this.makeUnderline,
|
||||
'Cmd-U' : this.makeUnderline,
|
||||
'Ctrl-.' : this.makeNbsp,
|
||||
'Cmd-.' : this.makeNbsp,
|
||||
'Shift-Ctrl-.' : this.makeSpace,
|
||||
'Shift-Cmd-.' : this.makeSpace,
|
||||
'Shift-Ctrl-,' : this.removeSpace,
|
||||
'Shift-Cmd-,' : this.removeSpace,
|
||||
'Ctrl-M' : this.makeSpan,
|
||||
'Cmd-M' : this.makeSpan,
|
||||
'Shift-Ctrl-M' : this.makeDiv,
|
||||
'Shift-Cmd-M' : this.makeDiv,
|
||||
'Ctrl-/' : this.makeComment,
|
||||
'Cmd-/' : this.makeComment,
|
||||
'Ctrl-K' : this.makeLink,
|
||||
'Cmd-K' : this.makeLink,
|
||||
'Ctrl-L' : ()=>this.makeList('UL'),
|
||||
'Cmd-L' : ()=>this.makeList('UL'),
|
||||
'Shift-Ctrl-L' : ()=>this.makeList('OL'),
|
||||
'Shift-Cmd-L' : ()=>this.makeList('OL'),
|
||||
'Shift-Ctrl-1' : ()=>this.makeHeader(1),
|
||||
'Shift-Ctrl-2' : ()=>this.makeHeader(2),
|
||||
'Shift-Ctrl-3' : ()=>this.makeHeader(3),
|
||||
'Shift-Ctrl-4' : ()=>this.makeHeader(4),
|
||||
'Shift-Ctrl-5' : ()=>this.makeHeader(5),
|
||||
'Shift-Ctrl-6' : ()=>this.makeHeader(6),
|
||||
'Shift-Cmd-1' : ()=>this.makeHeader(1),
|
||||
'Shift-Cmd-2' : ()=>this.makeHeader(2),
|
||||
'Shift-Cmd-3' : ()=>this.makeHeader(3),
|
||||
'Shift-Cmd-4' : ()=>this.makeHeader(4),
|
||||
'Shift-Cmd-5' : ()=>this.makeHeader(5),
|
||||
'Shift-Cmd-6' : ()=>this.makeHeader(6),
|
||||
'Shift-Ctrl-Enter' : this.newColumn,
|
||||
'Shift-Cmd-Enter' : this.newColumn,
|
||||
'Ctrl-Enter' : this.newPage,
|
||||
'Cmd-Enter' : this.newPage,
|
||||
'Ctrl-F' : 'findPersistent',
|
||||
'Cmd-F' : 'findPersistent',
|
||||
'Shift-Enter' : 'findPersistentPrevious',
|
||||
'Ctrl-[' : this.foldAllCode,
|
||||
'Cmd-[' : this.foldAllCode,
|
||||
'Ctrl-]' : this.unfoldAllCode,
|
||||
'Cmd-]' : this.unfoldAllCode
|
||||
},
|
||||
foldGutter : true,
|
||||
foldOptions : this.foldOptions(this.codeMirror),
|
||||
gutters : ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
|
||||
autoCloseTags : true,
|
||||
styleActiveLine : true,
|
||||
showTrailingSpace : false,
|
||||
theme : this.props.editorTheme
|
||||
// specialChars : / /,
|
||||
// specialCharPlaceholder : function(char) {
|
||||
// const el = document.createElement('span');
|
||||
// el.className = 'cm-space';
|
||||
// el.innerHTML = ' ';
|
||||
// return el;
|
||||
// }
|
||||
});
|
||||
|
||||
// Add custom behaviors (auto-close curlies and auto-complete emojis)
|
||||
closeTag.autoCloseCurlyBraces(CodeMirror, this.codeMirror);
|
||||
autoCompleteEmoji.showAutocompleteEmoji(CodeMirror, this.codeMirror);
|
||||
|
||||
// Note: codeMirror passes a copy of itself in this callback. cm === this.codeMirror?. Either one works.
|
||||
this.codeMirror?.on('change', (cm)=>{this.props.onChange(cm.getValue());});
|
||||
this.updateSize();
|
||||
},
|
||||
|
||||
indent : function () {
|
||||
const cm = this.codeMirror;
|
||||
if(cm.somethingSelected()) {
|
||||
cm.execCommand('indentMore');
|
||||
} else {
|
||||
cm.execCommand('insertSoftTab');
|
||||
}
|
||||
},
|
||||
|
||||
dedent : function () {
|
||||
this.codeMirror?.execCommand('indentLess');
|
||||
},
|
||||
|
||||
makeHeader : function (number) {
|
||||
const selection = this.codeMirror?.getSelection();
|
||||
const header = Array(number).fill('#').join('');
|
||||
this.codeMirror?.replaceSelection(`${header} ${selection}`, 'around');
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch + selection.length + number + 1 });
|
||||
},
|
||||
|
||||
makeBold : function() {
|
||||
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 2) === '**' && selection.slice(-2) === '**';
|
||||
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `**${selection}**`, 'around');
|
||||
if(selection.length === 0){
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
|
||||
}
|
||||
},
|
||||
|
||||
makeItalic : function() {
|
||||
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 1) === '*' && selection.slice(-1) === '*';
|
||||
this.codeMirror?.replaceSelection(t ? selection.slice(1, -1) : `*${selection}*`, 'around');
|
||||
if(selection.length === 0){
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
|
||||
}
|
||||
},
|
||||
|
||||
makeSuper : function() {
|
||||
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 1) === '^' && selection.slice(-1) === '^';
|
||||
this.codeMirror?.replaceSelection(t ? selection.slice(1, -1) : `^${selection}^`, 'around');
|
||||
if(selection.length === 0){
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 1 });
|
||||
}
|
||||
},
|
||||
|
||||
makeSub : function() {
|
||||
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 2) === '^^' && selection.slice(-2) === '^^';
|
||||
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `^^${selection}^^`, 'around');
|
||||
if(selection.length === 0){
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
makeNbsp : function() {
|
||||
this.codeMirror?.replaceSelection(' ', 'end');
|
||||
},
|
||||
|
||||
makeSpace : function() {
|
||||
const selection = this.codeMirror?.getSelection();
|
||||
const t = selection.slice(0, 8) === '{{width:' && selection.slice(0 -4) === '% }}';
|
||||
if(t){
|
||||
const percent = parseInt(selection.slice(8, -4)) + 10;
|
||||
this.codeMirror?.replaceSelection(percent < 90 ? `{{width:${percent}% }}` : '{{width:100% }}', 'around');
|
||||
} else {
|
||||
this.codeMirror?.replaceSelection(`{{width:10% }}`, 'around');
|
||||
}
|
||||
},
|
||||
|
||||
removeSpace : function() {
|
||||
const selection = this.codeMirror?.getSelection();
|
||||
const t = selection.slice(0, 8) === '{{width:' && selection.slice(0 -4) === '% }}';
|
||||
if(t){
|
||||
const percent = parseInt(selection.slice(8, -4)) - 10;
|
||||
this.codeMirror?.replaceSelection(percent > 10 ? `{{width:${percent}% }}` : '', 'around');
|
||||
}
|
||||
},
|
||||
|
||||
newColumn : function() {
|
||||
this.codeMirror?.replaceSelection('\n\\column\n\n', 'end');
|
||||
},
|
||||
|
||||
newPage : function() {
|
||||
this.codeMirror?.replaceSelection('\n\\page\n\n', 'end');
|
||||
},
|
||||
|
||||
injectText : function(injectText, overwrite=true) {
|
||||
const cm = this.codeMirror;
|
||||
if(!overwrite) {
|
||||
cm.setCursor(cm.getCursor('from'));
|
||||
}
|
||||
cm.replaceSelection(injectText, 'end');
|
||||
cm.focus();
|
||||
},
|
||||
|
||||
makeUnderline : function() {
|
||||
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 3) === '<u>' && selection.slice(-4) === '</u>';
|
||||
this.codeMirror?.replaceSelection(t ? selection.slice(3, -4) : `<u>${selection}</u>`, 'around');
|
||||
if(selection.length === 0){
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 4 });
|
||||
}
|
||||
},
|
||||
|
||||
makeSpan : function() {
|
||||
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 2) === '{{' && selection.slice(-2) === '}}';
|
||||
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `{{ ${selection}}}`, 'around');
|
||||
if(selection.length === 0){
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - 2 });
|
||||
}
|
||||
},
|
||||
|
||||
makeDiv : function() {
|
||||
const selection = this.codeMirror?.getSelection(), t = selection.slice(0, 2) === '{{' && selection.slice(-2) === '}}';
|
||||
this.codeMirror?.replaceSelection(t ? selection.slice(2, -2) : `{{\n${selection}\n}}`, 'around');
|
||||
if(selection.length === 0){
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setCursor({ line: cursor.line - 1, ch: cursor.ch }); // set to -2? if wanting to enter classes etc. if so, get rid of first \n when replacing selection
|
||||
}
|
||||
},
|
||||
|
||||
makeComment : function() {
|
||||
let regex;
|
||||
let cursorPos;
|
||||
let newComment;
|
||||
const selection = this.codeMirror?.getSelection();
|
||||
if(this.props.language === 'gfm'){
|
||||
regex = /^\s*(<!--\s?)(.*?)(\s?-->)\s*$/gs;
|
||||
cursorPos = 4;
|
||||
newComment = `<!-- ${selection} -->`;
|
||||
} else {
|
||||
regex = /^\s*(\/\*\s?)(.*?)(\s?\*\/)\s*$/gs;
|
||||
cursorPos = 3;
|
||||
newComment = `/* ${selection} */`;
|
||||
}
|
||||
this.codeMirror?.replaceSelection(regex.test(selection) == true ? selection.replace(regex, '$2') : newComment, 'around');
|
||||
if(selection.length === 0){
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setCursor({ line: cursor.line, ch: cursor.ch - cursorPos });
|
||||
};
|
||||
},
|
||||
|
||||
makeLink : function() {
|
||||
const isLink = /^\[(.*)\]\((.*)\)$/;
|
||||
const selection = this.codeMirror?.getSelection().trim();
|
||||
let match;
|
||||
if(match = isLink.exec(selection)){
|
||||
const altText = match[1];
|
||||
const url = match[2];
|
||||
this.codeMirror?.replaceSelection(`${altText} ${url}`);
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setSelection({ line: cursor.line, ch: cursor.ch - url.length }, { line: cursor.line, ch: cursor.ch });
|
||||
} else {
|
||||
this.codeMirror?.replaceSelection(`[${selection || 'alt text'}](url)`);
|
||||
const cursor = this.codeMirror?.getCursor();
|
||||
this.codeMirror?.setSelection({ line: cursor.line, ch: cursor.ch - 4 }, { line: cursor.line, ch: cursor.ch - 1 });
|
||||
}
|
||||
},
|
||||
|
||||
makeList : function(listType) {
|
||||
const selectionStart = this.codeMirror?.getCursor('from'), selectionEnd = this.codeMirror?.getCursor('to');
|
||||
this.codeMirror?.setSelection(
|
||||
{ line: selectionStart.line, ch: 0 },
|
||||
{ line: selectionEnd.line, ch: this.codeMirror?.getLine(selectionEnd.line).length }
|
||||
);
|
||||
const newSelection = this.codeMirror?.getSelection();
|
||||
|
||||
const regex = /^\d+\.\s|^-\s/gm;
|
||||
if(newSelection.match(regex) != null){ // if selection IS A LIST
|
||||
this.codeMirror?.replaceSelection(newSelection.replace(regex, ''), 'around');
|
||||
} else { // if selection IS NOT A LIST
|
||||
listType == 'UL' ? this.codeMirror?.replaceSelection(newSelection.replace(/^/gm, `- `), 'around') :
|
||||
this.codeMirror?.replaceSelection(newSelection.replace(/^/gm, (()=>{
|
||||
let n = 1;
|
||||
return ()=>{
|
||||
return `${n++}. `;
|
||||
};
|
||||
})()), 'around');
|
||||
}
|
||||
},
|
||||
|
||||
foldAllCode : function() {
|
||||
this.codeMirror?.execCommand('foldAll');
|
||||
},
|
||||
|
||||
unfoldAllCode : function() {
|
||||
this.codeMirror?.execCommand('unfoldAll');
|
||||
},
|
||||
|
||||
//=-- Externally used -==//
|
||||
setCursorPosition : function(line, char){
|
||||
setTimeout(()=>{
|
||||
this.codeMirror?.focus();
|
||||
this.codeMirror?.doc.setCursor(line, char);
|
||||
}, 10);
|
||||
},
|
||||
getCursorPosition : function(){
|
||||
return this.codeMirror?.getCursor();
|
||||
},
|
||||
getTopVisibleLine : function(){
|
||||
const rect = this.codeMirror?.getWrapperElement().getBoundingClientRect();
|
||||
const topVisibleLine = this.codeMirror?.lineAtHeight(rect.top, 'window');
|
||||
return topVisibleLine;
|
||||
},
|
||||
updateSize : function(){
|
||||
this.codeMirror?.refresh();
|
||||
},
|
||||
redo : function(){
|
||||
return this.codeMirror?.redo();
|
||||
},
|
||||
undo : function(){
|
||||
return this.codeMirror?.undo();
|
||||
},
|
||||
historySize : function(){
|
||||
return this.codeMirror?.doc.historySize();
|
||||
},
|
||||
|
||||
foldOptions : function(cm){
|
||||
return {
|
||||
scanUp : true,
|
||||
rangeFinder : this.props.language === 'css' ? CodeMirror.fold.homebrewerycss : CodeMirror.fold.homebrewery,
|
||||
widget : (from, to)=>{
|
||||
let text = '';
|
||||
let currentLine = from.line;
|
||||
let maxLength = 50;
|
||||
|
||||
let foldPreviewText = '';
|
||||
while (currentLine <= to.line && text.length <= maxLength) {
|
||||
const currentText = this.codeMirror?.getLine(currentLine);
|
||||
currentLine++;
|
||||
if(currentText[0] == '#'){
|
||||
foldPreviewText = currentText;
|
||||
break;
|
||||
}
|
||||
if(!foldPreviewText && currentText != '\n') {
|
||||
foldPreviewText = currentText;
|
||||
}
|
||||
}
|
||||
text = foldPreviewText || `Lines ${from.line+1}-${to.line+1}`;
|
||||
text = text.replace('{', '').trim();
|
||||
|
||||
// Truncate data URLs at `data:`
|
||||
const startOfData = text.indexOf('data:');
|
||||
if(startOfData > 0)
|
||||
maxLength = Math.min(startOfData + 5, maxLength);
|
||||
|
||||
if(text.length > maxLength)
|
||||
text = `${text.slice(0, maxLength)}...`;
|
||||
|
||||
return `\u21A4 ${text} \u21A6`;
|
||||
}
|
||||
};
|
||||
},
|
||||
//----------------------//
|
||||
|
||||
render : function(){
|
||||
return <>
|
||||
<link href={`../homebrew/cm-themes/${this.props.editorTheme}.css`} type='text/css' rel='stylesheet' />
|
||||
<div className='codeEditor' ref={this.editor} style={this.props.style}/>
|
||||
</>;
|
||||
}
|
||||
});
|
||||
|
||||
export default CodeEditor;
|
||||
|
||||
60
client/components/codeEditor/codeEditor.less
Normal file
60
client/components/codeEditor/codeEditor.less
Normal file
@@ -0,0 +1,60 @@
|
||||
@import (less) 'codemirror/lib/codemirror.css';
|
||||
@import (less) 'codemirror/addon/fold/foldgutter.css';
|
||||
@import (less) 'codemirror/addon/search/matchesonscrollbar.css';
|
||||
@import (less) 'codemirror/addon/dialog/dialog.css';
|
||||
@import (less) 'codemirror/addon/hint/show-hint.css';
|
||||
|
||||
//Icon fonts included so they can appear in emoji autosuggest dropdown
|
||||
@import (less) './themes/fonts/iconFonts/diceFont.less';
|
||||
@import (less) './themes/fonts/iconFonts/elderberryInn.less';
|
||||
@import (less) './themes/fonts/iconFonts/gameIcons.less';
|
||||
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
|
||||
|
||||
@keyframes sourceMoveAnimation {
|
||||
50% { color : white;background-color : red;}
|
||||
100% { color : unset;background-color : unset;}
|
||||
}
|
||||
|
||||
.codeEditor {
|
||||
@media screen and (pointer : coarse) {
|
||||
font-size : 16px;
|
||||
}
|
||||
.CodeMirror-foldmarker {
|
||||
font-family : inherit;
|
||||
font-weight : 600;
|
||||
color : grey;
|
||||
text-shadow : none;
|
||||
}
|
||||
|
||||
.CodeMirror-foldgutter {
|
||||
cursor : pointer;
|
||||
border-left : 1px solid #EEEEEE;
|
||||
transition : background 0.1s;
|
||||
&:hover { background : #DDDDDD; }
|
||||
}
|
||||
|
||||
.sourceMoveFlash .CodeMirror-line {
|
||||
animation-name : sourceMoveAnimation;
|
||||
animation-duration : 0.4s;
|
||||
}
|
||||
|
||||
.CodeMirror-search-field {
|
||||
width:25em !important;
|
||||
outline:1px inset #00000055 !important;
|
||||
}
|
||||
|
||||
//.cm-tab {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
|
||||
//}
|
||||
|
||||
//.cm-trailingspace {
|
||||
// .cm-space {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
||||
.emojiPreview {
|
||||
font-size : 1.5em;
|
||||
line-height : 1.2em;
|
||||
}
|
||||
44
client/components/codeEditor/fold-css.js
Normal file
44
client/components/codeEditor/fold-css.js
Normal file
@@ -0,0 +1,44 @@
|
||||
export default {
|
||||
registerHomebreweryHelper : function(CodeMirror) {
|
||||
CodeMirror.registerHelper('fold', 'homebrewerycss', function(cm, start) {
|
||||
|
||||
// BRACE FOLDING
|
||||
const startMatcher = /\{[ \t]*$/;
|
||||
const endMatcher = /\}[ \t]*$/;
|
||||
const activeLine = cm.getLine(start.line);
|
||||
|
||||
|
||||
if(activeLine.match(startMatcher)) {
|
||||
const lastLineNo = cm.lastLine();
|
||||
let end = start.line + 1;
|
||||
let braceCount = 1;
|
||||
|
||||
while (end < lastLineNo) {
|
||||
const curLine = cm.getLine(end);
|
||||
if(curLine.match(startMatcher)) braceCount++;
|
||||
if(curLine.match(endMatcher)) braceCount--;
|
||||
if(braceCount == 0) break;
|
||||
++end;
|
||||
}
|
||||
|
||||
return {
|
||||
from : CodeMirror.Pos(start.line, 0),
|
||||
to : CodeMirror.Pos(end, cm.getLine(end).length)
|
||||
};
|
||||
}
|
||||
|
||||
// @import and data-url folding
|
||||
const importMatcher = /^@import.*?;/;
|
||||
const dataURLMatcher = /url\(.*?data\:.*\)/;
|
||||
|
||||
if(activeLine.match(importMatcher) || activeLine.match(dataURLMatcher)) {
|
||||
return {
|
||||
from : CodeMirror.Pos(start.line, 0),
|
||||
to : CodeMirror.Pos(start.line, activeLine.length)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
};
|
||||
26
client/components/codeEditor/fold-pages.js
Normal file
26
client/components/codeEditor/fold-pages.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export default {
|
||||
registerHomebreweryHelper : function(CodeMirror) {
|
||||
CodeMirror.registerHelper('fold', 'homebrewery', function(cm, start) {
|
||||
const matcher = /^\\page.*/;
|
||||
const prevLine = cm.getLine(start.line - 1);
|
||||
|
||||
if(start.line === cm.firstLine() || prevLine.match(matcher)) {
|
||||
const lastLineNo = cm.lastLine();
|
||||
let end = start.line;
|
||||
|
||||
while (end < lastLineNo) {
|
||||
if(cm.getLine(end + 1).match(matcher))
|
||||
break;
|
||||
++end;
|
||||
}
|
||||
|
||||
return {
|
||||
from : CodeMirror.Pos(start.line, 0),
|
||||
to : CodeMirror.Pos(end, cm.getLine(end).length)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
}
|
||||
};
|
||||
129
client/components/combobox.jsx
Normal file
129
client/components/combobox.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import './combobox.less';
|
||||
|
||||
const Combobox = createReactClass({
|
||||
displayName : 'Combobox',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
className : '',
|
||||
trigger : 'hover',
|
||||
default : '',
|
||||
placeholder : '',
|
||||
autoSuggest : {
|
||||
clearAutoSuggestOnClick : true,
|
||||
suggestMethod : 'includes',
|
||||
filterOn : [] // should allow as array to filter on multiple attributes, or even custom filter
|
||||
},
|
||||
};
|
||||
},
|
||||
getInitialState : function() {
|
||||
return {
|
||||
showDropdown : false,
|
||||
value : '',
|
||||
options : [...this.props.options],
|
||||
inputFocused : false
|
||||
};
|
||||
},
|
||||
componentDidMount : function() {
|
||||
if(this.props.trigger == 'click')
|
||||
document.addEventListener('click', this.handleClickOutside);
|
||||
this.setState({
|
||||
value : this.props.default
|
||||
});
|
||||
},
|
||||
componentWillUnmount : function() {
|
||||
if(this.props.trigger == 'click')
|
||||
document.removeEventListener('click', this.handleClickOutside);
|
||||
},
|
||||
handleClickOutside : function(e){
|
||||
// Close dropdown when clicked outside
|
||||
if(this.refs.dropdown && !this.refs.dropdown.contains(e.target)) {
|
||||
this.handleDropdown(false);
|
||||
}
|
||||
},
|
||||
handleDropdown : function(show){
|
||||
this.setState({
|
||||
value : show ? '' : this.props.default,
|
||||
showDropdown : show,
|
||||
inputFocused : this.props.autoSuggest.clearAutoSuggestOnClick ? show : false
|
||||
});
|
||||
},
|
||||
handleInput : function(e){
|
||||
e.persist();
|
||||
this.setState({
|
||||
value : e.target.value,
|
||||
inputFocused : false
|
||||
}, ()=>{
|
||||
this.props.onEntry(e);
|
||||
});
|
||||
},
|
||||
handleSelect : function(value, data=value){
|
||||
this.setState({
|
||||
value : value
|
||||
}, ()=>{this.props.onSelect(data);});
|
||||
;
|
||||
},
|
||||
renderTextInput : function(){
|
||||
return (
|
||||
<div className='dropdown-input item'
|
||||
onMouseEnter={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(true);} : undefined}
|
||||
onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}>
|
||||
<input
|
||||
type='text'
|
||||
onChange={(e)=>this.handleInput(e)}
|
||||
value={this.state.value || ''}
|
||||
placeholder={this.props.placeholder}
|
||||
onBlur={(e)=>{
|
||||
if(!e.target.checkValidity()){
|
||||
this.setState({
|
||||
value : this.props.default
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<i className='fas fa-caret-down'/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
renderDropdown : function(dropdownChildren){
|
||||
if(!this.state.showDropdown) return null;
|
||||
if(this.props.autoSuggest && !this.state.inputFocused){
|
||||
const suggestMethod = this.props.autoSuggest.suggestMethod;
|
||||
const filterOn = _.isString(this.props.autoSuggest.filterOn) ? [this.props.autoSuggest.filterOn] : this.props.autoSuggest.filterOn;
|
||||
const filteredArrays = filterOn.map((attr)=>{
|
||||
const children = dropdownChildren.filter((item)=>{
|
||||
if(suggestMethod === 'includes')
|
||||
return item.props[attr]?.toLowerCase().includes(this.state.value.toLowerCase());
|
||||
if(suggestMethod === 'startsWith')
|
||||
return item.props[attr]?.toLowerCase().startsWith(this.state.value.toLowerCase());
|
||||
});
|
||||
return children;
|
||||
});
|
||||
dropdownChildren = _.uniq(filteredArrays.flat(1));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='dropdown-options'>
|
||||
{dropdownChildren}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
render : function () {
|
||||
const dropdownChildren = this.state.options.map((child, i)=>{
|
||||
const clone = React.cloneElement(child, { onClick: ()=>this.handleSelect(child.props.value, child.props.data) });
|
||||
return clone;
|
||||
});
|
||||
return (
|
||||
<div className={`dropdown-container ${this.props.className}`}
|
||||
ref='dropdown'
|
||||
onMouseLeave={this.props.trigger == 'hover' ? ()=>{this.handleDropdown(false);} : undefined}>
|
||||
{this.renderTextInput()}
|
||||
{this.renderDropdown(dropdownChildren)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default Combobox;
|
||||
46
client/components/combobox.less
Normal file
46
client/components/combobox.less
Normal file
@@ -0,0 +1,46 @@
|
||||
.dropdown-container {
|
||||
position : relative;
|
||||
input { width : 100%; }
|
||||
.item i {
|
||||
position : absolute;
|
||||
right : 10px;
|
||||
color : black;
|
||||
}
|
||||
.dropdown-options {
|
||||
position : absolute;
|
||||
z-index : 100;
|
||||
width : 100%;
|
||||
max-height : 200px;
|
||||
overflow-y : auto;
|
||||
background-color : white;
|
||||
border : 1px solid gray;
|
||||
|
||||
&::-webkit-scrollbar { width : 14px; }
|
||||
&::-webkit-scrollbar-track { background : #FFFFFF; }
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color : #949494;
|
||||
border : 3px solid #FFFFFF;
|
||||
border-radius : 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
position : relative;
|
||||
padding : 5px;
|
||||
margin : 0 3px;
|
||||
font-family : 'Open Sans';
|
||||
font-size : 11px;
|
||||
cursor : default;
|
||||
&:hover {
|
||||
background-color : rgb(163, 163, 163);
|
||||
filter : brightness(120%);
|
||||
}
|
||||
.detail {
|
||||
width : 100%;
|
||||
font-size : 9px;
|
||||
font-style : italic;
|
||||
color : rgb(124, 124, 124);
|
||||
text-align : left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
client/components/dialog.jsx
Normal file
31
client/components/dialog.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// Dialog box, for popups and modal blocking messages
|
||||
import React from 'react';
|
||||
const { useRef, useEffect } = React;
|
||||
|
||||
function Dialog({ dismisskeys = [], closeText = 'Close', blocking = false, ...rest }) {
|
||||
const dialogRef = useRef(null);
|
||||
|
||||
useEffect(()=>{
|
||||
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
|
||||
}, []);
|
||||
|
||||
const dismiss = ()=>{
|
||||
dismisskeys.forEach((key)=>{
|
||||
if(key) {
|
||||
localStorage.setItem(key, 'true');
|
||||
}
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} onCancel={dismiss} {...rest}>
|
||||
{rest.children}
|
||||
<button className='dismiss' onClick={dismiss}>
|
||||
{closeText}
|
||||
</button>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dialog;
|
||||
60
client/components/renderWarnings/renderWarnings.jsx
Normal file
60
client/components/renderWarnings/renderWarnings.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import './renderWarnings.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
|
||||
import Dialog from '../dialog.jsx';
|
||||
|
||||
const RenderWarnings = createReactClass({
|
||||
displayName : 'RenderWarnings',
|
||||
getInitialState : function() {
|
||||
return {
|
||||
warnings : {}
|
||||
};
|
||||
},
|
||||
componentDidMount : function() {
|
||||
this.checkWarnings();
|
||||
window.addEventListener('resize', this.checkWarnings);
|
||||
},
|
||||
componentWillUnmount : function() {
|
||||
window.removeEventListener('resize', this.checkWarnings);
|
||||
},
|
||||
warnings : {
|
||||
chrome : function(){
|
||||
const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
|
||||
if(!isChrome){
|
||||
return <li key='chrome'>
|
||||
<em>Built for Chrome </em> <br />
|
||||
Other browsers have not been tested for compatibility. If you
|
||||
experience issues with your document not rendering or printing
|
||||
properly, please try using the latest version of Chrome before
|
||||
submitting a bug report.
|
||||
</li>;
|
||||
}
|
||||
},
|
||||
},
|
||||
checkWarnings : function(){
|
||||
this.setState({
|
||||
warnings : _.reduce(this.warnings, (r, fn, type)=>{
|
||||
const element = fn();
|
||||
if(element) r[type] = element;
|
||||
return r;
|
||||
}, {})
|
||||
});
|
||||
},
|
||||
render : function(){
|
||||
if(_.isEmpty(this.state.warnings)) return null;
|
||||
|
||||
const DISMISS_KEY = 'dismiss_render_warning';
|
||||
const DISMISS_TEXT = <i className='fas fa-times dismiss' />;
|
||||
|
||||
return <Dialog className='renderWarnings' dismissKey={DISMISS_KEY} closeText={DISMISS_TEXT}>
|
||||
<i className='fas fa-exclamation-triangle ohno' />
|
||||
<h3>Render Warnings</h3>
|
||||
<small>If this homebrew is rendering badly if might be because of the following:</small>
|
||||
<ul>{_.values(this.state.warnings)}</ul>
|
||||
</Dialog>;
|
||||
}
|
||||
});
|
||||
|
||||
export default RenderWarnings;
|
||||
50
client/components/renderWarnings/renderWarnings.less
Normal file
50
client/components/renderWarnings/renderWarnings.less
Normal file
@@ -0,0 +1,50 @@
|
||||
@import './shared/naturalcrit/styles/colors.less';
|
||||
|
||||
.renderWarnings {
|
||||
position : relative;
|
||||
float : right;
|
||||
width : 350px;
|
||||
padding : 20px;
|
||||
padding-bottom : 10px;
|
||||
padding-left : 85px;
|
||||
margin-bottom : 10px;
|
||||
color : white;
|
||||
background-color : @yellow;
|
||||
border : none;
|
||||
a { font-weight : 800; }
|
||||
i.ohno {
|
||||
position : absolute;
|
||||
top : 24px;
|
||||
left : 24px;
|
||||
font-size : 2.5em;
|
||||
opacity : 0.8;
|
||||
}
|
||||
button.dismiss {
|
||||
position : absolute;
|
||||
top : 10px;
|
||||
right : 10px;
|
||||
cursor : pointer;
|
||||
background-color : transparent;
|
||||
opacity : 0.6;
|
||||
&:hover { opacity : 1; }
|
||||
}
|
||||
small {
|
||||
font-size : 0.6em;
|
||||
opacity : 0.7;
|
||||
}
|
||||
h3 {
|
||||
font-size : 1.1em;
|
||||
font-weight : 800;
|
||||
}
|
||||
ul {
|
||||
margin-top : 15px;
|
||||
font-size : 0.8em;
|
||||
list-style-position : outside;
|
||||
list-style-type : disc;
|
||||
li {
|
||||
font-size : 0.8em;
|
||||
line-height : 1.6em;
|
||||
em { font-weight : 800; }
|
||||
}
|
||||
}
|
||||
}
|
||||
111
client/components/splitPane/splitPane.jsx
Normal file
111
client/components/splitPane/splitPane.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
|
||||
import './splitPane.less';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const PANE_WIDTH_KEY = 'HB_editor_splitWidth';
|
||||
const LIVE_SCROLL_KEY = 'HB_editor_liveScroll';
|
||||
|
||||
const SplitPane = (props)=>{
|
||||
const {
|
||||
onDragFinish = ()=>{},
|
||||
showDividerButtons = true
|
||||
} = props;
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dividerPos, setDividerPos] = useState(null);
|
||||
const [moveSource, setMoveSource] = useState(false);
|
||||
const [moveBrew, setMoveBrew] = useState(false);
|
||||
const [showMoveArrows, setShowMoveArrows] = useState(true);
|
||||
const [liveScroll, setLiveScroll] = useState(false);
|
||||
|
||||
useEffect(()=>{
|
||||
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(LIVE_SCROLL_KEY) === 'true');
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return ()=>window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x)));
|
||||
|
||||
//when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position
|
||||
const handleResize = ()=>setDividerPos(limitPosition(window.localStorage.getItem(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(PANE_WIDTH_KEY, dividerPos);
|
||||
}
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDown = (e)=>{
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleMove = (e)=>{
|
||||
if(!isDragging) return;
|
||||
e.preventDefault();
|
||||
setDividerPos(limitPosition(e.pageX));
|
||||
};
|
||||
|
||||
const liveScrollToggle = ()=>{
|
||||
window.localStorage.setItem(LIVE_SCROLL_KEY, String(!liveScroll));
|
||||
setLiveScroll(!liveScroll);
|
||||
};
|
||||
|
||||
const renderMoveArrows = (showMoveArrows &&
|
||||
<>
|
||||
<div className='arrow left'
|
||||
onClick={()=>setMoveSource(!moveSource)} >
|
||||
<i className='fas fa-arrow-left' />
|
||||
</div>
|
||||
<div className='arrow right'
|
||||
onClick={()=>setMoveBrew(!moveBrew)} >
|
||||
<i className='fas fa-arrow-right' />
|
||||
</div>
|
||||
<div id='scrollToggleDiv' className={liveScroll ? 'arrow lock' : 'arrow unlock'}
|
||||
onClick={liveScrollToggle} >
|
||||
<i id='scrollToggle' className={liveScroll ? 'fas fa-lock' : 'fas fa-unlock'} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const renderDivider = (
|
||||
<div className={`divider ${isDragging && 'dragging'}`} onPointerDown={handleDown}>
|
||||
{showDividerButtons && renderMoveArrows}
|
||||
<div className='dots'>
|
||||
<i className='fas fa-circle' />
|
||||
<i className='fas fa-circle' />
|
||||
<i className='fas fa-circle' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='splitPane' onPointerMove={handleMove} onPointerUp={handleUp}>
|
||||
<Pane width={dividerPos} moveBrew={moveBrew} moveSource={moveSource} liveScroll={liveScroll} setMoveArrows={setShowMoveArrows}>
|
||||
{props.children[0]}
|
||||
</Pane>
|
||||
{renderDivider}
|
||||
<Pane isDragging={isDragging}>{props.children[1]}</Pane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Pane = ({ width, children, isDragging, moveBrew, moveSource, liveScroll, setMoveArrows })=>{
|
||||
const styles = width
|
||||
? { flex: 'none', width: `${width}px` }
|
||||
: { pointerEvents: isDragging ? 'none' : 'auto' }; //Disable mouse capture in the right pane; else dragging into the iframe drops the divider
|
||||
|
||||
return (
|
||||
<div className='pane' style={styles}>
|
||||
{React.cloneElement(children, { moveBrew, moveSource, liveScroll, setMoveArrows })}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplitPane;
|
||||
69
client/components/splitPane/splitPane.less
Normal file
69
client/components/splitPane/splitPane.less
Normal file
@@ -0,0 +1,69 @@
|
||||
@import './shared/naturalcrit/styles/core.less';
|
||||
|
||||
.splitPane {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-direction : row;
|
||||
height : 100%;
|
||||
outline : none;
|
||||
.pane {
|
||||
flex : 1;
|
||||
overflow-x : hidden;
|
||||
overflow-y : hidden;
|
||||
}
|
||||
.divider {
|
||||
position : relative;
|
||||
display : table;
|
||||
width : 15px;
|
||||
height : 100%;
|
||||
text-align : center;
|
||||
touch-action : none;
|
||||
cursor : ew-resize;
|
||||
background-color : #BBBBBB;
|
||||
.dots {
|
||||
display : table-cell;
|
||||
vertical-align : middle;
|
||||
text-align : center;
|
||||
i {
|
||||
display : block !important;
|
||||
margin : 10px 0px;
|
||||
font-size : 6px;
|
||||
color : #666666;
|
||||
}
|
||||
}
|
||||
&:hover,&.dragging { background-color : #999999; }
|
||||
}
|
||||
.arrow {
|
||||
position : absolute;
|
||||
left : 50%;
|
||||
z-index : 999;
|
||||
width : 25px;
|
||||
height : 25px;
|
||||
font-size : 1.2em;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
background-color : #DDDDDD;
|
||||
border : 2px solid #BBBBBB;
|
||||
border-radius : 15px;
|
||||
box-shadow : 0 4px 5px #0000007F;
|
||||
translate : -50%;
|
||||
&.left {
|
||||
.tooltipLeft('Jump to location in Editor');
|
||||
top : 30px;
|
||||
}
|
||||
&.right {
|
||||
.tooltipRight('Jump to location in Preview');
|
||||
top : 60px;
|
||||
}
|
||||
&.lock {
|
||||
.tooltipRight('De-sync Editor and Preview locations.');
|
||||
top : 90px;
|
||||
background : #666666;
|
||||
}
|
||||
&.unlock {
|
||||
.tooltipRight('Sync Editor and Preview locations');
|
||||
top : 90px;
|
||||
}
|
||||
&:hover { background-color : #666666; }
|
||||
}
|
||||
}
|
||||
9
client/components/svg/cauldron.svg.jsx
Normal file
9
client/components/svg/cauldron.svg.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function(props){
|
||||
return <svg version='1.1' x='0px' y='0px' viewBox='0 0 90 112.5' enableBackground='new 0 0 90 90' >
|
||||
<path d='M25.363,25.54c0,1.906,8.793,3.454,19.636,3.454c10.848,0,19.638-1.547,19.638-3.454c0-1.12-3.056-2.117-7.774-2.75 c-1.418,1.891-3.659,3.133-6.208,3.133c-2.85,0-5.315-1.547-6.67-3.833C33.617,22.185,25.363,23.692,25.363,25.54z'/><path d='M84.075,54.142c0-8.68-2.868-17.005-8.144-23.829c1.106-1.399,1.41-2.771,1.41-3.854c0-6.574-10.245-9.358-19.264-10.533 c0.209,0.706,0.359,1.439,0.359,2.215c0,0.09-0.022,0.17-0.028,0.26l0,0c-0.028,0.853-0.195,1.667-0.479,2.429 c9.106,1.282,14.508,3.754,14.508,5.63c0,2.644-10.688,6.486-27.439,6.486c-16.748,0-27.438-3.842-27.438-6.486 c0-2.542,9.904-6.183,25.559-6.459c-0.098-0.396-0.159-0.807-0.2-1.223c0.006,0,0.013,0,0.017,0 c-0.017-0.213-0.063-0.417-0.063-0.636c0-1.084,0.226-2.119,0.628-3.058c-6.788,0.129-30.846,1.299-30.846,11.376 c0,1.083,0.305,2.455,1.411,3.854c-5.276,6.823-8.145,15.149-8.145,23.829c0,11.548,5.187,20.107,14.693,25.115 c-0.902,3.146-1.391,7.056,1.111,8.181c2.626,1.178,5.364-2.139,7.111-5.005c4.73,1.261,10.13,1.923,16.161,1.923 c6.034,0,11.428-0.661,16.158-1.922c1.75,2.865,4.493,6.18,7.112,5.004c2.504-1.123,2.014-5.035,1.113-8.179 C78.889,74.249,84.075,65.689,84.075,54.142z M70.39,31.392c5.43,6.046,8.78,14,8.78,22.75c0,20.919-18.582,25.309-34.171,25.309 c-15.587,0-34.17-4.39-34.17-25.309c0-8.75,3.35-16.7,8.781-22.753c5.561,2.643,15.502,4.009,25.389,4.009 C54.886,35.397,64.829,34.031,70.39,31.392z'/><path d='M50.654,23.374c2.892,0,5.234-2.341,5.234-5.233c0-2.887-2.343-5.23-5.234-5.23c-2.887,0-5.231,2.343-5.231,5.23 C45.423,21.032,47.768,23.374,50.654,23.374z'/>
|
||||
<circle cx='62.905' cy='10.089' r='3.595'/>
|
||||
<circle cx='52.616' cy='5.048' r='2.73'/>
|
||||
</svg>;
|
||||
};
|
||||
5
client/components/svg/naturalcrit-d20.svg.jsx
Normal file
5
client/components/svg/naturalcrit-d20.svg.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function(props){
|
||||
return <svg version='1.1' x='0px' y='0px' viewBox='0 0 100 100' enableBackground='new 0 0 100 100'><path d='M80.644,87.982l16.592-41.483c0.054-0.128,0.088-0.26,0.108-0.394c0.006-0.039,0.007-0.077,0.011-0.116 c0.007-0.087,0.008-0.174,0.002-0.26c-0.003-0.046-0.007-0.091-0.014-0.137c-0.014-0.089-0.036-0.176-0.063-0.262 c-0.012-0.034-0.019-0.069-0.031-0.103c-0.047-0.118-0.106-0.229-0.178-0.335c-0.004-0.006-0.006-0.012-0.01-0.018L67.999,3.358 c-0.01-0.013-0.003-0.026-0.013-0.04L68,3.315V4c0,0-0.033,0-0.037,0c-0.403-1-1.094-1.124-1.752-0.976 c0,0.004-0.004-0.012-0.007-0.012C66.201,3.016,66.194,3,66.194,3H66.19h-0.003h-0.003h-0.004h-0.003c0,0-0.004,0-0.007,0 s-0.003-0.151-0.007-0.151L20.495,15.227c-0.025,0.007-0.046-0.019-0.071-0.011c-0.087,0.028-0.172,0.041-0.253,0.083 c-0.054,0.027-0.102,0.053-0.152,0.085c-0.051,0.033-0.101,0.061-0.147,0.099c-0.044,0.036-0.084,0.073-0.124,0.113 c-0.048,0.048-0.093,0.098-0.136,0.152c-0.03,0.039-0.059,0.076-0.085,0.117c-0.046,0.07-0.084,0.145-0.12,0.223 c-0.011,0.023-0.027,0.042-0.036,0.066L2.911,57.664C2.891,57.715,3,57.768,3,57.82v0.002c0,0.186,0,0.375,0,0.562 c0,0.004,0,0.004,0,0.008c0,0,0,0,0,0.002c0,0,0,0,0,0.004v0.004v0.002c0,0.074-0.002,0.15,0.012,0.223 C3.015,58.631,3,58.631,3,58.633c0,0.004,0,0.004,0,0.008c0,0,0,0,0,0.002c0,0,0,0,0,0.004v0.004c0,0,0,0,0,0.002v0.004 c0,0.191-0.046,0.377,0.06,0.545c0-0.002-0.03,0.004-0.03,0.004c0,0.004-0.03,0.004-0.03,0.004c0,0.002,0,0.002,0,0.002 l-0.045,0.004c0.03,0.047,0.036,0.09,0.068,0.133l29.049,37.359c0.002,0.004,0,0.006,0.002,0.01c0.002,0.002,0,0.004,0.002,0.008 c0.006,0.008,0.014,0.014,0.021,0.021c0.024,0.029,0.052,0.051,0.078,0.078c0.027,0.029,0.053,0.057,0.082,0.082 c0.03,0.027,0.055,0.062,0.086,0.088c0.026,0.02,0.057,0.033,0.084,0.053c0.04,0.027,0.081,0.053,0.123,0.076 c0.005,0.004,0.01,0.008,0.016,0.01c0.087,0.051,0.176,0.09,0.269,0.123c0.042,0.014,0.082,0.031,0.125,0.043 c0.021,0.006,0.041,0.018,0.062,0.021c0.123,0.027,0.249,0.043,0.375,0.043c0.099,0,0.202-0.012,0.304-0.027l45.669-8.303 c0.057-0.01,0.108-0.021,0.163-0.037C79.547,88.992,79.562,89,79.575,89c0.004,0,0.004,0,0.004,0c0.021,0,0.039-0.027,0.06-0.035 c0.041-0.014,0.08-0.034,0.12-0.052c0.021-0.01,0.044-0.019,0.064-0.03c0.017-0.01,0.026-0.015,0.033-0.017 c0.014-0.008,0.023-0.021,0.037-0.028c0.14-0.078,0.269-0.174,0.38-0.285c0.014-0.016,0.024-0.034,0.038-0.048 c0.109-0.119,0.201-0.252,0.271-0.398c0.006-0.01,0.016-0.018,0.021-0.029c0.004-0.008,0.008-0.017,0.011-0.026 c0.002-0.004,0.003-0.006,0.005-0.01C80.627,88.021,80.635,88.002,80.644,87.982z M77.611,84.461L48.805,66.453l32.407-25.202 L77.611,84.461z M46.817,63.709L35.863,23.542l43.818,14.608L46.817,63.709z M84.668,40.542l8.926,5.952l-11.902,29.75 L84.668,40.542z M89.128,39.446L84.53,36.38l-6.129-12.257L89.128,39.446z M79.876,34.645L37.807,20.622L65.854,6.599L79.876,34.645 z M33.268,19.107l-6.485-2.162l23.781-6.487L33.268,19.107z M21.92,18.895l8.67,2.891L10.357,47.798L21.92,18.895z M32.652,24.649 l10.845,39.757L7.351,57.178L32.652,24.649z M43.472,67.857L32.969,92.363L8.462,60.855L43.472,67.857z M46.631,69.09l27.826,17.393 l-38.263,6.959L46.631,69.09z'></path></svg>;
|
||||
};
|
||||
12
client/entry-client-admin.jsx
Normal file
12
client/entry-client-admin.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import Admin from './admin.jsx';
|
||||
|
||||
import './admin/admin.less'
|
||||
|
||||
window.start_app = (props) => {
|
||||
hydrateRoot(
|
||||
document.getElementById('reactRoot'),
|
||||
<Admin {...props} />
|
||||
)
|
||||
}
|
||||
16
client/entry-client-homebrew.jsx
Normal file
16
client/entry-client-homebrew.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import Homebrew from "./homebrew/homebrew.jsx";
|
||||
|
||||
// CSS MUST be imported here
|
||||
import "./homebrew/homebrew.less"; // or wherever your CSS lives
|
||||
|
||||
// Polyfill `global` in the browser
|
||||
if (typeof global === 'undefined') {
|
||||
window.global = window;
|
||||
}
|
||||
|
||||
console.log("entry-client-homebrew");
|
||||
const props = window.__SSR_PROPS__ || {};
|
||||
console.log("props: ", props);
|
||||
hydrateRoot(document.getElementById("reactRoot"), <Homebrew {...props} />);
|
||||
4
client/entry-server-admin.jsx
Normal file
4
client/entry-server-admin.jsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import Admin from './admin/admin.jsx';
|
||||
|
||||
export default (props) => renderToString(<Admin {...props} />);
|
||||
4
client/entry-server-homebrew.jsx
Normal file
4
client/entry-server-homebrew.jsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import Homebrew from './homebrew/homebrew.jsx';
|
||||
|
||||
export default (props) => renderToString(<Homebrew {...props} />);
|
||||
348
client/homebrew/brewRenderer/brewRenderer.jsx
Normal file
348
client/homebrew/brewRenderer/brewRenderer.jsx
Normal file
@@ -0,0 +1,348 @@
|
||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
||||
import './brewRenderer.less';
|
||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import MarkdownLegacy from '../../../shared/markdownLegacy.js';
|
||||
import Markdown from '../../../shared/markdown.js';
|
||||
import ErrorBar from './errorBar/errorBar.jsx';
|
||||
import ToolBar from './toolBar/toolBar.jsx';
|
||||
|
||||
//TODO: move to the brew renderer
|
||||
import RenderWarnings from '../../components/renderWarnings/renderWarnings.jsx';
|
||||
import NotificationPopup from './notificationPopup/notificationPopup.jsx';
|
||||
import frameComp from 'react-frame-component';
|
||||
const Frame = frameComp.default;
|
||||
import dedent from 'dedent';
|
||||
import { printCurrentBrew } from '../../../shared/helpers.js';
|
||||
|
||||
import HeaderNav from './headerNav/headerNav.jsx';
|
||||
import safeHTML from './safeHTML.js';
|
||||
|
||||
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
|
||||
const PAGEBREAK_REGEX_LEGACY = /\\page(?:break)?/m;
|
||||
const COLUMNBREAK_REGEX_LEGACY = /\\column(:?break)?/m;
|
||||
const 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" />
|
||||
<link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
|
||||
<base target=_blank>
|
||||
</head><body style='overflow: hidden'><div></div></body></html>`;
|
||||
|
||||
|
||||
//v=====----------------------< Brew Page Component >---------------------=====v//
|
||||
const BrewPage = (props)=>{
|
||||
props = {
|
||||
contents : '',
|
||||
index : 0,
|
||||
...props
|
||||
};
|
||||
const pageRef = useRef(null);
|
||||
const cleanText = safeHTML(props.contents);
|
||||
|
||||
useEffect(()=>{
|
||||
if(!pageRef.current) return;
|
||||
|
||||
// Observer for tracking pages within the `.pages` div
|
||||
const visibleObserver = new IntersectionObserver(
|
||||
(entries)=>{
|
||||
entries.forEach((entry)=>{
|
||||
if(entry.isIntersecting)
|
||||
props.onVisibilityChange(props.index + 1, true, false); // add page to array of visible pages.
|
||||
else
|
||||
props.onVisibilityChange(props.index + 1, false, false);
|
||||
});
|
||||
},
|
||||
{ threshold: .3, rootMargin: '0px 0px 0px 0px' } // detect when >30% of page is within bounds.
|
||||
);
|
||||
|
||||
// Observer for tracking the page at the center of the iframe.
|
||||
const centerObserver = new IntersectionObserver(
|
||||
(entries)=>{
|
||||
entries.forEach((entry)=>{
|
||||
if(entry.isIntersecting)
|
||||
props.onVisibilityChange(props.index + 1, true, true); // Set this page as the center page
|
||||
});
|
||||
},
|
||||
{ threshold: 0, rootMargin: '-50% 0px -50% 0px' } // Detect when the page is at the center
|
||||
);
|
||||
|
||||
// attach observers to each `.page`
|
||||
visibleObserver.observe(pageRef.current);
|
||||
centerObserver.observe(pageRef.current);
|
||||
return ()=>{
|
||||
visibleObserver.disconnect();
|
||||
centerObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div className={props.className} id={`p${props.index + 1}`} data-index={props.index} ref={pageRef} style={props.style} {...props.attributes}>
|
||||
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
||||
//v=====--------------------< Brew Renderer Component >-------------------=====v//
|
||||
let renderedPages = [];
|
||||
let rawPages = [];
|
||||
|
||||
const BrewRenderer = (props)=>{
|
||||
props = {
|
||||
text : '',
|
||||
style : '',
|
||||
renderer : 'legacy',
|
||||
theme : '5ePHB',
|
||||
lang : '',
|
||||
errors : [],
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentEditorViewPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
themeBundle : {},
|
||||
onPageChange : ()=>{},
|
||||
...props
|
||||
};
|
||||
|
||||
const [state, setState] = useState({
|
||||
isMounted : false,
|
||||
visibility : 'hidden',
|
||||
visiblePages : [],
|
||||
centerPage : 1
|
||||
});
|
||||
|
||||
const [displayOptions, setDisplayOptions] = useState({
|
||||
zoomLevel : 100,
|
||||
spread : 'single',
|
||||
startOnRight : true,
|
||||
pageShadows : true,
|
||||
rowGap : 5,
|
||||
columnGap : 10,
|
||||
});
|
||||
|
||||
//useEffect to store or gather toolbar state from storage
|
||||
useEffect(()=>{
|
||||
const toolbarState = JSON.parse(window.localStorage.getItem(TOOLBAR_STATE_KEY));
|
||||
toolbarState && setDisplayOptions(toolbarState);
|
||||
}, []);
|
||||
|
||||
const [headerState, setHeaderState] = useState(false);
|
||||
|
||||
const mainRef = useRef(null);
|
||||
const pagesRef = useRef(null);
|
||||
|
||||
if(props.renderer == 'legacy') {
|
||||
rawPages = props.text.split(PAGEBREAK_REGEX_LEGACY);
|
||||
} else {
|
||||
rawPages = props.text.split(PAGEBREAK_REGEX_V3);
|
||||
}
|
||||
|
||||
const handlePageVisibilityChange = (pageNum, isVisible, isCenter)=>{
|
||||
setState((prevState)=>{
|
||||
const updatedVisiblePages = new Set(prevState.visiblePages);
|
||||
if(!isCenter)
|
||||
isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum);
|
||||
|
||||
return {
|
||||
...prevState,
|
||||
visiblePages : [...updatedVisiblePages].sort((a, b)=>a - b),
|
||||
centerPage : isCenter ? pageNum : prevState.centerPage
|
||||
};
|
||||
});
|
||||
|
||||
if(isCenter)
|
||||
props.onPageChange(pageNum);
|
||||
};
|
||||
|
||||
const isInView = (index)=>{
|
||||
if(!state.isMounted)
|
||||
return false;
|
||||
|
||||
if(index == props.currentEditorCursorPageNum - 1) //Already rendered before this step
|
||||
return false;
|
||||
|
||||
if(Math.abs(index - props.currentBrewRendererPageNum - 1) <= 3)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const renderDummyPage = (index)=>{
|
||||
return <div className='phb page' id={`p${index + 1}`} key={index}>
|
||||
<i className='fas fa-spinner fa-spin' />
|
||||
</div>;
|
||||
};
|
||||
|
||||
const renderStyle = ()=>{
|
||||
const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>';
|
||||
const cleanStyle = safeHTML(`${themeStyles} \n\n <style> ${props.style} </style>`);
|
||||
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: cleanStyle }} />;
|
||||
};
|
||||
|
||||
const renderPage = (pageText, index)=>{
|
||||
|
||||
let styles = {
|
||||
...(!displayOptions.pageShadows ? { boxShadow: 'none' } : {})
|
||||
// Add more conditions as needed
|
||||
};
|
||||
let classes = 'page';
|
||||
let attributes = {};
|
||||
|
||||
if(props.renderer == 'legacy') {
|
||||
pageText.replace(COLUMNBREAK_REGEX_LEGACY, '```\n````\n'); // Allow Legacy brews to use `\column(break)`
|
||||
const html = MarkdownLegacy.render(pageText);
|
||||
|
||||
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
|
||||
} else {
|
||||
if(pageText.startsWith('\\page')) {
|
||||
const firstLineTokens = Markdown.marked.lexer(pageText.split('\n', 1)[0])[0].tokens;
|
||||
const injectedTags = firstLineTokens?.find((obj)=>obj.injectedTags !== undefined)?.injectedTags;
|
||||
if(injectedTags) {
|
||||
styles = { ...styles, ...injectedTags.styles };
|
||||
styles = _.mapKeys(styles, (v, k)=>k.startsWith('--') ? k : _.camelCase(k)); // Convert CSS to camelCase for React
|
||||
classes = [classes, injectedTags.classes].join(' ').trim();
|
||||
attributes = injectedTags.attributes;
|
||||
}
|
||||
pageText = pageText.includes('\n') ? pageText.substring(pageText.indexOf('\n') + 1) : ''; // Remove the \page line
|
||||
}
|
||||
|
||||
// DO NOT REMOVE!!! REQUIRED FOR BACKWARDS COMPATIBILITY WITH NON-UPGRADABLE VERSIONS OF CHROME.
|
||||
pageText += `\n\n \n\\column\n `; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
|
||||
|
||||
const html = Markdown.render(pageText, index);
|
||||
|
||||
return <BrewPage className={classes} index={index} key={index} contents={html} style={styles} attributes={attributes} onVisibilityChange={handlePageVisibilityChange} />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderPages = ()=>{
|
||||
if(props.errors && props.errors.length)
|
||||
return renderedPages;
|
||||
|
||||
if(rawPages.length != renderedPages.length) // Re-render all pages when page count changes
|
||||
renderedPages.length = 0;
|
||||
|
||||
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
|
||||
if(rawPages.length > props.currentEditorCursorPageNum -1)
|
||||
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
|
||||
|
||||
_.forEach(rawPages, (page, index)=>{
|
||||
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
|
||||
renderedPages[index] = renderPage(page, index); // Render any page not yet rendered, but only re-render those in PPR range
|
||||
}
|
||||
});
|
||||
return renderedPages;
|
||||
};
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
const P_KEY = 80;
|
||||
if(e.keyCode == P_KEY && props.allowPrint) printCurrentBrew();
|
||||
if(e.keyCode == P_KEY) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToHash = (hash)=>{
|
||||
if(!hash) return;
|
||||
|
||||
const iframeDoc = document.getElementById('BrewRenderer').contentDocument;
|
||||
let anchor = iframeDoc.querySelector(hash);
|
||||
|
||||
if(anchor) {
|
||||
anchor.scrollIntoView({ behavior: 'smooth' });
|
||||
} else {
|
||||
// Use MutationObserver to wait for the element if it's not immediately available
|
||||
new MutationObserver((mutations, obs)=>{
|
||||
anchor = iframeDoc.querySelector(hash);
|
||||
if(anchor) {
|
||||
anchor.scrollIntoView({ behavior: 'smooth' });
|
||||
obs.disconnect();
|
||||
}
|
||||
}).observe(iframeDoc, { childList: true, subtree: true });
|
||||
}
|
||||
};
|
||||
|
||||
const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount"
|
||||
scrollToHash(window.location.hash);
|
||||
|
||||
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
|
||||
renderPages(); //Make sure page is renderable before showing
|
||||
setState((prevState)=>({
|
||||
...prevState,
|
||||
isMounted : true,
|
||||
visibility : 'visible'
|
||||
}));
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const emitClick = ()=>{ // Allow clicks inside iFrame to interact with dropdowns, etc. from outside
|
||||
if(!window || !document) return;
|
||||
document.dispatchEvent(new MouseEvent('click'));
|
||||
};
|
||||
|
||||
const handleDisplayOptionsChange = (newDisplayOptions)=>{
|
||||
setDisplayOptions(newDisplayOptions);
|
||||
localStorage.setItem(TOOLBAR_STATE_KEY, JSON.stringify(newDisplayOptions));
|
||||
};
|
||||
|
||||
const pagesStyle = {
|
||||
zoom : `${displayOptions.zoomLevel}%`,
|
||||
columnGap : `${displayOptions.columnGap}px`,
|
||||
rowGap : `${displayOptions.rowGap}px`
|
||||
};
|
||||
|
||||
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
|
||||
renderedPages = useMemo(()=>renderPages(), [props.text, displayOptions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*render dummy page while iFrame is mounting.*/}
|
||||
{!state.isMounted
|
||||
? <div className='brewRenderer'>
|
||||
<div className='pages'>
|
||||
{renderDummyPage(1)}
|
||||
</div>
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<ErrorBar errors={props.errors} />
|
||||
<div className='popups' ref={mainRef}>
|
||||
<RenderWarnings />
|
||||
<NotificationPopup />
|
||||
</div>
|
||||
|
||||
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
|
||||
|
||||
{/*render in iFrame so broken code doesn't crash the site.*/}
|
||||
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
|
||||
style={{ width: '100%', height: '100%', visibility: state.visibility }}
|
||||
contentDidMount={frameDidMount}
|
||||
onClick={()=>{emitClick();}}
|
||||
>
|
||||
<div className='brewRenderer'
|
||||
onKeyDown={handleControlKeys}
|
||||
tabIndex={-1}
|
||||
>
|
||||
|
||||
{/* Apply CSS from Style tab and render pages from Markdown tab */}
|
||||
{state.isMounted
|
||||
&&
|
||||
<>
|
||||
{renderedStyle}
|
||||
<div className={`pages ${displayOptions.startOnRight ? 'recto' : 'verso'} ${displayOptions.spread}`} lang={`${props.lang || 'en'}`} style={pagesStyle} ref={pagesRef}>
|
||||
{renderedPages}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
{headerState ? <HeaderNav ref={pagesRef} /> : <></>}
|
||||
</Frame>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrewRenderer;
|
||||
84
client/homebrew/brewRenderer/brewRenderer.less
Normal file
84
client/homebrew/brewRenderer/brewRenderer.less
Normal file
@@ -0,0 +1,84 @@
|
||||
@import (multiple, less) 'shared/naturalcrit/styles/reset.less';
|
||||
|
||||
.brewRenderer {
|
||||
height : 100vh;
|
||||
padding-top : 60px;
|
||||
overflow-y : scroll;
|
||||
will-change : transform;
|
||||
&:has(.facing, .flow) { padding : 60px 30px; }
|
||||
:where(.pages) {
|
||||
&.facing {
|
||||
display : grid;
|
||||
grid-template-rows : repeat(3, auto);
|
||||
grid-template-columns : repeat(2, auto);
|
||||
gap : 10px 10px;
|
||||
justify-content : safe center;
|
||||
&.recto .page:first-child {
|
||||
// sets first page on 'right' ('recto') of the preview, as if for a Cover page.
|
||||
// todo: add a checkbox to toggle this setting
|
||||
grid-column-start : 2;
|
||||
}
|
||||
& :where(.page) {
|
||||
margin-right : unset !important;
|
||||
margin-left : unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.flow {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
gap : 10px;
|
||||
justify-content : safe center;
|
||||
& :where(.page) {
|
||||
flex : 0 0 auto;
|
||||
margin-right : unset !important;
|
||||
margin-left : unset !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
& > :where(.page) {
|
||||
width : 215.9mm;
|
||||
height : 279.4mm;
|
||||
margin-right : auto;
|
||||
margin-bottom : 30px;
|
||||
margin-left : auto;
|
||||
box-shadow : 1px 4px 14px #000000;
|
||||
}
|
||||
*[id] { scroll-margin-top : 100px; }
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width : 20px;
|
||||
&:horizontal {
|
||||
width : auto;
|
||||
height : 20px;
|
||||
}
|
||||
&-thumb {
|
||||
background : linear-gradient(90deg, #D3C1AF 15px, #00000000 15px);
|
||||
&:horizontal { background : linear-gradient(0deg, #D3C1AF 15px, #00000000 15px); }
|
||||
}
|
||||
&-corner { visibility : hidden; }
|
||||
}
|
||||
}
|
||||
|
||||
.pane { position : relative; }
|
||||
|
||||
|
||||
@media print {
|
||||
.toolBar { display : none; }
|
||||
.brewRenderer {
|
||||
height : 100%;
|
||||
padding : unset;
|
||||
overflow-y : unset;
|
||||
&:has(.facing, .flow) {
|
||||
padding : unset;
|
||||
}
|
||||
.pages {
|
||||
margin : 0px;
|
||||
zoom : 100% !important;
|
||||
display : block;
|
||||
& > .page { box-shadow : unset; }
|
||||
}
|
||||
}
|
||||
.headerNav { visibility : hidden; }
|
||||
}
|
||||
53
client/homebrew/brewRenderer/errorBar/errorBar.jsx
Normal file
53
client/homebrew/brewRenderer/errorBar/errorBar.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import './errorBar.less';
|
||||
import React from 'react';
|
||||
|
||||
import Dialog from '../../../components/dialog.jsx';
|
||||
|
||||
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
|
||||
|
||||
const ErrorBar = (props)=>{
|
||||
if(!props.errors.length) return null;
|
||||
let hasOpenError = false, hasCloseError = false, hasMatchError = false;
|
||||
|
||||
props.errors.map((err)=>{
|
||||
if(err.id === 'OPEN') hasOpenError = true;
|
||||
if(err.id === 'CLOSE') hasCloseError = true;
|
||||
if(err.id === 'MISMATCH') hasMatchError = true;
|
||||
});
|
||||
|
||||
const renderErrors = ()=>(
|
||||
<ul>
|
||||
{props.errors.map((err, idx)=>{
|
||||
return <li key={idx}>
|
||||
Line {err.line} : {err.text}, '{err.type}' tag
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
const renderProtip = ()=>(
|
||||
<div className='protips'>
|
||||
<h4>Protips!</h4>
|
||||
{hasOpenError && <div>Unmatched opening tag. Close your tags, like this {'</div>'}. Match types!</div>}
|
||||
{hasCloseError && <div>Unmatched closing tag. Either remove it or check where it was opened.</div>}
|
||||
{hasMatchError && <div>Type mismatch. Closed a tag with a different type.</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog className='errorBar' closeText={DISMISS_BUTTON} >
|
||||
<div>
|
||||
<i className='fas fa-exclamation-triangle' />
|
||||
<h2> There are HTML errors in your markup</h2>
|
||||
<small>
|
||||
If these aren't fixed your brew will not render properly when you print it to PDF or share it
|
||||
</small>
|
||||
{renderErrors()}
|
||||
</div>
|
||||
<hr />
|
||||
{renderProtip()}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorBar;
|
||||
59
client/homebrew/brewRenderer/errorBar/errorBar.less
Normal file
59
client/homebrew/brewRenderer/errorBar/errorBar.less
Normal file
@@ -0,0 +1,59 @@
|
||||
@import './shared/naturalcrit/styles/colors.less';
|
||||
|
||||
.errorBar {
|
||||
position : absolute;
|
||||
top : 32px;
|
||||
z-index : 1;
|
||||
width : 100%;
|
||||
color : white;
|
||||
background-color : @red;
|
||||
border : unset;
|
||||
|
||||
div {
|
||||
> i {
|
||||
float : left;
|
||||
margin-right : 10px;
|
||||
margin-bottom : 20px;
|
||||
font-size : 3em;
|
||||
opacity : 0.8;
|
||||
}
|
||||
h2 { font-weight : 800; }
|
||||
ul {
|
||||
margin-top : 15px;
|
||||
font-size : 0.8em;
|
||||
list-style-position : inside;
|
||||
list-style-type : disc;
|
||||
li { line-height : 1.6em; }
|
||||
}
|
||||
}
|
||||
hr {
|
||||
height : 2px;
|
||||
margin-top : 25px;
|
||||
margin-bottom : 15px;
|
||||
background-color : darken(@red, 8%);
|
||||
border : none;
|
||||
}
|
||||
small {
|
||||
font-size : 0.6em;
|
||||
opacity : 0.7;
|
||||
}
|
||||
.protips {
|
||||
font-size : 0.6em;
|
||||
line-height : 1.2em;
|
||||
h4 {
|
||||
font-weight : 800;
|
||||
line-height : 1.5em;
|
||||
text-transform : uppercase;
|
||||
}
|
||||
}
|
||||
button.dismiss {
|
||||
position : absolute;
|
||||
top : 20px;
|
||||
right : 30px;
|
||||
padding : unset;
|
||||
font-size : 40px;
|
||||
background-color : transparent;
|
||||
opacity : 0.6;
|
||||
&:hover { opacity : 1; }
|
||||
}
|
||||
}
|
||||
113
client/homebrew/brewRenderer/headerNav/headerNav.jsx
Normal file
113
client/homebrew/brewRenderer/headerNav/headerNav.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import './headerNav.less';
|
||||
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
const MAX_TEXT_LENGTH = 40;
|
||||
|
||||
const HeaderNav = React.forwardRef(({}, pagesRef)=>{
|
||||
|
||||
const renderHeaderLinks = ()=>{
|
||||
if(!pagesRef.current) return;
|
||||
|
||||
// Top Level Pages
|
||||
// Pages that contain an element with a specified class (e.g. cover pages, table of contents)
|
||||
// will NOT have its content scanned for navigation headers, instead displaying a custom label
|
||||
// ---
|
||||
// The property name is class that will be used for detecting the page is a top level page
|
||||
// The property value is a function that returns the text to be used
|
||||
|
||||
const topLevelPages = {
|
||||
'.frontCover' : (el, pageType)=>{ const text = getHeaderContent(el); return text ? `Cover: ${text}` : 'Cover Page'; },
|
||||
'.insideCover' : (el, pageType)=>{ const text = getHeaderContent(el); return text ? `Interior: ${text}` : 'Interior Cover Page'; },
|
||||
'.partCover' : (el, pageType)=>{ const text = getHeaderContent(el); return text ? `Section: ${text}` : 'Section Cover Page'; },
|
||||
'.backCover' : (el, pageType)=>{ const text = getHeaderContent(el); return text ? `Back: ${text}` : 'Rear Cover Page'; },
|
||||
'.toc' : ()=>{ return 'Table of Contents'; },
|
||||
};
|
||||
|
||||
const getHeaderContent = (el)=>el.querySelector('h1')?.textContent;
|
||||
|
||||
const topLevelPageSelector = Object.keys(topLevelPages).join(',');
|
||||
|
||||
const selector = [
|
||||
'.pages > .page', // All page elements, which by definition have IDs
|
||||
`.page:not(:has(${topLevelPageSelector})) > [id]`, // All direct children of non-excluded .pages with an ID (Legacy)
|
||||
`.page:not(:has(${topLevelPageSelector})) > .columnWrapper > [id]`, // All direct children of non-excluded .page > .columnWrapper with an ID (V3)
|
||||
`.page:not(:has(${topLevelPageSelector})) h2`, // All non-excluded H2 titles, like Monster frame titles
|
||||
];
|
||||
const elements = pagesRef.current.querySelectorAll(selector.join(','));
|
||||
if(!elements) return;
|
||||
const navList = [];
|
||||
|
||||
// navList is a list of objects which have the following structure:
|
||||
// {
|
||||
// depth : how deeply indented the item should be
|
||||
// text : the text to display in the nav link
|
||||
// link : the hyperlink to navigate to when clicked
|
||||
// className : [optional] the class to apply to the nav link for styling
|
||||
// }
|
||||
|
||||
elements.forEach((el)=>{
|
||||
const navEntry = { // Default structure of a navList entry
|
||||
depth : 7, // All unmatched elements with IDs are set to the maximum depth (7)
|
||||
text : el.textContent, // Use `textContent` because `innerText` is affected by rendering, e.g. 'content-visibility: auto'
|
||||
link : el.id
|
||||
};
|
||||
if(el.classList.contains('page')) {
|
||||
let text = `Page ${el.id.slice(1)}`; // Get the page # by trimming off the 'p' from the ID
|
||||
const pageType = Object.keys(topLevelPages).find((pageType)=>el.querySelector(pageType));
|
||||
if(pageType)
|
||||
text += ` - ${topLevelPages[pageType](el, pageType)}`; // If a Top Level Page, add extra label
|
||||
|
||||
navEntry.depth = 0; // Pages are always at the least indented level
|
||||
navEntry.text = text;
|
||||
navEntry.className = 'pageLink';
|
||||
} else if(el.localName.match(/^h[1-6]/)){ // Header elements H1 through H6
|
||||
navEntry.depth = el.localName[1]; // Depth is set by the header level
|
||||
}
|
||||
navList.push(navEntry);
|
||||
});
|
||||
|
||||
return _.map(navList, (navItem, index)=><HeaderNavItem {...navItem} key={index} />
|
||||
);
|
||||
};
|
||||
|
||||
return <nav className='headerNav'>
|
||||
<ul>
|
||||
{renderHeaderLinks()}
|
||||
</ul>
|
||||
</nav>;
|
||||
});
|
||||
|
||||
const HeaderNavItem = ({ link, text, depth, className })=>{
|
||||
|
||||
const trimString = (text, prefixLength = 0)=>{
|
||||
// Sanity check nav link strings
|
||||
let output = text;
|
||||
|
||||
// If the string has a line break, only use the first line
|
||||
if(text.indexOf('\n')){
|
||||
output = text.split('\n')[0];
|
||||
}
|
||||
|
||||
// Trim unecessary spaces from string
|
||||
output = output.trim();
|
||||
|
||||
// Reduce excessively long strings
|
||||
const maxLength = MAX_TEXT_LENGTH - prefixLength;
|
||||
if(output.length > maxLength){
|
||||
return `${output.slice(0, maxLength).trim()}...`;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
if(!link || !text) return;
|
||||
|
||||
return <li>
|
||||
<a href={`#${link}`} target='_self' className={`depth-${depth} ${className ?? ''}`}>
|
||||
{trimString(text, depth)}
|
||||
</a>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export default HeaderNav;
|
||||
39
client/homebrew/brewRenderer/headerNav/headerNav.less
Normal file
39
client/homebrew/brewRenderer/headerNav/headerNav.less
Normal file
@@ -0,0 +1,39 @@
|
||||
.headerNav {
|
||||
position : fixed;
|
||||
top : 32px;
|
||||
left : 0px;
|
||||
max-width : 40vw;
|
||||
max-height : calc(100vh - 32px);
|
||||
padding : 5px 10px;
|
||||
overflow-y : auto;
|
||||
background-color : #CCCCCC;
|
||||
border-radius : 5px;
|
||||
&.active {
|
||||
padding-bottom : 10px;
|
||||
.navIcon { padding-bottom : 10px; }
|
||||
}
|
||||
.navIcon { cursor : pointer; }
|
||||
li {
|
||||
list-style-type : none;
|
||||
a {
|
||||
display : inline-block;
|
||||
width : 100%;
|
||||
padding : 2px;
|
||||
font-family : 'Open Sans';
|
||||
font-size : 12px;
|
||||
color : inherit;
|
||||
text-decoration : none;
|
||||
cursor : pointer;
|
||||
&:hover { text-decoration : underline; }
|
||||
&.pageLink { font-weight : 900; }
|
||||
|
||||
@depths: 0,1,2,3,4,5,6,7;
|
||||
|
||||
each(@depths, {
|
||||
&.depth-@{value} {
|
||||
padding-left: ((@value) * 0.5em);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import './notificationPopup.less';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '../../../../shared/markdown.js';
|
||||
|
||||
import Dialog from '../../../components/dialog.jsx';
|
||||
|
||||
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
|
||||
|
||||
const NotificationPopup = ()=>{
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [dissmissKeyList, setDismissKeyList] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(()=>{
|
||||
getNotifications();
|
||||
}, []);
|
||||
|
||||
const getNotifications = async ()=>{
|
||||
setError(null);
|
||||
try {
|
||||
const res = await request.get('/admin/notification/all');
|
||||
pickActiveNotifications(res.body || []);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setError(`Error looking up notifications: ${err?.response?.body?.message || err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const pickActiveNotifications = (notifs)=>{
|
||||
const now = new Date();
|
||||
const filteredNotifications = notifs.filter((notification)=>{
|
||||
const startDate = new Date(notification.startAt);
|
||||
const stopDate = new Date(notification.stopAt);
|
||||
const dismissed = localStorage.getItem(notification.dismissKey) ? true : false;
|
||||
return now >= startDate && now <= stopDate && !dismissed;
|
||||
});
|
||||
setNotifications(filteredNotifications);
|
||||
setDismissKeyList(filteredNotifications.map((notif)=>notif.dismissKey));
|
||||
};
|
||||
|
||||
const renderNotificationsList = ()=>{
|
||||
if(error) return <div className='error'>{error}</div>;
|
||||
return notifications.map((notification)=>(
|
||||
<li key={notification.dismissKey} >
|
||||
<em>{notification.title}</em><br />
|
||||
<p dangerouslySetInnerHTML={{ __html: Markdown.render(notification.text) }}></p>
|
||||
</li>
|
||||
));
|
||||
};
|
||||
|
||||
if(!notifications.length) return;
|
||||
return <Dialog className='notificationPopup' dismisskeys={dissmissKeyList} closeText={DISMISS_BUTTON} >
|
||||
<div className='header'>
|
||||
<i className='fas fa-info-circle info'></i>
|
||||
<h3>Notice</h3>
|
||||
<small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small>
|
||||
</div>
|
||||
<ul>
|
||||
{renderNotificationsList()}
|
||||
</ul>
|
||||
</Dialog>;
|
||||
};
|
||||
|
||||
export default NotificationPopup;
|
||||
@@ -0,0 +1,95 @@
|
||||
@import './client/homebrew/navbar/navbar.less';
|
||||
|
||||
.popups {
|
||||
position : fixed;
|
||||
top : calc(@navbarHeight + @viewerToolsHeight);
|
||||
right : 24px;
|
||||
z-index : 10001;
|
||||
width : 450px;
|
||||
margin-top : 5px;
|
||||
}
|
||||
|
||||
.notificationPopup {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
padding : 15px;
|
||||
padding-bottom : 10px;
|
||||
padding-left : 25px;
|
||||
color : white;
|
||||
background-color : @blue;
|
||||
border : none;
|
||||
&[open] { display : inline-block; }
|
||||
a {
|
||||
font-weight : 800;
|
||||
color : #E0E5C1;
|
||||
}
|
||||
i.info {
|
||||
position : absolute;
|
||||
top : 12px;
|
||||
left : 12px;
|
||||
font-size : 2.5em;
|
||||
opacity : 0.8;
|
||||
}
|
||||
button.dismiss {
|
||||
position : absolute;
|
||||
top : 10px;
|
||||
right : 10px;
|
||||
cursor : pointer;
|
||||
background-color : transparent;
|
||||
opacity : 0.6;
|
||||
&:hover { opacity : 1; }
|
||||
}
|
||||
.header { padding-left : 50px; }
|
||||
small {
|
||||
font-size : 0.6em;
|
||||
opacity : 0.7;
|
||||
}
|
||||
h3 {
|
||||
font-size : 1.1em;
|
||||
font-weight : 800;
|
||||
}
|
||||
ul {
|
||||
margin-top : 15px;
|
||||
font-size : 0.9em;
|
||||
list-style-position : outside;
|
||||
list-style-type : disc;
|
||||
li {
|
||||
padding-left : 1em;
|
||||
margin-top : 1.5em;
|
||||
font-size : 0.9em;
|
||||
line-height : 1.5em;
|
||||
em {
|
||||
font-weight : 800;
|
||||
text-transform : capitalize;
|
||||
}
|
||||
li {
|
||||
margin-top : 0;
|
||||
line-height : 1.2em;
|
||||
list-style-type : square;
|
||||
}
|
||||
}
|
||||
ul ul,ol ol,ul ol,ol ul {
|
||||
margin-bottom : 0px;
|
||||
margin-left : 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown styling */
|
||||
code {
|
||||
padding : 0.1em 0.5em;
|
||||
font-family : 'Courier New', 'Courier', monospace;
|
||||
overflow-wrap : break-word;
|
||||
white-space : pre-wrap;
|
||||
background : #08115A;
|
||||
border-radius : 2px;
|
||||
}
|
||||
pre code {
|
||||
display : inline-block;
|
||||
width : 100%;
|
||||
}
|
||||
.blank {
|
||||
height : 1em;
|
||||
margin-top : 0;
|
||||
& + * { margin-top : 0; }
|
||||
}
|
||||
}
|
||||
46
client/homebrew/brewRenderer/safeHTML.js
Normal file
46
client/homebrew/brewRenderer/safeHTML.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// Derived from the vue-html-secure package, customized for Homebrewery
|
||||
|
||||
let doc = null;
|
||||
let div = null;
|
||||
|
||||
function safeHTML(htmlString) {
|
||||
// If the Document interface doesn't exist, exit
|
||||
if(typeof document == 'undefined') return null;
|
||||
// If the test document and div don't exist, create them
|
||||
if(!doc) doc = document.implementation.createHTMLDocument('');
|
||||
if(!div) div = doc.createElement('div');
|
||||
|
||||
// Set the test div contents to the evaluation string
|
||||
div.innerHTML = htmlString;
|
||||
// Grab all nodes from the test div
|
||||
const elements = div.querySelectorAll('*');
|
||||
|
||||
// Blacklisted tags
|
||||
const blacklistTags = ['script', 'noscript', 'noembed'];
|
||||
// Tests to remove attributes
|
||||
const blacklistAttrs = [
|
||||
(test)=>{return test.localName.indexOf('on') == 0;},
|
||||
(test)=>{return test.localName.indexOf('type') == 0 && test.value.match(/submit/i);},
|
||||
(test)=>{return test.value.replace(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g, '').toLowerCase().trim().indexOf('javascript:') == 0;}
|
||||
];
|
||||
|
||||
|
||||
elements.forEach((element)=>{
|
||||
// Check each element for blacklisted type
|
||||
if(blacklistTags.includes(element?.localName?.toLowerCase())) {
|
||||
element.remove();
|
||||
return;
|
||||
}
|
||||
// Check remaining elements for blacklisted attributes
|
||||
for (const attribute of element.attributes){
|
||||
if(blacklistAttrs.some((test)=>{return test(attribute);})) {
|
||||
element.removeAttribute(attribute.localName);
|
||||
break;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
export default safeHTML;
|
||||
261
client/homebrew/brewRenderer/toolBar/toolBar.jsx
Normal file
261
client/homebrew/brewRenderer/toolBar/toolBar.jsx
Normal file
@@ -0,0 +1,261 @@
|
||||
/* eslint-disable max-lines */
|
||||
import './toolBar.less';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
|
||||
|
||||
const MAX_ZOOM = 300;
|
||||
const MIN_ZOOM = 10;
|
||||
|
||||
const TOOLBAR_VISIBILITY = 'HB_renderer_toolbarVisibility';
|
||||
|
||||
const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages, headerState, setHeaderState })=>{
|
||||
|
||||
const [pageNum, setPageNum] = useState(1);
|
||||
const [toolsVisible, setToolsVisible] = useState(true);
|
||||
|
||||
useEffect(()=>{
|
||||
// format multiple visible pages as a range (e.g. "150-153")
|
||||
const pageRange = visiblePages.length === 1 ? `${visiblePages[0]}` : `${visiblePages[0]} - ${visiblePages.at(-1)}`;
|
||||
setPageNum(pageRange);
|
||||
}, [visiblePages]);
|
||||
|
||||
useEffect(()=>{
|
||||
const Visibility = localStorage.getItem(TOOLBAR_VISIBILITY);
|
||||
if(Visibility) setToolsVisible(Visibility === 'true');
|
||||
|
||||
}, []);
|
||||
|
||||
const handleZoomButton = (zoom)=>{
|
||||
handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
|
||||
};
|
||||
|
||||
const handleOptionChange = (optionKey, newValue)=>{
|
||||
onDisplayOptionsChange({ ...displayOptions, [optionKey]: newValue });
|
||||
};
|
||||
|
||||
const handlePageInput = (pageInput)=>{
|
||||
if(/[0-9]/.test(pageInput))
|
||||
setPageNum(parseInt(pageInput)); // input type is 'text', so `page` comes in as a string, not number.
|
||||
};
|
||||
|
||||
// scroll to a page, used in the Prev/Next Page buttons.
|
||||
const scrollToPage = (pageNumber)=>{
|
||||
if(typeof pageNumber !== 'number') return;
|
||||
pageNumber = _.clamp(pageNumber, 1, totalPages);
|
||||
const iframe = document.getElementById('BrewRenderer');
|
||||
const brewRenderer = iframe?.contentWindow?.document.querySelector('.brewRenderer');
|
||||
const page = brewRenderer?.querySelector(`#p${pageNumber}`);
|
||||
page?.scrollIntoView({ block: 'start' });
|
||||
};
|
||||
|
||||
const calculateChange = (mode)=>{
|
||||
const iframe = document.getElementById('BrewRenderer');
|
||||
const iframeWidth = iframe.getBoundingClientRect().width;
|
||||
const iframeHeight = iframe.getBoundingClientRect().height;
|
||||
const pages = iframe.contentWindow.document.getElementsByClassName('page');
|
||||
|
||||
let desiredZoom = 0;
|
||||
|
||||
if(mode == 'fill'){
|
||||
// find widest page, in case pages are different widths, so that the zoom is adapted to not cut the widest page off screen.
|
||||
const widestPage = _.maxBy([...pages], 'offsetWidth').offsetWidth;
|
||||
|
||||
if(displayOptions.spread === 'facing')
|
||||
desiredZoom = (iframeWidth / ((widestPage * 2) + parseInt(displayOptions.columnGap))) * 100;
|
||||
else
|
||||
desiredZoom = (iframeWidth / (widestPage + 20)) * 100;
|
||||
|
||||
} else if(mode == 'fit'){
|
||||
// find the page with the largest single dim (height or width) so that zoom can be adapted to fit it.
|
||||
let minDimRatio;
|
||||
if(displayOptions.spread === 'single')
|
||||
minDimRatio = [...pages].reduce(
|
||||
(minRatio, page)=>Math.min(minRatio,
|
||||
iframeWidth / page.offsetWidth,
|
||||
iframeHeight / page.offsetHeight
|
||||
),
|
||||
Infinity
|
||||
);
|
||||
else
|
||||
minDimRatio = [...pages].reduce(
|
||||
(minRatio, page)=>Math.min(minRatio,
|
||||
iframeWidth / ((page.offsetWidth * 2) + parseInt(displayOptions.columnGap)),
|
||||
iframeHeight / page.offsetHeight
|
||||
),
|
||||
Infinity
|
||||
);
|
||||
|
||||
desiredZoom = minDimRatio * 100;
|
||||
}
|
||||
|
||||
const margin = 5; // extra space so page isn't edge to edge (not truly "to fill")
|
||||
|
||||
const deltaZoom = (desiredZoom - displayOptions.zoomLevel) - margin;
|
||||
return deltaZoom;
|
||||
};
|
||||
|
||||
return (
|
||||
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
|
||||
<div className='toggleButton'>
|
||||
<button title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{
|
||||
setToolsVisible(!toolsVisible);
|
||||
localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible);
|
||||
}}><i className='fas fa-glasses' /></button>
|
||||
<button title={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button>
|
||||
</div>
|
||||
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
|
||||
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
|
||||
<button
|
||||
id='fill-width'
|
||||
className='tool'
|
||||
title='Set zoom to fill preview with one page'
|
||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
|
||||
>
|
||||
<i className='fac fit-width' />
|
||||
</button>
|
||||
<button
|
||||
id='zoom-to-fit'
|
||||
className='tool'
|
||||
title='Set zoom to fit entire page in preview'
|
||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
|
||||
>
|
||||
<i className='fac zoom-to-fit' />
|
||||
</button>
|
||||
<button
|
||||
id='zoom-out'
|
||||
className='tool'
|
||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
|
||||
disabled={displayOptions.zoomLevel <= MIN_ZOOM}
|
||||
title='Zoom Out'
|
||||
>
|
||||
<i className='fas fa-magnifying-glass-minus' />
|
||||
</button>
|
||||
<input
|
||||
id='zoom-slider'
|
||||
className='range-input tool hover-tooltip'
|
||||
type='range'
|
||||
name='zoom'
|
||||
title='Set Zoom'
|
||||
list='zoomLevels'
|
||||
min={MIN_ZOOM}
|
||||
max={MAX_ZOOM}
|
||||
step='1'
|
||||
value={displayOptions.zoomLevel}
|
||||
onChange={(e)=>handleZoomButton(parseInt(e.target.value))}
|
||||
/>
|
||||
<datalist id='zoomLevels'>
|
||||
<option value='100' />
|
||||
</datalist>
|
||||
|
||||
<button
|
||||
id='zoom-in'
|
||||
className='tool'
|
||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
|
||||
disabled={displayOptions.zoomLevel >= MAX_ZOOM}
|
||||
title='Zoom In'
|
||||
>
|
||||
<i className='fas fa-magnifying-glass-plus' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/*v=====----------------------< Spread Controls >---------------------=====v*/}
|
||||
<div className='group' role='group' aria-label='Spread' aria-hidden={!toolsVisible}>
|
||||
<div className='radio-group' role='radiogroup'>
|
||||
<button role='radio'
|
||||
id='single-spread'
|
||||
className='tool'
|
||||
title='Single Page'
|
||||
onClick={()=>{handleOptionChange('spread', 'single');}}
|
||||
aria-checked={displayOptions.spread === 'single'}
|
||||
><i className='fac single-spread' /></button>
|
||||
<button role='radio'
|
||||
id='facing-spread'
|
||||
className='tool'
|
||||
title='Facing Pages'
|
||||
onClick={()=>{handleOptionChange('spread', 'facing');}}
|
||||
aria-checked={displayOptions.spread === 'facing'}
|
||||
><i className='fac facing-spread' /></button>
|
||||
<button role='radio'
|
||||
id='flow-spread'
|
||||
className='tool'
|
||||
title='Flow Pages'
|
||||
onClick={()=>{handleOptionChange('spread', 'flow');}}
|
||||
aria-checked={displayOptions.spread === 'flow'}
|
||||
><i className='fac flow-spread' /></button>
|
||||
|
||||
</div>
|
||||
<Anchored>
|
||||
<AnchoredTrigger id='spread-settings' className='tool' title='Spread options'><i className='fas fa-gear' /></AnchoredTrigger>
|
||||
<AnchoredBox title='Options'>
|
||||
<h1>Options</h1>
|
||||
<label title='Modify the horizontal space between pages.'>
|
||||
Column gap
|
||||
<input type='range' min={0} max={200} defaultValue={displayOptions.columnGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('columnGap', evt.target.value)} />
|
||||
</label>
|
||||
<label title='Modify the vertical space between rows of pages.'>
|
||||
Row gap
|
||||
<input type='range' min={0} max={200} defaultValue={displayOptions.rowGap || 10} className='range-input' onChange={(evt)=>handleOptionChange('rowGap', evt.target.value)} />
|
||||
</label>
|
||||
<label title='Start 1st page on the right side, such as if you have cover page.'>
|
||||
Start on right
|
||||
<input type='checkbox' checked={displayOptions.startOnRight} onChange={()=>{handleOptionChange('startOnRight', !displayOptions.startOnRight);}}
|
||||
title={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
|
||||
</label>
|
||||
<label title='Toggle the page shadow on every page.'>
|
||||
Page shadows
|
||||
<input type='checkbox' checked={displayOptions.pageShadows} onChange={()=>{handleOptionChange('pageShadows', !displayOptions.pageShadows);}} />
|
||||
</label>
|
||||
</AnchoredBox>
|
||||
</Anchored>
|
||||
</div>
|
||||
|
||||
{/*v=====----------------------< Page Controls >---------------------=====v*/}
|
||||
<div className='group' role='group' aria-label='Pages' aria-hidden={!toolsVisible}>
|
||||
<button
|
||||
id='previous-page'
|
||||
className='previousPage tool'
|
||||
type='button'
|
||||
title='Previous Page(s)'
|
||||
onClick={()=>scrollToPage(_.min(visiblePages) - visiblePages.length)}
|
||||
disabled={visiblePages.includes(1)}
|
||||
>
|
||||
<i className='fas fa-arrow-left'></i>
|
||||
</button>
|
||||
|
||||
<div className='tool'>
|
||||
<input
|
||||
id='page-input'
|
||||
className='text-input'
|
||||
type='text'
|
||||
name='page'
|
||||
title='Current page(s) in view'
|
||||
inputMode='numeric'
|
||||
pattern='[0-9]'
|
||||
value={pageNum}
|
||||
onClick={(e)=>e.target.select()}
|
||||
onChange={(e)=>handlePageInput(e.target.value)}
|
||||
onBlur={()=>scrollToPage(pageNum)}
|
||||
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
|
||||
style={{ width: `${pageNum.length}ch` }}
|
||||
/>
|
||||
<span id='page-count' title='Total Page Count'>/ {totalPages}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id='next-page'
|
||||
className='tool'
|
||||
type='button'
|
||||
title='Next Page(s)'
|
||||
onClick={()=>scrollToPage(_.max(visiblePages) + 1)}
|
||||
disabled={visiblePages.includes(totalPages)}
|
||||
>
|
||||
<i className='fas fa-arrow-right'></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolBar;
|
||||
191
client/homebrew/brewRenderer/toolBar/toolBar.less
Normal file
191
client/homebrew/brewRenderer/toolBar/toolBar.less
Normal file
@@ -0,0 +1,191 @@
|
||||
@import (less) './client/icons/customIcons.less';
|
||||
|
||||
.toolBar {
|
||||
position : absolute;
|
||||
z-index : 1;
|
||||
box-sizing : border-box;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
gap : 8px 20px;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
width : 100%;
|
||||
height : auto;
|
||||
padding : 2px 10px 2px 90px;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 13px;
|
||||
color : #CCCCCC;
|
||||
background-color : #555555;
|
||||
& > *:not(.toggleButton) {
|
||||
opacity : 1;
|
||||
transition : all 0.2s ease;
|
||||
}
|
||||
|
||||
.group {
|
||||
box-sizing : border-box;
|
||||
display : flex;
|
||||
gap : 0 3px;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
height : 28px;
|
||||
}
|
||||
|
||||
.tool {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
}
|
||||
|
||||
.active, [aria-checked='true'] { background-color : #444444; }
|
||||
|
||||
.anchored-trigger {
|
||||
&.active { background-color : #444444; }
|
||||
}
|
||||
|
||||
.anchored-box {
|
||||
--box-color : #555555;
|
||||
top : 30px;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
gap : 5px;
|
||||
padding : 15px;
|
||||
margin-top : 10px;
|
||||
font-size : 0.8em;
|
||||
color : #CCCCCC;
|
||||
background-color : var(--box-color);
|
||||
border-radius : 5px;
|
||||
|
||||
h1 {
|
||||
padding-bottom : 0.3em;
|
||||
margin-bottom : 0.5em;
|
||||
border-bottom : 1px solid currentColor;
|
||||
}
|
||||
|
||||
h2 {
|
||||
padding-bottom : 0.3em;
|
||||
margin : 1em 0 0.5em 0;
|
||||
color : lightgray;
|
||||
border-bottom : 1px solid currentColor;
|
||||
}
|
||||
|
||||
label {
|
||||
display : flex;
|
||||
gap : 6px;
|
||||
align-items : center;
|
||||
justify-content : space-between;
|
||||
|
||||
}
|
||||
input {
|
||||
height : unset;
|
||||
&[type='range'] { padding : 0; }
|
||||
}
|
||||
&::before {
|
||||
position : absolute;
|
||||
top : -20px;
|
||||
left : 50%;
|
||||
width : 0px;
|
||||
height : 0px;
|
||||
pointer-events : none;
|
||||
content : '';
|
||||
border : 10px solid transparent;
|
||||
border-bottom : 10px solid var(--box-color);
|
||||
transform : translateX(-50%);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.radio-group:has(button[role='radio']) {
|
||||
display : flex;
|
||||
height : 100%;
|
||||
border : 1px solid #333333;
|
||||
}
|
||||
|
||||
input {
|
||||
position : relative;
|
||||
height : 1.5em;
|
||||
padding : 2px 5px;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
color : inherit;
|
||||
background : #3B3B3B;
|
||||
border : none;
|
||||
&:focus { outline : 1px solid #D3D3D3; }
|
||||
|
||||
// `.range-input` if generic to all range inputs, or `#zoom-slider` if only for zoom slider
|
||||
&.range-input {
|
||||
padding : 2px 0;
|
||||
color : #D3D3D3;
|
||||
accent-color : #D3D3D3;
|
||||
|
||||
&::-webkit-slider-thumb, &::-moz-range-thumb {
|
||||
width : 5px;
|
||||
height : 5px;
|
||||
cursor : ew-resize;
|
||||
outline : none;
|
||||
}
|
||||
|
||||
&.hover-tooltip[value]:hover::after {
|
||||
position : absolute;
|
||||
bottom : -30px;
|
||||
left : 50%;
|
||||
z-index : 1;
|
||||
display : grid;
|
||||
place-items : center;
|
||||
width : 4ch;
|
||||
height : 1.2lh;
|
||||
pointer-events : none;
|
||||
content : attr(value);
|
||||
background-color : #555555;
|
||||
border : 1px solid #A1A1A1;
|
||||
transform : translate(-50%, 50%);
|
||||
}
|
||||
}
|
||||
|
||||
// `.text-input` if generic to all range inputs, or `#page-input` if only for current page input
|
||||
&#page-input {
|
||||
min-width : 5ch;
|
||||
margin-right : 1ch;
|
||||
text-align : center;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
box-sizing : border-box;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
width : auto;
|
||||
min-width : 40px;
|
||||
height : 100%;
|
||||
&:hover { background-color : #444444; }
|
||||
&:focus {outline : none; border : 1px solid #D3D3D3;}
|
||||
&:disabled {
|
||||
color : #777777;
|
||||
background-color : unset !important;
|
||||
}
|
||||
i { font-size : 1.2em; }
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
flex-wrap : nowrap;
|
||||
width : 92px;
|
||||
overflow : hidden;
|
||||
background-color : unset;
|
||||
opacity : 0.7;
|
||||
transition : all 0.3s ease;
|
||||
& > *:not(.toggleButton) {
|
||||
opacity : 0;
|
||||
transition : all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggleButton button i {
|
||||
filter: drop-shadow(0 0 2px black) drop-shadow(0 0 1px black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
position : absolute;
|
||||
left : 0;
|
||||
z-index : 5;
|
||||
display : flex;
|
||||
height : 100%;
|
||||
}
|
||||
547
client/homebrew/editor/editor.jsx
Normal file
547
client/homebrew/editor/editor.jsx
Normal file
@@ -0,0 +1,547 @@
|
||||
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
|
||||
import './editor.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import dedent from 'dedent';
|
||||
import Markdown from '../../../shared/markdown.js';
|
||||
|
||||
import CodeEditor from '../../components/codeEditor/codeEditor.jsx';
|
||||
import SnippetBar from './snippetbar/snippetbar.jsx';
|
||||
import MetadataEditor from './metadataEditor/metadataEditor.jsx';
|
||||
|
||||
const EDITOR_THEME_KEY = 'HB_editor_theme';
|
||||
|
||||
const PAGEBREAK_REGEX_V3 = /^(?=\\page(?:break)?(?: *{[^\n{}]*})?$)/m;
|
||||
const SNIPPETBREAK_REGEX_V3 = /^\\snippet\ .*$/;
|
||||
const DEFAULT_STYLE_TEXT = dedent`
|
||||
/*=======--- Example CSS styling ---=======*/
|
||||
/* Any CSS here will apply to your document! */
|
||||
|
||||
.myExampleClass {
|
||||
color: black;
|
||||
}`;
|
||||
|
||||
const DEFAULT_SNIPPET_TEXT = dedent`
|
||||
\snippet example snippet
|
||||
|
||||
The text between \`\snippet title\` lines will become a snippet of name \`title\` as this example provides.
|
||||
|
||||
This snippet is accessible in the brew tab, and will be inherited if the brew is used as a theme.
|
||||
`;
|
||||
let isJumping = false;
|
||||
|
||||
const Editor = createReactClass({
|
||||
displayName : 'Editor',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brew : {
|
||||
text : '',
|
||||
style : ''
|
||||
},
|
||||
|
||||
onBrewChange : ()=>{},
|
||||
reportError : ()=>{},
|
||||
|
||||
onCursorPageChange : ()=>{},
|
||||
onViewPageChange : ()=>{},
|
||||
|
||||
editorTheme : 'default',
|
||||
renderer : 'legacy',
|
||||
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentEditorViewPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
};
|
||||
},
|
||||
getInitialState : function() {
|
||||
return {
|
||||
editorTheme : this.props.editorTheme,
|
||||
view : 'text', //'text', 'style', 'meta', 'snippet'
|
||||
snippetBarHeight : 26,
|
||||
};
|
||||
},
|
||||
|
||||
editor : React.createRef(null),
|
||||
codeEditor : React.createRef(null),
|
||||
|
||||
isText : function() {return this.state.view == 'text';},
|
||||
isStyle : function() {return this.state.view == 'style';},
|
||||
isMeta : function() {return this.state.view == 'meta';},
|
||||
isSnip : function() {return this.state.view == 'snippet';},
|
||||
|
||||
componentDidMount : function() {
|
||||
|
||||
this.highlightCustomMarkdown();
|
||||
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys);
|
||||
document.addEventListener('keydown', this.handleControlKeys);
|
||||
|
||||
this.codeEditor.current.codeMirror?.on('cursorActivity', (cm)=>{this.updateCurrentCursorPage(cm.getCursor());});
|
||||
this.codeEditor.current.codeMirror?.on('scroll', _.throttle(()=>{this.updateCurrentViewPage(this.codeEditor.current.getTopVisibleLine());}, 200));
|
||||
|
||||
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
|
||||
if(editorTheme) {
|
||||
this.setState({
|
||||
editorTheme : editorTheme
|
||||
});
|
||||
}
|
||||
const snippetBar = document.querySelector('.editor > .snippetBar');
|
||||
if (!snippetBar) return;
|
||||
|
||||
this.resizeObserver = new ResizeObserver(entries => {
|
||||
const height = document.querySelector('.editor > .snippetBar').offsetHeight;
|
||||
this.setState({ snippetBarHeight: height });
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(snippetBar);
|
||||
},
|
||||
|
||||
componentDidUpdate : function(prevProps, prevState, snapshot) {
|
||||
|
||||
this.highlightCustomMarkdown();
|
||||
if(prevProps.moveBrew !== this.props.moveBrew)
|
||||
this.brewJump();
|
||||
|
||||
if(prevProps.moveSource !== this.props.moveSource)
|
||||
this.sourceJump();
|
||||
|
||||
if(this.props.liveScroll) {
|
||||
if(prevProps.currentBrewRendererPageNum !== this.props.currentBrewRendererPageNum) {
|
||||
this.sourceJump(this.props.currentBrewRendererPageNum, false);
|
||||
} else if(prevProps.currentEditorViewPageNum !== this.props.currentEditorViewPageNum) {
|
||||
this.brewJump(this.props.currentEditorViewPageNum, false);
|
||||
} else if(prevProps.currentEditorCursorPageNum !== this.props.currentEditorCursorPageNum) {
|
||||
this.brewJump(this.props.currentEditorCursorPageNum, false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
},
|
||||
|
||||
handleControlKeys : function(e){
|
||||
if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return;
|
||||
const LEFTARROW_KEY = 37;
|
||||
const RIGHTARROW_KEY = 39;
|
||||
if(e.keyCode == RIGHTARROW_KEY) this.brewJump();
|
||||
if(e.keyCode == LEFTARROW_KEY) this.sourceJump();
|
||||
if(e.keyCode == LEFTARROW_KEY || e.keyCode == RIGHTARROW_KEY) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
updateCurrentCursorPage : function(cursor) {
|
||||
const lines = this.props.brew.text.split('\n').slice(1, cursor.line + 1);
|
||||
const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
|
||||
const currentPage = lines.reduce((count, line)=>count + (pageRegex.test(line) ? 1 : 0), 1);
|
||||
this.props.onCursorPageChange(currentPage);
|
||||
},
|
||||
|
||||
updateCurrentViewPage : function(topScrollLine) {
|
||||
const lines = this.props.brew.text.split('\n').slice(1, topScrollLine + 1);
|
||||
const pageRegex = this.props.brew.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
|
||||
const currentPage = lines.reduce((count, line)=>count + (pageRegex.test(line) ? 1 : 0), 1);
|
||||
this.props.onViewPageChange(currentPage);
|
||||
},
|
||||
|
||||
handleInject : function(injectText){
|
||||
this.codeEditor.current?.injectText(injectText, false);
|
||||
},
|
||||
|
||||
handleViewChange : function(newView){
|
||||
this.props.setMoveArrows(newView === 'text');
|
||||
|
||||
this.setState({
|
||||
view : newView
|
||||
}, ()=>{
|
||||
this.codeEditor.current?.codeMirror?.focus();
|
||||
});
|
||||
},
|
||||
|
||||
highlightCustomMarkdown : function(){
|
||||
if(!this.codeEditor.current?.codeMirror) return;
|
||||
if((this.state.view === 'text') ||(this.state.view === 'snippet')) {
|
||||
const codeMirror = this.codeEditor.current.codeMirror;
|
||||
|
||||
codeMirror?.operation(()=>{ // Batch CodeMirror styling
|
||||
|
||||
const foldLines = [];
|
||||
|
||||
//reset custom text styles
|
||||
const customHighlights = codeMirror?.getAllMarks().filter((mark)=>{
|
||||
// Record details of folded sections
|
||||
if(mark.__isFold) {
|
||||
const fold = mark.find();
|
||||
foldLines.push({ from: fold.from?.line, to: fold.to?.line });
|
||||
}
|
||||
return !mark.__isFold;
|
||||
}); //Don't undo code folding
|
||||
|
||||
for (let i=customHighlights.length - 1;i>=0;i--) customHighlights[i].clear();
|
||||
|
||||
let userSnippetCount = 1; // start snippet count from snippet 1
|
||||
let editorPageCount = 1; // start page count from page 1
|
||||
|
||||
const whichSource = this.state.view === 'text' ? this.props.brew.text : this.props.brew.snippets;
|
||||
_.forEach(whichSource?.split('\n'), (line, lineNumber)=>{
|
||||
|
||||
const tabHighlight = this.state.view === 'text' ? 'pageLine' : 'snippetLine';
|
||||
const textOrSnip = this.state.view === 'text';
|
||||
|
||||
//reset custom line styles
|
||||
codeMirror?.removeLineClass(lineNumber, 'background', 'pageLine');
|
||||
codeMirror?.removeLineClass(lineNumber, 'background', 'snippetLine');
|
||||
codeMirror?.removeLineClass(lineNumber, 'text');
|
||||
codeMirror?.removeLineClass(lineNumber, 'wrap', 'sourceMoveFlash');
|
||||
|
||||
// Don't process lines inside folded text
|
||||
// If the current lineNumber is inside any folded marks, skip line styling
|
||||
if(foldLines.some((fold)=>lineNumber >= fold.from && lineNumber <= fold.to))
|
||||
return;
|
||||
|
||||
// Styling for \page breaks
|
||||
if((this.props.renderer == 'legacy' && line.includes('\\page')) ||
|
||||
(this.props.renderer == 'V3' && line.match(textOrSnip ? PAGEBREAK_REGEX_V3 : SNIPPETBREAK_REGEX_V3))) {
|
||||
|
||||
if((lineNumber > 0) && (textOrSnip)) // Since \page is optional on first line of document,
|
||||
editorPageCount += 1; // don't use it to increment page count; stay at 1
|
||||
else if(this.state.view !== 'text') userSnippetCount += 1;
|
||||
|
||||
// add back the original class 'background' but also add the new class '.pageline'
|
||||
codeMirror?.addLineClass(lineNumber, 'background', tabHighlight);
|
||||
const pageCountElement = Object.assign(document.createElement('span'), {
|
||||
className : 'editor-page-count',
|
||||
textContent : textOrSnip ? editorPageCount : userSnippetCount
|
||||
});
|
||||
codeMirror?.setBookmark({ line: lineNumber, ch: line.length }, pageCountElement);
|
||||
};
|
||||
|
||||
|
||||
// New CodeMirror styling for V3 renderer
|
||||
if(this.props.renderer === 'V3') {
|
||||
if(line.match(/^\\column(?:break)?$/)){
|
||||
codeMirror?.addLineClass(lineNumber, 'text', 'columnSplit');
|
||||
}
|
||||
|
||||
// definition lists
|
||||
if(line.includes('::')){
|
||||
if(/^:*$/.test(line) == true){ return; };
|
||||
const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error.
|
||||
let match;
|
||||
while ((match = regex.exec(line)) != null){
|
||||
codeMirror?.markText({ line: lineNumber, ch: match.indices[0][0] }, { line: lineNumber, ch: match.indices[0][1] }, { className: 'dl-highlight' });
|
||||
codeMirror?.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
|
||||
codeMirror?.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
|
||||
const ddIndex = match.indices[2][0];
|
||||
const colons = /::/g;
|
||||
const colonMatches = colons.exec(match[2]);
|
||||
if(colonMatches !== null){
|
||||
codeMirror?.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscript & Superscript
|
||||
if(line.includes('^')) {
|
||||
let startIndex = line.indexOf('^');
|
||||
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
|
||||
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
|
||||
|
||||
while (startIndex >= 0) {
|
||||
superRegex.lastIndex = subRegex.lastIndex = startIndex;
|
||||
let isSuper = false;
|
||||
const match = subRegex.exec(line) || superRegex.exec(line);
|
||||
if(match) {
|
||||
isSuper = !subRegex.lastIndex;
|
||||
codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
|
||||
}
|
||||
startIndex = line.indexOf('^', Math.max(startIndex + 1, subRegex.lastIndex, superRegex.lastIndex));
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight injectors {style}
|
||||
if(line.includes('{') && line.includes('}')){
|
||||
const regex = /(?:^|[^{\n])({(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\2})/gm;
|
||||
let match;
|
||||
while ((match = regex.exec(line)) != null) {
|
||||
codeMirror?.markText({ line: lineNumber, ch: line.indexOf(match[1]) }, { line: lineNumber, ch: line.indexOf(match[1]) + match[1].length }, { className: 'injection' });
|
||||
}
|
||||
}
|
||||
// Highlight inline spans {{content}}
|
||||
if(line.includes('{{') && line.includes('}}')){
|
||||
const regex = /{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *|}}/g;
|
||||
let match;
|
||||
let blockCount = 0;
|
||||
while ((match = regex.exec(line)) != null) {
|
||||
if(match[0].startsWith('{')) {
|
||||
blockCount += 1;
|
||||
} else {
|
||||
blockCount -= 1;
|
||||
}
|
||||
if(blockCount < 0) {
|
||||
blockCount = 0;
|
||||
continue;
|
||||
}
|
||||
codeMirror?.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: 'inline-block' });
|
||||
}
|
||||
} else if(line.trimLeft().startsWith('{{') || line.trimLeft().startsWith('}}')){
|
||||
// Highlight block divs {{\n Content \n}}
|
||||
let endCh = line.length+1;
|
||||
|
||||
const match = line.match(/^ *{{(?=((?:[:=](?:"[\w,\-()#%. ]*"|[\w\-()#%.]*)|[^"':={}\s]*)*))\1 *$|^ *}}$/);
|
||||
if(match)
|
||||
endCh = match.index+match[0].length;
|
||||
codeMirror?.markText({ line: lineNumber, ch: 0 }, { line: lineNumber, ch: endCh }, { className: 'block' });
|
||||
}
|
||||
|
||||
// Emojis
|
||||
if(line.match(/:[^\s:]+:/g)) {
|
||||
let startIndex = line.indexOf(':');
|
||||
const emojiRegex = /:[^\s:]+:/gy;
|
||||
|
||||
while (startIndex >= 0) {
|
||||
emojiRegex.lastIndex = startIndex;
|
||||
const match = emojiRegex.exec(line);
|
||||
if(match) {
|
||||
let tokens = Markdown.marked.lexer(match[0]);
|
||||
tokens = tokens[0].tokens.filter((t)=>t.type == 'emoji');
|
||||
if(!tokens.length)
|
||||
return;
|
||||
|
||||
const startPos = { line: lineNumber, ch: match.index };
|
||||
const endPos = { line: lineNumber, ch: match.index + match[0].length };
|
||||
|
||||
// Iterate over conflicting marks and clear them
|
||||
const marks = codeMirror?.findMarks(startPos, endPos);
|
||||
marks.forEach(function(marker) {
|
||||
if(!marker.__isFold) marker.clear();
|
||||
});
|
||||
codeMirror?.markText(startPos, endPos, { className: 'emoji' });
|
||||
}
|
||||
startIndex = line.indexOf(':', Math.max(startIndex + 1, emojiRegex.lastIndex));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
|
||||
if(!window || !this.isText() || isJumping)
|
||||
return;
|
||||
|
||||
// Get current brewRenderer scroll position and calculate target position
|
||||
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
|
||||
const currentPos = brewRenderer.scrollTop;
|
||||
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
|
||||
|
||||
let scrollingTimeout;
|
||||
const checkIfScrollComplete = ()=>{ // Prevent interrupting a scroll in progress if user clicks multiple times
|
||||
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
|
||||
scrollingTimeout = setTimeout(()=>{
|
||||
isJumping = false;
|
||||
brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
|
||||
}, 150); // If 150 ms pass without a brewRenderer scroll event, assume scrolling is done
|
||||
};
|
||||
|
||||
isJumping = true;
|
||||
checkIfScrollComplete();
|
||||
brewRenderer.addEventListener('scroll', checkIfScrollComplete);
|
||||
|
||||
if(smooth) {
|
||||
const bouncePos = targetPos >= 0 ? -30 : 30; //Do a little bounce before scrolling
|
||||
const bounceDelay = 100;
|
||||
const scrollDelay = 500;
|
||||
|
||||
if(!this.throttleBrewMove) {
|
||||
this.throttleBrewMove = _.throttle((currentPos, bouncePos, targetPos)=>{
|
||||
brewRenderer.scrollTo({ top: currentPos + bouncePos, behavior: 'smooth' });
|
||||
setTimeout(()=>{
|
||||
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
|
||||
}, bounceDelay);
|
||||
}, scrollDelay, { leading: true, trailing: false });
|
||||
};
|
||||
this.throttleBrewMove(currentPos, bouncePos, targetPos);
|
||||
} else {
|
||||
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'instant', block: 'start' });
|
||||
}
|
||||
},
|
||||
|
||||
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
|
||||
if(!this.isText() || isJumping)
|
||||
return;
|
||||
|
||||
const textSplit = this.props.renderer == 'V3' ? PAGEBREAK_REGEX_V3 : /\\page/;
|
||||
const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit);
|
||||
const targetLine = textString.match('\n') ? textString.split('\n').length - 1 : -1;
|
||||
|
||||
let currentY = this.codeEditor.current.codeMirror?.getScrollInfo().top;
|
||||
let targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true);
|
||||
|
||||
let scrollingTimeout;
|
||||
const checkIfScrollComplete = ()=>{ // Prevent interrupting a scroll in progress if user clicks multiple times
|
||||
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
|
||||
scrollingTimeout = setTimeout(()=>{
|
||||
isJumping = false;
|
||||
this.codeEditor.current.codeMirror?.off('scroll', checkIfScrollComplete);
|
||||
}, 150); // If 150 ms pass without a scroll event, assume scrolling is done
|
||||
};
|
||||
|
||||
isJumping = true;
|
||||
checkIfScrollComplete();
|
||||
if (this.codeEditor.current?.codeMirror) {
|
||||
this.codeEditor.current.codeMirror?.on('scroll', checkIfScrollComplete);
|
||||
}
|
||||
|
||||
if(smooth) {
|
||||
//Scroll 1/10 of the way every 10ms until 1px off.
|
||||
const incrementalScroll = setInterval(()=>{
|
||||
currentY += (targetY - currentY) / 10;
|
||||
this.codeEditor.current.codeMirror?.scrollTo(null, currentY);
|
||||
|
||||
// Update target: target height is not accurate until within +-10 lines of the visible window
|
||||
if(Math.abs(targetY - currentY > 100))
|
||||
targetY = this.codeEditor.current.codeMirror?.heightAtLine(targetLine, 'local', true);
|
||||
|
||||
// End when close enough
|
||||
if(Math.abs(targetY - currentY) < 1) {
|
||||
this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference
|
||||
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
|
||||
this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
|
||||
clearInterval(incrementalScroll);
|
||||
}
|
||||
}, 10);
|
||||
} else {
|
||||
this.codeEditor.current.codeMirror?.scrollTo(null, targetY); // Scroll any remaining difference
|
||||
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
|
||||
this.codeEditor.current.codeMirror?.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
|
||||
}
|
||||
},
|
||||
|
||||
//Called when there are changes to the editor's dimensions
|
||||
update : function(){},
|
||||
|
||||
updateEditorTheme : function(newTheme){
|
||||
window.localStorage.setItem(EDITOR_THEME_KEY, newTheme);
|
||||
this.setState({
|
||||
editorTheme : newTheme
|
||||
});
|
||||
},
|
||||
|
||||
//Called by CodeEditor after document switch, so Snippetbar can refresh UndoHistory
|
||||
rerenderParent : function (){
|
||||
this.forceUpdate();
|
||||
},
|
||||
|
||||
renderEditor : function(){
|
||||
if(this.isText()){
|
||||
return <>
|
||||
<CodeEditor key='codeEditor'
|
||||
ref={this.codeEditor}
|
||||
language='gfm'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.text}
|
||||
onChange={this.props.onBrewChange('text')}
|
||||
editorTheme={this.state.editorTheme}
|
||||
rerenderParent={this.rerenderParent}
|
||||
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} />
|
||||
</>;
|
||||
}
|
||||
if(this.isStyle()){
|
||||
return <>
|
||||
<CodeEditor key='codeEditor'
|
||||
ref={this.codeEditor}
|
||||
language='css'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.style ?? DEFAULT_STYLE_TEXT}
|
||||
onChange={this.props.onBrewChange('style')}
|
||||
enableFolding={true}
|
||||
editorTheme={this.state.editorTheme}
|
||||
rerenderParent={this.rerenderParent}
|
||||
style={{ height: `calc(100% - ${this.state.snippetBarHeight}px)` }} />
|
||||
</>;
|
||||
}
|
||||
if(this.isMeta()){
|
||||
return <>
|
||||
<CodeEditor key='codeEditor'
|
||||
view={this.state.view}
|
||||
style={{ display: 'none' }}
|
||||
rerenderParent={this.rerenderParent} />
|
||||
<MetadataEditor
|
||||
metadata={this.props.brew}
|
||||
themeBundle={this.props.themeBundle}
|
||||
onChange={this.props.onBrewChange('metadata')}
|
||||
reportError={this.props.reportError}
|
||||
userThemes={this.props.userThemes}/>
|
||||
</>;
|
||||
}
|
||||
if(this.isSnip()){
|
||||
if(!this.props.brew.snippets) { this.props.brew.snippets = DEFAULT_SNIPPET_TEXT; }
|
||||
return <>
|
||||
<CodeEditor key='codeEditor'
|
||||
ref={this.codeEditor}
|
||||
language='gfm'
|
||||
view={this.state.view}
|
||||
value={this.props.brew.snippets}
|
||||
onChange={this.props.onBrewChange('snippets')}
|
||||
enableFolding={true}
|
||||
editorTheme={this.state.editorTheme}
|
||||
rerenderParent={this.rerenderParent}
|
||||
style={{ height: `calc(100% -${this.state.snippetBarHeight}px)` }} />
|
||||
</>;
|
||||
}
|
||||
},
|
||||
|
||||
redo : function(){
|
||||
return this.codeEditor.current?.redo();
|
||||
},
|
||||
|
||||
historySize : function(){
|
||||
return this.codeEditor.current?.historySize();
|
||||
},
|
||||
|
||||
undo : function(){
|
||||
return this.codeEditor.current?.undo();
|
||||
},
|
||||
|
||||
foldCode : function(){
|
||||
return this.codeEditor.current?.foldAllCode();
|
||||
},
|
||||
|
||||
unfoldCode : function(){
|
||||
return this.codeEditor.current?.unfoldAllCode();
|
||||
},
|
||||
|
||||
render : function(){
|
||||
return (
|
||||
<div className='editor' ref={this.editor}>
|
||||
<SnippetBar
|
||||
brew={this.props.brew}
|
||||
view={this.state.view}
|
||||
onViewChange={this.handleViewChange}
|
||||
onInject={this.handleInject}
|
||||
showEditButtons={this.props.showEditButtons}
|
||||
renderer={this.props.renderer}
|
||||
theme={this.props.brew.theme}
|
||||
undo={this.undo}
|
||||
redo={this.redo}
|
||||
foldCode={this.foldCode}
|
||||
unfoldCode={this.unfoldCode}
|
||||
historySize={this.historySize()}
|
||||
currentEditorTheme={this.state.editorTheme}
|
||||
updateEditorTheme={this.updateEditorTheme}
|
||||
themeBundle={this.props.themeBundle}
|
||||
cursorPos={this.codeEditor.current?.getCursorPosition() || {}}
|
||||
updateBrew={this.props.updateBrew}
|
||||
/>
|
||||
|
||||
{this.renderEditor()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default Editor;
|
||||
113
client/homebrew/editor/editor.less
Normal file
113
client/homebrew/editor/editor.less
Normal file
@@ -0,0 +1,113 @@
|
||||
@import './shared/naturalcrit/styles/core.less';
|
||||
@import './themes/codeMirror/customEditorStyles.less';
|
||||
|
||||
.editor {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
container : editor / inline-size;
|
||||
.codeEditor {
|
||||
height : calc(100% - 25px);
|
||||
.CodeMirror { height : 100%; }
|
||||
.pageLine, .snippetLine {
|
||||
background : #33333328;
|
||||
border-top : #333399 solid 1px;
|
||||
}
|
||||
.editor-page-count {
|
||||
float : right;
|
||||
color : grey;
|
||||
}
|
||||
.editor-snippet-count {
|
||||
float : right;
|
||||
color : grey;
|
||||
}
|
||||
.columnSplit {
|
||||
font-style : italic;
|
||||
color : grey;
|
||||
background-color : fade(#229999, 15%);
|
||||
border-bottom : #229999 solid 1px;
|
||||
}
|
||||
.define {
|
||||
&:not(.term):not(.definition) {
|
||||
font-weight : bold;
|
||||
color : #949494;
|
||||
background : #E5E5E5;
|
||||
border-radius : 3px;
|
||||
}
|
||||
&.term { color : rgb(96, 117, 143); }
|
||||
&.definition { color : rgb(97, 57, 178); }
|
||||
}
|
||||
.block:not(.cm-comment) {
|
||||
font-weight : bold;
|
||||
color : purple;
|
||||
//font-style: italic;
|
||||
}
|
||||
.inline-block:not(.cm-comment) {
|
||||
font-weight : bold;
|
||||
color : red;
|
||||
//font-style: italic;
|
||||
}
|
||||
.injection:not(.cm-comment) {
|
||||
font-weight : bold;
|
||||
color : green;
|
||||
}
|
||||
.emoji:not(.cm-comment) {
|
||||
padding-bottom : 1px;
|
||||
margin-left : 2px;
|
||||
font-weight : bold;
|
||||
color : #360034;
|
||||
outline : solid 2px #FF96FC;
|
||||
outline-offset : -2px;
|
||||
background : #FFC8FF;
|
||||
border-radius : 6px;
|
||||
}
|
||||
.superscript:not(.cm-comment) {
|
||||
font-size : 0.9em;
|
||||
font-weight : bold;
|
||||
vertical-align : super;
|
||||
color : goldenrod;
|
||||
}
|
||||
.subscript:not(.cm-comment) {
|
||||
font-size : 0.9em;
|
||||
font-weight : bold;
|
||||
vertical-align : sub;
|
||||
color : rgb(123, 123, 15);
|
||||
}
|
||||
.dl-highlight {
|
||||
&.dl-colon-highlight {
|
||||
font-weight : bold;
|
||||
color : #949494;
|
||||
background : #E5E5E5;
|
||||
border-radius : 3px;
|
||||
}
|
||||
&.dt-highlight { color : rgb(96, 117, 143); }
|
||||
&.dd-highlight { color : rgb(97, 57, 178); }
|
||||
}
|
||||
}
|
||||
|
||||
.brewJump {
|
||||
position : absolute;
|
||||
right : 20px;
|
||||
bottom : 20px;
|
||||
z-index : 1000000;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
width : 30px;
|
||||
height : 30px;
|
||||
cursor : pointer;
|
||||
background-color : @teal;
|
||||
.tooltipLeft('Jump to brew page');
|
||||
}
|
||||
|
||||
.editorToolbar {
|
||||
position : absolute;
|
||||
top : 5px;
|
||||
left : 50%;
|
||||
z-index : 9;
|
||||
font-size : 13px;
|
||||
color : black;
|
||||
span { padding : 2px 5px; }
|
||||
}
|
||||
|
||||
}
|
||||
414
client/homebrew/editor/metadataEditor/metadataEditor.jsx
Normal file
414
client/homebrew/editor/metadataEditor/metadataEditor.jsx
Normal file
@@ -0,0 +1,414 @@
|
||||
/* eslint-disable max-lines */
|
||||
import './metadataEditor.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Combobox from '../../../components/combobox.jsx';
|
||||
import TagInput from '../tagInput/tagInput.jsx';
|
||||
|
||||
|
||||
import Themes from '../../../../themes/themes.json';
|
||||
import validations from './validations.js';
|
||||
|
||||
const SYSTEMS = ['5e', '4e', '3.5e', 'Pathfinder'];
|
||||
|
||||
import homebreweryThumbnail from '../../thumbnail.png';
|
||||
|
||||
const callIfExists = (val, fn, ...args)=>{
|
||||
if(val[fn]) {
|
||||
val[fn](...args);
|
||||
}
|
||||
};
|
||||
|
||||
const MetadataEditor = createReactClass({
|
||||
displayName : 'MetadataEditor',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
metadata : {
|
||||
editId : null,
|
||||
shareId : null,
|
||||
title : '',
|
||||
description : '',
|
||||
thumbnail : '',
|
||||
tags : [],
|
||||
published : false,
|
||||
authors : [],
|
||||
systems : [],
|
||||
renderer : 'legacy',
|
||||
theme : '5ePHB',
|
||||
lang : 'en'
|
||||
},
|
||||
|
||||
onChange : ()=>{},
|
||||
reportError : ()=>{}
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState : function(){
|
||||
return {
|
||||
showThumbnail : true
|
||||
};
|
||||
},
|
||||
|
||||
toggleThumbnailDisplay : function(){
|
||||
this.setState({
|
||||
showThumbnail : !this.state.showThumbnail
|
||||
});
|
||||
},
|
||||
|
||||
renderThumbnail : function(){
|
||||
if(!this.state.showThumbnail) return;
|
||||
return <img className='thumbnail-preview' src={this.props.metadata.thumbnail || homebreweryThumbnail}></img>;
|
||||
},
|
||||
|
||||
handleFieldChange : function(name, e){
|
||||
// load validation rules, and check input value against them
|
||||
const inputRules = validations[name] ?? [];
|
||||
const validationErr = inputRules.map((rule)=>rule(e.target.value)).filter(Boolean);
|
||||
|
||||
const debouncedReportValidity = _.debounce((target, errMessage)=>{
|
||||
callIfExists(target, 'setCustomValidity', errMessage);
|
||||
callIfExists(target, 'reportValidity');
|
||||
}, 300); // 300ms debounce delay, adjust as needed
|
||||
|
||||
// if no validation rules, save to props
|
||||
if(validationErr.length === 0){
|
||||
callIfExists(e.target, 'setCustomValidity', '');
|
||||
this.props.onChange({
|
||||
...this.props.metadata,
|
||||
[name] : e.target.value
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
// if validation issues, display built-in browser error popup with each error.
|
||||
const errMessage = validationErr.map((err)=>{
|
||||
return `- ${err}`;
|
||||
}).join('\n');
|
||||
|
||||
|
||||
debouncedReportValidity(e.target, errMessage);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
handleSystem : function(system, e){
|
||||
if(e.target.checked){
|
||||
this.props.metadata.systems.push(system);
|
||||
} else {
|
||||
this.props.metadata.systems = _.without(this.props.metadata.systems, system);
|
||||
}
|
||||
this.props.onChange(this.props.metadata);
|
||||
},
|
||||
|
||||
handleRenderer : function(renderer, e){
|
||||
if(e.target.checked){
|
||||
this.props.metadata.renderer = renderer;
|
||||
if(renderer == 'legacy')
|
||||
this.props.metadata.theme = '5ePHB';
|
||||
}
|
||||
this.props.onChange(this.props.metadata, 'renderer');
|
||||
},
|
||||
|
||||
handlePublish : function(val){
|
||||
this.props.onChange({
|
||||
...this.props.metadata,
|
||||
published : val
|
||||
});
|
||||
},
|
||||
|
||||
handleTheme : function(theme){
|
||||
this.props.metadata.renderer = theme.renderer;
|
||||
this.props.metadata.theme = theme.path;
|
||||
|
||||
this.props.onChange(this.props.metadata, 'theme');
|
||||
},
|
||||
|
||||
handleThemeWritein : function(e) {
|
||||
const shareId = e.target.value.split('/').pop(); //Extract just the ID if a URL was pasted in
|
||||
this.props.metadata.theme = shareId;
|
||||
|
||||
this.props.onChange(this.props.metadata, 'theme');
|
||||
},
|
||||
|
||||
handleLanguage : function(languageCode){
|
||||
this.props.metadata.lang = languageCode;
|
||||
this.props.onChange(this.props.metadata);
|
||||
},
|
||||
|
||||
handleDelete : function(){
|
||||
if(this.props.metadata.authors && this.props.metadata.authors.length <= 1){
|
||||
if(!confirm('Are you sure you want to delete this brew? Because you are the only owner of this brew, the document will be deleted permanently.')) return;
|
||||
if(!confirm('Are you REALLY sure? You will not be able to recover the document.')) return;
|
||||
} else {
|
||||
if(!confirm('Are you sure you want to remove this brew from your collection? This will remove you as an editor, but other owners will still be able to access the document.')) return;
|
||||
if(!confirm('Are you REALLY sure? You will lose editor access to this document.')) return;
|
||||
}
|
||||
|
||||
request.delete(`/api/${this.props.metadata.googleId ?? ''}${this.props.metadata.editId}`)
|
||||
.send()
|
||||
.end((err, res)=>{
|
||||
if(err) {
|
||||
this.props.reportError(err);
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
renderSystems : function(){
|
||||
return _.map(SYSTEMS, (val)=>{
|
||||
return <label key={val}>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={_.includes(this.props.metadata.systems, val)}
|
||||
onChange={(e)=>this.handleSystem(val, e)} />
|
||||
{val}
|
||||
</label>;
|
||||
});
|
||||
},
|
||||
|
||||
renderPublish : function(){
|
||||
if(this.props.metadata.published){
|
||||
return <button className='unpublish' onClick={()=>this.handlePublish(false)}>
|
||||
<i className='fas fa-ban' /> unpublish
|
||||
</button>;
|
||||
} else {
|
||||
return <button className='publish' onClick={()=>this.handlePublish(true)}>
|
||||
<i className='fas fa-globe' /> publish
|
||||
</button>;
|
||||
}
|
||||
},
|
||||
|
||||
renderDelete : function(){
|
||||
if(!this.props.metadata.editId) return;
|
||||
|
||||
return <div className='field delete'>
|
||||
<label>delete</label>
|
||||
<div className='value'>
|
||||
<button className='publish' onClick={this.handleDelete}>
|
||||
<i className='fas fa-trash-alt' /> delete brew
|
||||
</button>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderAuthors : function(){
|
||||
let text = 'None.';
|
||||
if(this.props.metadata.authors && this.props.metadata.authors.length){
|
||||
text = this.props.metadata.authors.join(', ');
|
||||
}
|
||||
return <div className='field authors'>
|
||||
<label>authors</label>
|
||||
<div className='value'>
|
||||
{text}
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderThemeDropdown : function(){
|
||||
const mergedThemes = _.merge(Themes, this.props.userThemes);
|
||||
|
||||
const listThemes = (renderer)=>{
|
||||
return _.map(_.values(mergedThemes[renderer]), (theme)=>{
|
||||
if(theme.path == this.props.metadata.shareId) return;
|
||||
const preview = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownPreview.png`;
|
||||
const texture = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownTexture.png`;
|
||||
return <div className='item' key={`${renderer}_${theme.name}`} value={`${theme.author ?? renderer} : ${theme.name}`} data={theme} title={''}>
|
||||
{theme.author ?? renderer} : {theme.name}
|
||||
<div className='texture-container'>
|
||||
<img src={texture}/>
|
||||
</div>
|
||||
<div className='preview'>
|
||||
<h6>{theme.name} preview</h6>
|
||||
<img src={preview}/>
|
||||
</div>
|
||||
</div>;
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
const currentRenderer = this.props.metadata.renderer;
|
||||
const currentThemeDisplay = this.props.themeBundle?.name ? `${this.props.themeBundle.author ?? currentRenderer} : ${this.props.themeBundle.name}` : 'No Theme Selected';
|
||||
let dropdown;
|
||||
|
||||
if(currentRenderer == 'legacy') {
|
||||
dropdown =
|
||||
<div className='disabled value' trigger='disabled'>
|
||||
<div> Themes are not supported in the Legacy Renderer </div>
|
||||
</div>;
|
||||
} else {
|
||||
dropdown =
|
||||
<div className='value'>
|
||||
<Combobox trigger='click'
|
||||
className='themes-dropdown'
|
||||
default={currentThemeDisplay}
|
||||
placeholder='Select from below, or enter the Share URL or ID of a brew with the meta:theme tag'
|
||||
onSelect={(value)=>this.handleTheme(value)}
|
||||
onEntry={(e)=>{
|
||||
e.target.setCustomValidity(''); //Clear the validation popup while typing
|
||||
if(this.handleFieldChange('theme', e))
|
||||
this.handleThemeWritein(e);
|
||||
}}
|
||||
options={listThemes(currentRenderer)}
|
||||
autoSuggest={{
|
||||
suggestMethod : 'includes',
|
||||
clearAutoSuggestOnClick : true,
|
||||
filterOn : ['value', 'title']
|
||||
}}
|
||||
/>
|
||||
<small>Select from the list below (built-in themes and brews you have tagged "meta:theme"), or paste in the Share URL or Share ID of any brew.</small>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className='field themes'>
|
||||
<label>theme</label>
|
||||
{dropdown}
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderLanguageDropdown : function(){
|
||||
const langCodes = ['en', 'de', 'de-ch', 'fr', 'ja', 'es', 'it', 'sv', 'ru', 'zh-Hans', 'zh-Hant'];
|
||||
const listLanguages = ()=>{
|
||||
return _.map(langCodes.sort(), (code, index)=>{
|
||||
const localName = new Intl.DisplayNames([code], { type: 'language' });
|
||||
const englishName = new Intl.DisplayNames('en', { type: 'language' });
|
||||
return <div className='item' title={englishName.of(code)} key={`${index}`} value={code} detail={localName.of(code)}>
|
||||
{code}
|
||||
<div className='detail'>{localName.of(code)}</div>
|
||||
</div>;
|
||||
});
|
||||
};
|
||||
|
||||
return <div className='field language'>
|
||||
<label>language</label>
|
||||
<div className='value'>
|
||||
<Combobox trigger='click'
|
||||
className='language-dropdown'
|
||||
default={this.props.metadata.lang || ''}
|
||||
placeholder='en'
|
||||
onSelect={(value)=>this.handleLanguage(value)}
|
||||
onEntry={(e)=>{
|
||||
e.target.setCustomValidity(''); //Clear the validation popup while typing
|
||||
this.handleFieldChange('lang', e);
|
||||
}}
|
||||
options={listLanguages()}
|
||||
autoSuggest={{
|
||||
suggestMethod : 'startsWith',
|
||||
clearAutoSuggestOnClick : true,
|
||||
filterOn : ['value', 'detail', 'title']
|
||||
}}
|
||||
/>
|
||||
<small>Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.</small>
|
||||
</div>
|
||||
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderRenderOptions : function(){
|
||||
return <div className='field systems'>
|
||||
<label>Renderer</label>
|
||||
<div className='value'>
|
||||
<label key='legacy'>
|
||||
<input
|
||||
type='radio'
|
||||
value = 'legacy'
|
||||
name = 'renderer'
|
||||
checked={this.props.metadata.renderer === 'legacy'}
|
||||
onChange={(e)=>this.handleRenderer('legacy', e)} />
|
||||
Legacy
|
||||
</label>
|
||||
|
||||
<label key='V3'>
|
||||
<input
|
||||
type='radio'
|
||||
value = 'V3'
|
||||
name = 'renderer'
|
||||
checked={this.props.metadata.renderer === 'V3'}
|
||||
onChange={(e)=>this.handleRenderer('V3', e)} />
|
||||
V3
|
||||
</label>
|
||||
<small><a href='/legacy' target='_blank' rel='noopener noreferrer'>Click here to see the demo page for the old Legacy renderer!</a></small>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
|
||||
render : function(){
|
||||
return <div className='metadataEditor'>
|
||||
<h1>Properties Editor</h1>
|
||||
|
||||
<div className='field title'>
|
||||
<label>title</label>
|
||||
<input type='text' className='value'
|
||||
defaultValue={this.props.metadata.title}
|
||||
onChange={(e)=>this.handleFieldChange('title', e)} />
|
||||
</div>
|
||||
<div className='field-group'>
|
||||
<div className='field-column'>
|
||||
<div className='field description'>
|
||||
<label>description</label>
|
||||
<textarea defaultValue={this.props.metadata.description} className='value'
|
||||
onChange={(e)=>this.handleFieldChange('description', e)} />
|
||||
</div>
|
||||
<div className='field thumbnail'>
|
||||
<label>thumbnail</label>
|
||||
<input type='text'
|
||||
defaultValue={this.props.metadata.thumbnail}
|
||||
placeholder='https://my.thumbnail.url'
|
||||
className='value'
|
||||
onChange={(e)=>this.handleFieldChange('thumbnail', e)} />
|
||||
<button className='display' onClick={this.toggleThumbnailDisplay}>
|
||||
<i className={`fas fa-caret-${this.state.showThumbnail ? 'right' : 'left'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{this.renderThumbnail()}
|
||||
</div>
|
||||
|
||||
<TagInput label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
|
||||
placeholder='add tag' unique={true}
|
||||
values={this.props.metadata.tags}
|
||||
onChange={(e)=>this.handleFieldChange('tags', e)}
|
||||
/>
|
||||
|
||||
<div className='field systems'>
|
||||
<label>systems</label>
|
||||
<div className='value'>
|
||||
{this.renderSystems()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.renderLanguageDropdown()}
|
||||
|
||||
{this.renderThemeDropdown()}
|
||||
|
||||
{this.renderRenderOptions()}
|
||||
|
||||
<h2>Authors</h2>
|
||||
|
||||
{this.renderAuthors()}
|
||||
|
||||
<TagInput label='invited authors' valuePatterns={[/.+/]}
|
||||
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
|
||||
placeholder='invite author' unique={true}
|
||||
values={this.props.metadata.invitedAuthors}
|
||||
notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']}
|
||||
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}
|
||||
/>
|
||||
|
||||
<h2>Privacy</h2>
|
||||
|
||||
<div className='field publish'>
|
||||
<label>publish</label>
|
||||
<div className='value'>
|
||||
{this.renderPublish()}
|
||||
<small>Published brews are searchable in <a href='/vault'>the Vault</a> and visible on your user page. Unpublished brews are not indexed in the Vault or visible on your user page, but can still be shared and indexed by search engines. You can unpublish a brew any time.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.renderDelete()}
|
||||
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default MetadataEditor;
|
||||
317
client/homebrew/editor/metadataEditor/metadataEditor.less
Normal file
317
client/homebrew/editor/metadataEditor/metadataEditor.less
Normal file
@@ -0,0 +1,317 @@
|
||||
@import './shared/naturalcrit/styles/core.less';
|
||||
|
||||
.userThemeName {
|
||||
padding-right : 10px;
|
||||
padding-left : 10px;
|
||||
}
|
||||
|
||||
.metadataEditor {
|
||||
position : absolute;
|
||||
box-sizing : border-box;
|
||||
width : 100%;
|
||||
height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this.
|
||||
padding : 25px;
|
||||
overflow-y : auto;
|
||||
font-size : 13px;
|
||||
background-color : #999999;
|
||||
|
||||
h1 {
|
||||
margin : 0 0 40px;
|
||||
font-weight : bold;
|
||||
text-transform : uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin : 20px 0;
|
||||
font-weight : bold;
|
||||
color : #555555;
|
||||
border-bottom : 2px solid gray;
|
||||
}
|
||||
|
||||
& > div { margin-bottom : 10px; }
|
||||
|
||||
.field-group {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
gap : 10px;
|
||||
width : 100%;
|
||||
}
|
||||
|
||||
.field-column {
|
||||
display : flex;
|
||||
flex : 5 0 200px;
|
||||
flex-direction : column;
|
||||
gap : 10px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.field {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
width : 100%;
|
||||
min-width : 200px;
|
||||
& > label {
|
||||
width : 80px;
|
||||
font-size : 0.9em;
|
||||
font-weight : 800;
|
||||
line-height : 1.8em;
|
||||
text-transform : uppercase;
|
||||
}
|
||||
& > .value {
|
||||
flex : 1 1 auto;
|
||||
width : 50px;
|
||||
&:invalid { background : #FFB9B9; }
|
||||
small {
|
||||
display : block;
|
||||
font-size : 0.9em;
|
||||
font-style : italic;
|
||||
line-height : 1.4em;
|
||||
}
|
||||
}
|
||||
input[type='text'], textarea {
|
||||
border : 1px solid gray;
|
||||
&:focus { outline : 1px solid #444444; }
|
||||
}
|
||||
&.thumbnail, &.themes {
|
||||
label { line-height : 2.0em; }
|
||||
.value {
|
||||
overflow : hidden;
|
||||
text-overflow : ellipsis;
|
||||
}
|
||||
button {
|
||||
.colorButton();
|
||||
padding : 0px 5px;
|
||||
color : white;
|
||||
background-color : black;
|
||||
border : 1px solid #999999;
|
||||
&:hover { background-color : #777777; }
|
||||
}
|
||||
}
|
||||
|
||||
&.themes {
|
||||
.value {
|
||||
overflow : visible;
|
||||
text-overflow : auto;
|
||||
}
|
||||
button {
|
||||
padding-right : 5px;
|
||||
padding-left : 5px;
|
||||
}
|
||||
}
|
||||
|
||||
&.description {
|
||||
flex : 1;
|
||||
textarea.value {
|
||||
height : auto;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
resize : none;
|
||||
}
|
||||
}
|
||||
|
||||
&.language .language-dropdown {
|
||||
z-index : 200;
|
||||
max-width : 150px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.thumbnail-preview {
|
||||
position : relative;
|
||||
flex : 1 1;
|
||||
justify-self : center;
|
||||
width : 80px;
|
||||
height : min-content;
|
||||
max-height : 115px;
|
||||
aspect-ratio : 1 / 1;
|
||||
object-fit : contain;
|
||||
background-color : #AAAAAA;
|
||||
}
|
||||
|
||||
.systems.field .value {
|
||||
label {
|
||||
display : inline-flex;
|
||||
align-items : center;
|
||||
margin-right : 15px;
|
||||
font-size : 0.9em;
|
||||
font-weight : 800;
|
||||
vertical-align : middle;
|
||||
white-space : nowrap;
|
||||
cursor : pointer;
|
||||
user-select : none;
|
||||
}
|
||||
input {
|
||||
margin : 3px;
|
||||
vertical-align : middle;
|
||||
cursor : pointer;
|
||||
}
|
||||
}
|
||||
.publish.field .value {
|
||||
position : relative;
|
||||
margin-bottom : 15px;
|
||||
button { width : 100%; }
|
||||
button.publish {
|
||||
.colorButton(@blueLight);
|
||||
}
|
||||
button.unpublish {
|
||||
.colorButton(@silver);
|
||||
}
|
||||
}
|
||||
|
||||
.delete.field .value {
|
||||
button {
|
||||
.colorButton(@red);
|
||||
}
|
||||
}
|
||||
.authors.field .value { line-height : 1.5em; }
|
||||
|
||||
.themes.field {
|
||||
& .dropdown-container {
|
||||
position : relative;
|
||||
z-index : 100;
|
||||
background-color : white;
|
||||
}
|
||||
& .dropdown-options { overflow-y : visible; }
|
||||
.disabled {
|
||||
font-style : italic;
|
||||
color : dimgray;
|
||||
background-color : darkgray;
|
||||
}
|
||||
.item {
|
||||
position : relative;
|
||||
padding : 3px 3px;
|
||||
overflow : visible;
|
||||
background-color : white;
|
||||
border-top : 1px solid rgb(118, 118, 118);
|
||||
.preview {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
right : 0;
|
||||
z-index : 1;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
width : 200px;
|
||||
overflow : hidden;
|
||||
color : black;
|
||||
background : #CCCCCC;
|
||||
border-radius : 5px;
|
||||
box-shadow : 0 0 5px black;
|
||||
opacity : 0;
|
||||
transition : opacity 250ms ease;
|
||||
h6 {
|
||||
padding-block : 0.5em;
|
||||
padding-inline : 1em;
|
||||
font-weight : 900;
|
||||
border-bottom : 2px solid hsl(0,0%,40%);
|
||||
}
|
||||
}
|
||||
|
||||
.texture-container {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
left : 0;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
min-height : 100%;
|
||||
overflow : hidden;
|
||||
> img {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
right : 0;
|
||||
width : 50%;
|
||||
min-height : 100%;
|
||||
-webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
|
||||
mask-image : linear-gradient(90deg, transparent, black 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color : white;
|
||||
background-color : @blue;
|
||||
filter : unset;
|
||||
}
|
||||
&:hover > .preview { opacity : 1; }
|
||||
}
|
||||
}
|
||||
|
||||
.field .list {
|
||||
display : flex;
|
||||
flex : 1 0;
|
||||
flex-wrap : wrap;
|
||||
|
||||
> * { flex : 0 0 auto; }
|
||||
|
||||
#groupedIcon {
|
||||
#backgroundColors;
|
||||
position : relative;
|
||||
top : -0.3em;
|
||||
right : -0.3em;
|
||||
display : inline-block;
|
||||
min-width : 20px;
|
||||
height : ~'calc(100% + 0.6em)';
|
||||
color : white;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
|
||||
i {
|
||||
position : relative;
|
||||
top : 50%;
|
||||
transform : translateY(-50%);
|
||||
}
|
||||
|
||||
&:not(:last-child) { border-right : 1px solid black; }
|
||||
|
||||
&:last-child { border-radius : 0 0.5em 0.5em 0; }
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding : 0.3em;
|
||||
margin : 2px;
|
||||
font-size : 0.9em;
|
||||
background-color : #DDDDDD;
|
||||
border-radius : 0.5em;
|
||||
|
||||
.icon { #groupedIcon; }
|
||||
}
|
||||
|
||||
.input-group {
|
||||
height : ~'calc(.9em + 4px + .6em)';
|
||||
|
||||
input { border-radius : 0.5em 0 0 0.5em; }
|
||||
|
||||
input:last-child { border-radius : 0.5em; }
|
||||
|
||||
.value {
|
||||
width : 7.5vw;
|
||||
min-width : 75px;
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
height : ~'calc(.9em + 4px + .6em)';
|
||||
|
||||
input { border-radius : 0.5em 0 0 0.5em; }
|
||||
|
||||
input:last-child { border-radius : 0.5em; }
|
||||
|
||||
.value {
|
||||
width : 7.5vw;
|
||||
min-width : 75px;
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
.invalid:focus { background-color : pink; }
|
||||
|
||||
.icon {
|
||||
#groupedIcon;
|
||||
top : -0.54em;
|
||||
right : 1px;
|
||||
height : 97%;
|
||||
|
||||
i { font-size : 1.125em; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
client/homebrew/editor/metadataEditor/validations.js
Normal file
47
client/homebrew/editor/metadataEditor/validations.js
Normal file
@@ -0,0 +1,47 @@
|
||||
export default {
|
||||
title : [
|
||||
(value)=>{
|
||||
return value?.length > 100 ? 'Max title length of 100 characters' : null;
|
||||
}
|
||||
],
|
||||
description : [
|
||||
(value)=>{
|
||||
return value?.length > 500 ? 'Max description length of 500 characters.' : null;
|
||||
}
|
||||
],
|
||||
thumbnail : [
|
||||
(value)=>{
|
||||
return value?.length > 256 ? 'Max URL length of 256 characters.' : null;
|
||||
},
|
||||
(value)=>{
|
||||
if(value?.length == 0){return null;}
|
||||
try {
|
||||
Boolean(new URL(value));
|
||||
return null;
|
||||
} catch {
|
||||
return 'Must be a valid URL';
|
||||
}
|
||||
}
|
||||
],
|
||||
lang : [
|
||||
(value)=>{
|
||||
return new RegExp(/^([a-zA-Z]{2,3})(-[a-zA-Z]{4})?(-(?:[0-9]{3}|[a-zA-Z]{2}))?$/).test(value) === false && (value.length > 0) ? 'Invalid language code.' : null;
|
||||
}
|
||||
],
|
||||
theme : [
|
||||
(value)=>{
|
||||
const URL = global.config.baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); //Escape any regex characters
|
||||
const shareIDPattern = '[a-zA-Z0-9-_]{12}';
|
||||
const shareURLRegex = new RegExp(`^${URL}\\/share\\/${shareIDPattern}$`);
|
||||
const shareIDRegex = new RegExp(`^${shareIDPattern}$`);
|
||||
if(value?.length === 0) return null;
|
||||
if(shareURLRegex.test(value)) return null;
|
||||
if(shareIDRegex.test(value)) return null;
|
||||
|
||||
return 'Must be a valid Share URL or a 12-character ID.';
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
|
||||
337
client/homebrew/editor/snippetbar/snippetbar.jsx
Normal file
337
client/homebrew/editor/snippetbar/snippetbar.jsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/*eslint max-lines: ["warn", {"max": 350, "skipBlankLines": true, "skipComments": true}]*/
|
||||
import './snippetbar.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
import _ from 'lodash';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { loadHistory } from '../../utils/versionHistory.js';
|
||||
import { brewSnippetsToJSON } from '../../../../shared/helpers.js';
|
||||
|
||||
import Legacy5ePHB from '../../../../themes/Legacy/5ePHB/snippets.js';
|
||||
import V3_5ePHB from '../../../../themes/V3/5ePHB/snippets.js';
|
||||
import V3_5eDMG from '../../../../themes/V3/5eDMG/snippets.js';
|
||||
import V3_Journal from '../../../../themes/V3/Journal/snippets.js';
|
||||
import V3_Blank from '../../../../themes/V3/Blank/snippets.js';
|
||||
|
||||
const ThemeSnippets = {
|
||||
Legacy_5ePHB : Legacy5ePHB,
|
||||
V3_5ePHB : V3_5ePHB,
|
||||
V3_5eDMG : V3_5eDMG,
|
||||
V3_Journal : V3_Journal,
|
||||
V3_Blank : V3_Blank,
|
||||
};
|
||||
|
||||
import EditorThemes from '../../../../build/homebrew/codeMirror/editorThemes.json';
|
||||
|
||||
const execute = function(val, props){
|
||||
if(_.isFunction(val)) return val(props);
|
||||
return val;
|
||||
};
|
||||
|
||||
const Snippetbar = createReactClass({
|
||||
displayName : 'SnippetBar',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brew : {},
|
||||
view : 'text',
|
||||
onViewChange : ()=>{},
|
||||
onInject : ()=>{},
|
||||
onToggle : ()=>{},
|
||||
showEditButtons : true,
|
||||
renderer : 'legacy',
|
||||
undo : ()=>{},
|
||||
redo : ()=>{},
|
||||
historySize : ()=>{},
|
||||
foldCode : ()=>{},
|
||||
unfoldCode : ()=>{},
|
||||
updateEditorTheme : ()=>{},
|
||||
cursorPos : {},
|
||||
themeBundle : [],
|
||||
updateBrew : ()=>{}
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
renderer : this.props.renderer,
|
||||
themeSelector : false,
|
||||
snippets : [],
|
||||
showHistory : false,
|
||||
historyExists : false,
|
||||
historyItems : []
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount : async function(prevState) {
|
||||
const snippets = this.compileSnippets();
|
||||
this.setState({
|
||||
snippets : snippets
|
||||
});
|
||||
},
|
||||
|
||||
componentDidUpdate : async function(prevProps, prevState) {
|
||||
if(prevProps.renderer != this.props.renderer ||
|
||||
prevProps.theme != this.props.theme ||
|
||||
prevProps.themeBundle != this.props.themeBundle ||
|
||||
prevProps.brew.snippets != this.props.brew.snippets) {
|
||||
this.setState({
|
||||
snippets : this.compileSnippets()
|
||||
});
|
||||
};
|
||||
|
||||
// Update history list if it has changed
|
||||
const checkHistoryItems = await loadHistory(this.props.brew);
|
||||
|
||||
// If all items have the noData property, there is no saved data
|
||||
const checkHistoryExists = !checkHistoryItems.every((historyItem)=>{
|
||||
return historyItem?.noData;
|
||||
});
|
||||
if(prevState.historyExists != checkHistoryExists){
|
||||
this.setState({
|
||||
historyExists : checkHistoryExists
|
||||
});
|
||||
}
|
||||
|
||||
// If any history items have changed, update the list
|
||||
if(checkHistoryExists && checkHistoryItems.some((historyItem, index)=>{
|
||||
return index >= prevState.historyItems.length || !_.isEqual(historyItem, prevState.historyItems[index]);
|
||||
})){
|
||||
this.setState({
|
||||
historyItems : checkHistoryItems
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
mergeCustomizer : function(oldValue, newValue, key) {
|
||||
if(key == 'snippets') {
|
||||
const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme
|
||||
return result.filter((snip)=>snip.gen || snip.subsnippets);
|
||||
};
|
||||
},
|
||||
|
||||
compileSnippets : function() {
|
||||
let compiledSnippets = [];
|
||||
|
||||
let oldSnippets = _.keyBy(compiledSnippets, 'groupName');
|
||||
|
||||
if(this.props.themeBundle.snippets) {
|
||||
for (let snippets of this.props.themeBundle.snippets) {
|
||||
if(typeof(snippets) == 'string') // load staticThemes as needed; they were sent as just a file name
|
||||
snippets = ThemeSnippets[snippets];
|
||||
|
||||
const newSnippets = _.keyBy(_.cloneDeep(snippets), 'groupName');
|
||||
compiledSnippets = _.values(_.mergeWith(oldSnippets, newSnippets, this.mergeCustomizer));
|
||||
|
||||
oldSnippets = _.keyBy(compiledSnippets, 'groupName');
|
||||
}
|
||||
}
|
||||
|
||||
const userSnippetsasJSON = brewSnippetsToJSON(this.props.brew.title || 'New Document', this.props.brew.snippets, this.props.themeBundle.snippets);
|
||||
compiledSnippets.push(userSnippetsasJSON);
|
||||
|
||||
return compiledSnippets;
|
||||
},
|
||||
|
||||
handleSnippetClick : function(injectedText){
|
||||
this.props.onInject(injectedText);
|
||||
},
|
||||
|
||||
toggleThemeSelector : function(e){
|
||||
if(e.target.tagName != 'SELECT'){
|
||||
this.setState({
|
||||
themeSelector : !this.state.themeSelector
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
changeTheme : function(e){
|
||||
if(e.target.value == this.props.currentEditorTheme) return;
|
||||
this.props.updateEditorTheme(e.target.value);
|
||||
|
||||
this.setState({
|
||||
showThemeSelector : false,
|
||||
});
|
||||
},
|
||||
|
||||
renderThemeSelector : function(){
|
||||
return <div className='themeSelector'>
|
||||
<select value={this.props.currentEditorTheme} onChange={this.changeTheme} >
|
||||
{EditorThemes.map((theme, key)=>{
|
||||
return <option key={key} value={theme}>{theme}</option>;
|
||||
})}
|
||||
</select>
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderSnippetGroups : function(){
|
||||
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
|
||||
if(snippets.length === 0) return null;
|
||||
|
||||
return <div className='snippets'>
|
||||
{_.map(snippets, (snippetGroup)=>{
|
||||
return <SnippetGroup
|
||||
brew={this.props.brew}
|
||||
groupName={snippetGroup.groupName}
|
||||
icon={snippetGroup.icon}
|
||||
snippets={snippetGroup.snippets}
|
||||
key={snippetGroup.groupName}
|
||||
onSnippetClick={this.handleSnippetClick}
|
||||
cursorPos={this.props.cursorPos}
|
||||
/>;
|
||||
})
|
||||
}
|
||||
</div>;
|
||||
},
|
||||
|
||||
replaceContent : function(item){
|
||||
return this.props.updateBrew(item);
|
||||
},
|
||||
|
||||
toggleHistoryMenu : function(){
|
||||
this.setState({
|
||||
showHistory : !this.state.showHistory
|
||||
});
|
||||
},
|
||||
|
||||
renderHistoryItems : function() {
|
||||
if(!this.state.historyExists) return;
|
||||
|
||||
return <div className='dropdown'>
|
||||
{_.map(this.state.historyItems, (item, index)=>{
|
||||
if(item.noData || !item.savedAt) return;
|
||||
|
||||
const saveTime = new Date(item.savedAt);
|
||||
const diffMs = new Date() - saveTime;
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
|
||||
let diffString = `about ${diffSecs} seconds ago`;
|
||||
|
||||
if(diffSecs > 60) diffString = `about ${Math.floor(diffSecs / 60)} minutes ago`;
|
||||
if(diffSecs > (60 * 60)) diffString = `about ${Math.floor(diffSecs / (60 * 60))} hours ago`;
|
||||
if(diffSecs > (24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (24 * 60 * 60))} days ago`;
|
||||
if(diffSecs > (7 * 24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (7 * 24 * 60 * 60))} weeks ago`;
|
||||
|
||||
return <div className='snippet' key={index} onClick={()=>{this.replaceContent(item);}} >
|
||||
<i className={`fas fa-${index+1}`} />
|
||||
<span className='name' title={saveTime.toISOString()}>v{item.version} : {diffString}</span>
|
||||
</div>;
|
||||
})}
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderEditorButtons : function(){
|
||||
if(!this.props.showEditButtons) return;
|
||||
|
||||
return (
|
||||
<div className='editors'>
|
||||
{this.props.view !== 'meta' && <><div className='historyTools'>
|
||||
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
|
||||
onClick={this.toggleHistoryMenu} >
|
||||
<i className='fas fa-clock-rotate-left' />
|
||||
{ this.state.showHistory && this.renderHistoryItems() }
|
||||
</div>
|
||||
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
|
||||
onClick={this.props.undo} >
|
||||
<i className='fas fa-undo' />
|
||||
</div>
|
||||
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
|
||||
onClick={this.props.redo} >
|
||||
<i className='fas fa-redo' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='codeTools'>
|
||||
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
|
||||
onClick={this.props.foldCode} >
|
||||
<i className='fas fa-compress-alt' />
|
||||
</div>
|
||||
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
|
||||
onClick={this.props.unfoldCode} >
|
||||
<i className='fas fa-expand-alt' />
|
||||
</div>
|
||||
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
|
||||
onClick={this.toggleThemeSelector} >
|
||||
<i className='fas fa-palette' />
|
||||
{this.state.themeSelector && this.renderThemeSelector()}
|
||||
</div>
|
||||
</div></>}
|
||||
|
||||
<div className='tabs'>
|
||||
<div className={cx('text', { selected: this.props.view === 'text' })}
|
||||
onClick={()=>this.props.onViewChange('text')}>
|
||||
<i className='fa fa-beer' />
|
||||
</div>
|
||||
<div className={cx('style', { selected: this.props.view === 'style' })}
|
||||
onClick={()=>this.props.onViewChange('style')}>
|
||||
<i className='fa fa-paint-brush' />
|
||||
</div>
|
||||
<div className={cx('snippet', { selected: this.props.view === 'snippet' })}
|
||||
onClick={()=>this.props.onViewChange('snippet')}>
|
||||
<i className='fas fa-th-list' />
|
||||
</div>
|
||||
<div className={cx('meta', { selected: this.props.view === 'meta' })}
|
||||
onClick={()=>this.props.onViewChange('meta')}>
|
||||
<i className='fas fa-info-circle' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
render : function(){
|
||||
return <div className='snippetBar'>
|
||||
{this.renderSnippetGroups()}
|
||||
{this.renderEditorButtons()}
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default Snippetbar;
|
||||
|
||||
const SnippetGroup = createReactClass({
|
||||
displayName : 'SnippetGroup',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brew : {},
|
||||
groupName : '',
|
||||
icon : 'fas fa-rocket',
|
||||
snippets : [],
|
||||
onSnippetClick : function(){},
|
||||
};
|
||||
},
|
||||
handleSnippetClick : function(e, snippet){
|
||||
e.stopPropagation();
|
||||
this.props.onSnippetClick(execute(snippet.gen, this.props));
|
||||
},
|
||||
renderSnippets : function(snippets){
|
||||
return _.map(snippets, (snippet)=>{
|
||||
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}>
|
||||
<i className={snippet.icon} />
|
||||
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
|
||||
{snippet.experimental && <span className='beta'>beta</span>}
|
||||
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
|
||||
{snippet.subsnippets && <>
|
||||
<i className='fas fa-caret-right'></i>
|
||||
<div className='dropdown side'>
|
||||
{this.renderSnippets(snippet.subsnippets)}
|
||||
</div></>}
|
||||
</div>;
|
||||
|
||||
});
|
||||
},
|
||||
|
||||
render : function(){
|
||||
const snippetGroup = `snippetGroup snippetBarButton ${this.props.snippets.length === 0 ? 'disabledSnippets' : ''}`;
|
||||
return <div className={snippetGroup}>
|
||||
<div className='text'>
|
||||
<i className={this.props.icon} />
|
||||
<span className='groupName'>{this.props.groupName}</span>
|
||||
</div>
|
||||
<div className='dropdown'>
|
||||
{this.renderSnippets(this.props.snippets)}
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
});
|
||||
255
client/homebrew/editor/snippetbar/snippetbar.less
Normal file
255
client/homebrew/editor/snippetbar/snippetbar.less
Normal file
@@ -0,0 +1,255 @@
|
||||
@import './shared/naturalcrit/styles/core.less';
|
||||
@import (less) './client/icons/customIcons.less';
|
||||
@import (less) '././././themes/fonts/5e/fonts.less';
|
||||
|
||||
.snippetBar {
|
||||
@menuHeight : 25px;
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-wrap : wrap-reverse;
|
||||
justify-content : space-between;
|
||||
height : auto;
|
||||
color : black;
|
||||
background-color : #DDDDDD;
|
||||
|
||||
.snippets {
|
||||
display : flex;
|
||||
justify-content : flex-start;
|
||||
min-width : 499.35px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
|
||||
}
|
||||
|
||||
.editors {
|
||||
display : flex;
|
||||
justify-content : flex-end;
|
||||
min-width : 250px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
|
||||
|
||||
&:only-child {min-width : unset; margin-left : auto;}
|
||||
|
||||
>div {
|
||||
display : flex;
|
||||
flex : 1;
|
||||
justify-content : space-around;
|
||||
|
||||
&:first-child { border-left : none; }
|
||||
|
||||
& > div {
|
||||
position : relative;
|
||||
width : @menuHeight;
|
||||
height : @menuHeight;
|
||||
line-height : @menuHeight;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
|
||||
&.editorTool:not(.active) { cursor : not-allowed; }
|
||||
|
||||
&:hover,&.selected { background-color : #999999; }
|
||||
&.text {
|
||||
.tooltipLeft('Brew Editor');
|
||||
}
|
||||
&.style {
|
||||
.tooltipLeft('Style Editor');
|
||||
}
|
||||
&.meta {
|
||||
.tooltipLeft('Properties');
|
||||
}
|
||||
&.snippet {
|
||||
.tooltipLeft('Snippets');
|
||||
}
|
||||
&.undo {
|
||||
.tooltipLeft('Undo');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.redo {
|
||||
.tooltipLeft('Redo');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.foldAll {
|
||||
.tooltipLeft('Fold All');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.unfoldAll {
|
||||
.tooltipLeft('Unfold All');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.history {
|
||||
.tooltipLeft('History');
|
||||
position : relative;
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
border : none;
|
||||
&.active { color : inherit; }
|
||||
& > .dropdown {
|
||||
right : -1px;
|
||||
& > .snippet { padding-right : 10px; }
|
||||
}
|
||||
}
|
||||
&.editorTheme {
|
||||
.tooltipLeft('Editor Themes');
|
||||
font-size : 0.75em;
|
||||
color : inherit;
|
||||
&.active {
|
||||
position : relative;
|
||||
background-color : #999999;
|
||||
}
|
||||
}
|
||||
&.divider {
|
||||
width : 5px;
|
||||
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
|
||||
&:hover { background-color : inherit; }
|
||||
}
|
||||
}
|
||||
.themeSelector {
|
||||
position : absolute;
|
||||
top : 25px;
|
||||
right : 0;
|
||||
z-index : 10;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
width : 170px;
|
||||
height : inherit;
|
||||
background-color : inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
.snippetBarButton {
|
||||
display : inline-block;
|
||||
height : @menuHeight;
|
||||
padding : 0px 5px;
|
||||
font-size : 0.625em;
|
||||
font-weight : 800;
|
||||
line-height : @menuHeight;
|
||||
text-transform : uppercase;
|
||||
text-wrap : nowrap;
|
||||
cursor : pointer;
|
||||
&:hover, &.selected { background-color : #999999; }
|
||||
i {
|
||||
margin-right : 3px;
|
||||
font-size : 1.4em;
|
||||
vertical-align : middle;
|
||||
}
|
||||
}
|
||||
.toggleMeta {
|
||||
position : absolute;
|
||||
top : 0px;
|
||||
right : 0px;
|
||||
border-left : 1px solid black;
|
||||
.tooltipLeft('Edit Brew Properties');
|
||||
}
|
||||
.snippetGroup {
|
||||
|
||||
&:hover {
|
||||
& > .dropdown { visibility : visible; }
|
||||
}
|
||||
.dropdown {
|
||||
position : absolute;
|
||||
top : 100%;
|
||||
z-index : 1000;
|
||||
visibility : hidden;
|
||||
padding : 0px;
|
||||
margin-left : -5px;
|
||||
background-color : #DDDDDD;
|
||||
.snippet {
|
||||
position : relative;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
min-width : max-content;
|
||||
padding : 5px;
|
||||
font-size : 10px;
|
||||
cursor : pointer;
|
||||
.animate(background-color);
|
||||
i {
|
||||
min-width : 25px;
|
||||
height : 1.2em;
|
||||
margin-right : 8px;
|
||||
font-size : 1.2em;
|
||||
text-align : center;
|
||||
& ~ i {
|
||||
margin-right : 0;
|
||||
margin-left : 5px;
|
||||
}
|
||||
/* Fonts */
|
||||
&.font {
|
||||
height : auto;
|
||||
&::before {
|
||||
font-size : 1em;
|
||||
content : 'ABC';
|
||||
}
|
||||
|
||||
&.OpenSans {font-family : 'OpenSans';}
|
||||
&.CodeBold {font-family : 'CodeBold';}
|
||||
&.CodeLight {font-family : 'CodeLight';}
|
||||
&.ScalySansRemake {font-family : 'ScalySansRemake';}
|
||||
&.BookInsanityRemake {font-family : 'BookInsanityRemake';}
|
||||
&.MrEavesRemake {font-family : 'MrEavesRemake';}
|
||||
&.SolberaImitationRemake {font-family : 'SolberaImitationRemake';}
|
||||
&.ScalySansSmallCapsRemake {font-family : 'ScalySansSmallCapsRemake';}
|
||||
&.WalterTurncoat {font-family : 'WalterTurncoat';}
|
||||
&.Lato {font-family : 'Lato';}
|
||||
&.Courier {font-family : 'Courier';}
|
||||
&.NodestoCapsCondensed {font-family : 'NodestoCapsCondensed';}
|
||||
&.Overpass {font-family : 'Overpass';}
|
||||
&.Davek {font-family : 'Davek';}
|
||||
&.Iokharic {font-family : 'Iokharic';}
|
||||
&.Rellanic {font-family : 'Rellanic';}
|
||||
&.TimesNewRoman {font-family : 'Times New Roman';}
|
||||
}
|
||||
}
|
||||
.name { margin-right : auto; }
|
||||
.disabled { text-decoration : line-through; }
|
||||
.beta {
|
||||
align-self : center;
|
||||
padding : 4px 6px;
|
||||
margin-left : 5px;
|
||||
font-family : monospace;
|
||||
line-height : 1em;
|
||||
color : white;
|
||||
background : grey;
|
||||
border-radius : 12px;
|
||||
}
|
||||
&:hover {
|
||||
background-color : #999999;
|
||||
& > .dropdown {
|
||||
visibility : visible;
|
||||
&.side {
|
||||
top : 0%;
|
||||
left : 100%;
|
||||
margin-left : 0;
|
||||
box-shadow : -1px 1px 2px 0px #999999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabledSnippets {
|
||||
color: grey;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover { background-color: #DDDDDD;}
|
||||
}
|
||||
|
||||
}
|
||||
@container editor (width < 750px) {
|
||||
.snippetBar {
|
||||
.editors {
|
||||
flex : 1;
|
||||
justify-content : space-between;
|
||||
border-bottom : 1px solid;
|
||||
}
|
||||
.snippets {
|
||||
flex : 1;
|
||||
justify-content : space-evenly;
|
||||
}
|
||||
.editors > div.history > .dropdown { right : unset; }
|
||||
}
|
||||
}
|
||||
|
||||
104
client/homebrew/editor/tagInput/tagInput.jsx
Normal file
104
client/homebrew/editor/tagInput/tagInput.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import './tagInput.less';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
const TagInput = ({ unique = true, values = [], ...props })=>{
|
||||
const [tempInputText, setTempInputText] = useState('');
|
||||
const [tagList, setTagList] = useState(values.map((value)=>({ value, editing: false })));
|
||||
|
||||
useEffect(()=>{
|
||||
handleChange(tagList.map((context)=>context.value));
|
||||
}, [tagList]);
|
||||
|
||||
const handleChange = (value)=>{
|
||||
props.onChange({
|
||||
target : { value }
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputKeyDown = ({ evt, value, index, options = {} })=>{
|
||||
if(_.includes(['Enter', ','], evt.key)) {
|
||||
evt.preventDefault();
|
||||
submitTag(evt.target.value, value, index);
|
||||
if(options.clear) {
|
||||
setTempInputText('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submitTag = (newValue, originalValue, index)=>{
|
||||
setTagList((prevContext)=>{
|
||||
// remove existing tag
|
||||
if(newValue === null){
|
||||
return [...prevContext].filter((context, i)=>i !== index);
|
||||
}
|
||||
// add new tag
|
||||
if(originalValue === null){
|
||||
return [...prevContext, { value: newValue, editing: false }];
|
||||
}
|
||||
// update existing tag
|
||||
return prevContext.map((context, i)=>{
|
||||
if(i === index) {
|
||||
return { ...context, value: newValue, editing: false };
|
||||
}
|
||||
return context;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const editTag = (index)=>{
|
||||
setTagList((prevContext)=>{
|
||||
return prevContext.map((context, i)=>{
|
||||
if(i === index) {
|
||||
return { ...context, editing: true };
|
||||
}
|
||||
return { ...context, editing: false };
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderReadTag = (context, index)=>{
|
||||
return (
|
||||
<li key={index}
|
||||
data-value={context.value}
|
||||
className='tag'
|
||||
onClick={()=>editTag(index)}>
|
||||
{context.value}
|
||||
<button onClick={(evt)=>{evt.stopPropagation(); submitTag(null, context.value, index);}}><i className='fa fa-times fa-fw'/></button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWriteTag = (context, index)=>{
|
||||
return (
|
||||
<input type='text'
|
||||
key={index}
|
||||
defaultValue={context.value}
|
||||
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: context.value, index: index })}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='field'>
|
||||
<label>{props.label}</label>
|
||||
<div className='value'>
|
||||
<ul className='list'>
|
||||
{tagList.map((context, index)=>{ return context.editing ? renderWriteTag(context, index) : renderReadTag(context, index); })}
|
||||
</ul>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className='value'
|
||||
placeholder={props.placeholder}
|
||||
value={tempInputText}
|
||||
onChange={(e)=>setTempInputText(e.target.value)}
|
||||
onKeyDown={(evt)=>handleInputKeyDown({ evt, value: null, options: { clear: true } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagInput;
|
||||
0
client/homebrew/editor/tagInput/tagInput.less
Normal file
0
client/homebrew/editor/tagInput/tagInput.less
Normal file
BIN
client/homebrew/favicon.ico
Normal file
BIN
client/homebrew/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
8
client/homebrew/googleDrive.svg
Normal file
8
client/homebrew/googleDrive.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da"/>
|
||||
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47"/>
|
||||
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#ea4335"/>
|
||||
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d"/>
|
||||
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc"/>
|
||||
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#ffba00"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 755 B |
83
client/homebrew/homebrew.jsx
Normal file
83
client/homebrew/homebrew.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
|
||||
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';
|
||||
|
||||
import { updateLocalStorage } from './utils/updateLocalStorage/updateLocalStorageKeys.js';
|
||||
|
||||
import HomePage from './pages/homePage/homePage.jsx';
|
||||
import EditPage from './pages/editPage/editPage.jsx';
|
||||
import UserPage from './pages/userPage/userPage.jsx';
|
||||
import SharePage from './pages/sharePage/sharePage.jsx';
|
||||
import NewPage from './pages/newPage/newPage.jsx';
|
||||
import ErrorPage from './pages/errorPage/errorPage.jsx';
|
||||
import VaultPage from './pages/vaultPage/vaultPage.jsx';
|
||||
import AccountPage from './pages/accountPage/accountPage.jsx';
|
||||
|
||||
const WithRoute = ({ el: Element, ...rest })=>{
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryParams = Object.fromEntries(searchParams?.entries() || []);
|
||||
return <Element {...rest} {...params} query={queryParams} />;
|
||||
};
|
||||
|
||||
const Homebrew = (props)=>{
|
||||
const {
|
||||
url = '',
|
||||
version = '0.0.0',
|
||||
account = null,
|
||||
config,
|
||||
brew = {
|
||||
title : '',
|
||||
text : '',
|
||||
shareId : null,
|
||||
editId : null,
|
||||
createdAt : null,
|
||||
updatedAt : null,
|
||||
lang : ''
|
||||
},
|
||||
userThemes,
|
||||
brews
|
||||
} = props;
|
||||
|
||||
global.account = account;
|
||||
global.version = version;
|
||||
global.config = config;
|
||||
|
||||
const backgroundObject = ()=>{
|
||||
if(global.config?.deployment || (config?.local && config?.development)){
|
||||
const bgText = global.config?.deployment || 'Local';
|
||||
return {
|
||||
backgroundImage : `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='100px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${bgText}</text></svg>")`
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
updateLocalStorage();
|
||||
|
||||
return (
|
||||
<Router location={url}>
|
||||
<div className={`homebrew${(config?.deployment || config?.local) ? ' deployment' : ''}`} style={backgroundObject()}>
|
||||
<Routes>
|
||||
<Route path='/edit/:id' element={<WithRoute el={EditPage} brew={brew} userThemes={userThemes}/>} />
|
||||
<Route path='/share/:id' element={<WithRoute el={SharePage} brew={brew} />} />
|
||||
<Route path='/new/:id' element={<WithRoute el={NewPage} brew={brew} userThemes={userThemes}/>} />
|
||||
<Route path='/new' element={<WithRoute el={NewPage} userThemes={userThemes}/> } />
|
||||
<Route path='/user/:username' element={<WithRoute el={UserPage} brews={brews} />} />
|
||||
<Route path='/vault' element={<WithRoute el={VaultPage}/>}/>
|
||||
<Route path='/changelog' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/faq' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/migrate' element={<WithRoute el={SharePage} brew={brew} disableMeta={true} />} />
|
||||
<Route path='/account' element={<WithRoute el={AccountPage} brew={brew} accountDetails={brew.accountDetails} />} />
|
||||
<Route path='/legacy' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
<Route path='/error' element={<WithRoute el={ErrorPage} brew={brew} />} />
|
||||
<Route path='/' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
<Route path='/*' element={<WithRoute el={HomePage} brew={brew} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default Homebrew;
|
||||
35
client/homebrew/homebrew.less
Normal file
35
client/homebrew/homebrew.less
Normal file
@@ -0,0 +1,35 @@
|
||||
@import './shared/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;
|
||||
.content {
|
||||
position : relative;
|
||||
flex : auto;
|
||||
height : calc(~'100% - 29px'); //Navbar height
|
||||
overflow-y : hidden;
|
||||
}
|
||||
&.listPage .content {
|
||||
overflow-y : scroll;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width : 20px;
|
||||
&:horizontal {
|
||||
width : auto;
|
||||
height : 20px;
|
||||
}
|
||||
&-thumb {
|
||||
background : linear-gradient(90deg, #D3C1AF 15px, #00000000 15px);
|
||||
&:horizontal { background : linear-gradient(0deg, #D3C1AF 15px, #00000000 15px); }
|
||||
}
|
||||
&-corner { visibility : hidden; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
client/homebrew/navbar/account.navitem.jsx
Normal file
114
client/homebrew/navbar/account.navitem.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import request from 'superagent';
|
||||
import Nav from './nav.jsx';
|
||||
|
||||
const Account = createReactClass({
|
||||
displayName : 'AccountNavItem',
|
||||
getInitialState : function() {
|
||||
return {
|
||||
url : ''
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount : function(){
|
||||
if(typeof window !== 'undefined'){
|
||||
this.setState({
|
||||
url : window.location.href
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleLogout : function(){
|
||||
if(confirm('Are you sure you want to log out?')) {
|
||||
// Reset divider position
|
||||
window.localStorage.removeItem('naturalcrit-pane-split');
|
||||
// Clear login cookie
|
||||
let domain = '';
|
||||
if(window.location?.hostname) {
|
||||
let domainArray = window.location.hostname.split('.');
|
||||
if(domainArray.length > 2){
|
||||
domainArray = [''].concat(domainArray.slice(-2));
|
||||
}
|
||||
domain = domainArray.join('.');
|
||||
}
|
||||
document.cookie = `nc_session=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/;samesite=lax;${domain ? `domain=${domain}` : ''}`;
|
||||
window.location = '/';
|
||||
}
|
||||
},
|
||||
|
||||
localLogin : async function(){
|
||||
const username = prompt('Enter username:');
|
||||
if(!username) {return;}
|
||||
|
||||
const expiry = new Date;
|
||||
expiry.setFullYear(expiry.getFullYear() + 1);
|
||||
|
||||
const token = await request.post('/local/login')
|
||||
.send({ username })
|
||||
.then((response)=>{
|
||||
return response.body;
|
||||
})
|
||||
.catch((err)=>{
|
||||
console.warn(err);
|
||||
});
|
||||
if(!token) return;
|
||||
|
||||
document.cookie = `nc_session=${token};expires=${expiry};path=/;samesite=lax;${window.domain ? `domain=${window.domain}` : ''}`;
|
||||
window.location.reload(true);
|
||||
},
|
||||
|
||||
render : function(){
|
||||
// Logged in
|
||||
if(global.account){
|
||||
return <Nav.dropdown>
|
||||
<Nav.item
|
||||
className='account username'
|
||||
color='orange'
|
||||
icon='fas fa-user'
|
||||
>
|
||||
{global.account.username}
|
||||
</Nav.item>
|
||||
<Nav.item
|
||||
href={`/user/${encodeURIComponent(global.account.username)}`}
|
||||
color='yellow'
|
||||
icon='fas fa-beer'
|
||||
>
|
||||
brews
|
||||
</Nav.item>
|
||||
<Nav.item
|
||||
className='account'
|
||||
color='orange'
|
||||
icon='fas fa-user'
|
||||
href='/account'
|
||||
>
|
||||
account
|
||||
</Nav.item>
|
||||
<Nav.item
|
||||
className='logout'
|
||||
color='red'
|
||||
icon='fas fa-power-off'
|
||||
onClick={this.handleLogout}
|
||||
>
|
||||
logout
|
||||
</Nav.item>
|
||||
</Nav.dropdown>;
|
||||
}
|
||||
|
||||
// Logged out
|
||||
// LOCAL ONLY
|
||||
if(global.config?.local) {
|
||||
return <Nav.item color='teal' icon='fas fa-sign-in-alt' onClick={this.localLogin}>
|
||||
login
|
||||
</Nav.item>;
|
||||
};
|
||||
|
||||
// Logged out
|
||||
// Production site
|
||||
return <Nav.item href={`https://www.naturalcrit.com/login?redirect=${this.state.url}`} color='teal' icon='fas fa-sign-in-alt'>
|
||||
login
|
||||
</Nav.item>;
|
||||
}
|
||||
});
|
||||
|
||||
export default Account;
|
||||
147
client/homebrew/navbar/error-navitem.jsx
Normal file
147
client/homebrew/navbar/error-navitem.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import './error-navitem.less';
|
||||
import React from 'react';
|
||||
import Nav from './nav.jsx';
|
||||
|
||||
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;
|
||||
|
||||
let errMsg = '';
|
||||
try {
|
||||
errMsg += `${error.toString()}\n\n`;
|
||||
errMsg += `\`\`\`\n${error.stack}\n`;
|
||||
errMsg += `${JSON.stringify(response?.error, null, ' ')}\n\`\`\``;
|
||||
console.log(errMsg);
|
||||
} catch {}
|
||||
|
||||
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(HBErrorCode === '13') {
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
||||
Oops!
|
||||
<div className='errorContainer' onClick={clearError}>
|
||||
Server has lost connection to the database.
|
||||
</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
if(errorCode === 'ECONNABORTED') {
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
||||
Oops!
|
||||
<div className='errorContainer' onClick={clearError}>
|
||||
The request to the server was interrupted or timed out.
|
||||
This can happen due to a network issue, or if
|
||||
trying to save a particularly large brew.
|
||||
Please check your internet connection and try again.
|
||||
</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
||||
Oops!
|
||||
<div className='errorContainer'>
|
||||
Looks like there was a problem saving. <br />
|
||||
Report the issue <a target='_blank' rel='noopener noreferrer' href={`https://github.com/naturalcrit/homebrewery/issues/new?template=save_issue.yml&error-code=${encodeURIComponent(errMsg)}`}>
|
||||
here
|
||||
</a>.
|
||||
</div>
|
||||
</Nav.item>;
|
||||
};
|
||||
|
||||
export default ErrorNavItem;
|
||||
72
client/homebrew/navbar/error-navitem.less
Normal file
72
client/homebrew/navbar/error-navitem.less
Normal file
@@ -0,0 +1,72 @@
|
||||
@import './shared/naturalcrit/styles/core.less';
|
||||
|
||||
.navItem.error {
|
||||
position : relative;
|
||||
background-color : @red;
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
position : absolute;
|
||||
top : 100%;
|
||||
left : 50%;
|
||||
z-index : 1000;
|
||||
width : 140px;
|
||||
padding : 3px;
|
||||
font-size : 10px;
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
text-align : center;
|
||||
text-transform : uppercase;
|
||||
background-color : #333333;
|
||||
border : 3px solid #444444;
|
||||
border-radius : 5px;
|
||||
transform : translate(-50% + 3px, 10px);
|
||||
animation-name : glideDown;
|
||||
animation-duration : 0.4s;
|
||||
.lowercase { text-transform : none; }
|
||||
a { color : @teal; }
|
||||
&::before {
|
||||
position : absolute;
|
||||
top : -23px;
|
||||
left : 53px;
|
||||
width : 0px;
|
||||
height : 0px;
|
||||
content : '';
|
||||
border-top : 10px solid transparent;
|
||||
border-right : 10px solid transparent;
|
||||
border-bottom : 10px solid #444444;
|
||||
border-left : 10px solid transparent;
|
||||
}
|
||||
&::after {
|
||||
position : absolute;
|
||||
top : -19px;
|
||||
left : 53px;
|
||||
width : 0px;
|
||||
height : 0px;
|
||||
content : '';
|
||||
border-top : 10px solid transparent;
|
||||
border-right : 10px solid transparent;
|
||||
border-bottom : 10px solid #333333;
|
||||
border-left : 10px solid transparent;
|
||||
}
|
||||
.deny {
|
||||
display : inline-block;
|
||||
width : 48%;
|
||||
padding : 5px;
|
||||
margin : 1px;
|
||||
background-color : #333333;
|
||||
border-left : 1px solid #666666;
|
||||
.animate(background-color);
|
||||
&:hover { background-color : red; }
|
||||
}
|
||||
.confirm {
|
||||
display : inline-block;
|
||||
width : 48%;
|
||||
padding : 5px;
|
||||
margin : 1px;
|
||||
color : white;
|
||||
background-color : #333333;
|
||||
.animate(background-color);
|
||||
&:hover { background-color : teal; }
|
||||
}
|
||||
}
|
||||
34
client/homebrew/navbar/help.navitem.jsx
Normal file
34
client/homebrew/navbar/help.navitem.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import dedent from 'dedent';
|
||||
|
||||
import Nav from './nav.jsx';
|
||||
|
||||
export default function(props){
|
||||
return <Nav.dropdown>
|
||||
<Nav.item color='grey' icon='fas fa-question-circle'>
|
||||
need help?
|
||||
</Nav.item>
|
||||
<Nav.item color='red' icon='fas fa-fw fa-bug'
|
||||
href={`https://www.reddit.com/r/homebrewery/submit?selftext=true&text=${encodeURIComponent(dedent`
|
||||
- **Browser(s)** :
|
||||
- **Operating System** :
|
||||
- **Legacy or v3 Renderer** :
|
||||
- **Issue** : `)}`}
|
||||
newTab={true}
|
||||
rel='noopener noreferrer'>
|
||||
report issue
|
||||
</Nav.item>
|
||||
<Nav.item color='green' icon='fas fa-question-circle'
|
||||
href='/faq'
|
||||
newTab={true}
|
||||
rel='noopener noreferrer'>
|
||||
FAQ
|
||||
</Nav.item>
|
||||
<Nav.item color='blue' icon='fas fa-fw fa-file-import'
|
||||
href='/migrate'
|
||||
newTab={true}
|
||||
rel='noopener noreferrer'>
|
||||
migrate
|
||||
</Nav.item>
|
||||
</Nav.dropdown>;
|
||||
};
|
||||
89
client/homebrew/navbar/metadata.navitem.jsx
Normal file
89
client/homebrew/navbar/metadata.navitem.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import Moment from 'moment';
|
||||
|
||||
import Nav from './nav.jsx';
|
||||
|
||||
|
||||
const MetadataNav = createReactClass({
|
||||
displayName : 'MetadataNav',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
showMetaWindow : false
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount : function() {
|
||||
},
|
||||
|
||||
toggleMetaWindow : function(){
|
||||
this.setState((prevProps)=>({
|
||||
showMetaWindow : !prevProps.showMetaWindow
|
||||
}));
|
||||
},
|
||||
|
||||
getAuthors : function(){
|
||||
if(!this.props.brew.authors || this.props.brew.authors.length == 0) return 'No authors';
|
||||
return <>
|
||||
{this.props.brew.authors.map((author, idx, arr)=>{
|
||||
const spacer = arr.length - 1 == idx ? <></> : <span>, </span>;
|
||||
return <span key={idx}><a className='userPageLink' href={`/user/${encodeURIComponent(author)}`}>{author}</a>{spacer}</span>;
|
||||
})}
|
||||
</>;
|
||||
},
|
||||
|
||||
getTags : function(){
|
||||
if(!this.props.brew.tags || this.props.brew.tags.length == 0) return 'No tags';
|
||||
return <>
|
||||
{this.props.brew.tags.map((tag, idx)=>{
|
||||
return <span className='tag' key={idx}>{tag}</span>;
|
||||
})}
|
||||
</>;
|
||||
},
|
||||
|
||||
getSystems : function(){
|
||||
if(!this.props.brew.systems || this.props.brew.systems.length == 0) return 'No systems';
|
||||
return this.props.brew.systems.join(', ');
|
||||
},
|
||||
|
||||
renderMetaWindow : function(){
|
||||
return <div className={`window ${this.state.showMetaWindow ? 'active' : 'inactive'}`}>
|
||||
<div className='row'>
|
||||
<h4>Description</h4>
|
||||
<p>{this.props.brew.description || 'No description.'}</p>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<h4>Authors</h4>
|
||||
<p>{this.getAuthors()}</p>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<h4>Tags</h4>
|
||||
<p>{this.getTags()}</p>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<h4>Systems</h4>
|
||||
<p>{this.getSystems()}</p>
|
||||
</div>
|
||||
<div className='row'>
|
||||
<h4>Updated</h4>
|
||||
<p>{Moment(this.props.brew.updatedAt).fromNow()}</p>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
|
||||
render : function(){
|
||||
return <Nav.item icon='fas fa-info-circle' color='grey' className='metadata'
|
||||
onClick={()=>this.toggleMetaWindow()}>
|
||||
{this.props.children}
|
||||
{this.renderMetaWindow()}
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default MetadataNav;
|
||||
119
client/homebrew/navbar/nav.jsx
Normal file
119
client/homebrew/navbar/nav.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import './navbar.less';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import cx from 'classnames';
|
||||
|
||||
import NaturalCritIcon from '../../components/svg/naturalcrit-d20.svg.jsx';
|
||||
|
||||
const Nav = {
|
||||
base : createReactClass({
|
||||
displayName : 'Nav.base',
|
||||
render : function(){
|
||||
return <nav>
|
||||
{this.props.children}
|
||||
</nav>;
|
||||
}
|
||||
}),
|
||||
logo : function(){
|
||||
return <a className='navLogo' href='https://www.naturalcrit.com'>
|
||||
<NaturalCritIcon />
|
||||
<span className='name'>
|
||||
Natural<span className='crit'>Crit</span>
|
||||
</span>
|
||||
</a>;
|
||||
},
|
||||
|
||||
section : createReactClass({
|
||||
displayName : 'Nav.section',
|
||||
render : function(){
|
||||
return <div className={`navSection ${this.props.className ?? ''}`}>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
}
|
||||
}),
|
||||
|
||||
item : createReactClass({
|
||||
displayName : 'Nav.item',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
icon : null,
|
||||
href : null,
|
||||
newTab : false,
|
||||
onClick : function(){},
|
||||
color : null
|
||||
};
|
||||
},
|
||||
handleClick : function(e){
|
||||
this.props.onClick(e);
|
||||
},
|
||||
render : function(){
|
||||
const classes = cx('navItem', this.props.color, this.props.className);
|
||||
|
||||
let icon;
|
||||
if(this.props.icon) icon = <i className={this.props.icon} />;
|
||||
|
||||
const props = _.omit(this.props, ['newTab']);
|
||||
|
||||
if(this.props.href){
|
||||
return <a {...props} className={classes} target={this.props.newTab ? '_blank' : '_self'} >
|
||||
{this.props.children}
|
||||
{icon}
|
||||
</a>;
|
||||
} else {
|
||||
return <div {...props} className={classes} onClick={this.handleClick} >
|
||||
{this.props.children}
|
||||
{icon}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
dropdown : function dropdown(props) {
|
||||
props = Object.assign({}, props, {
|
||||
trigger : 'hover click'
|
||||
});
|
||||
|
||||
const myRef = useRef(null);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
useEffect(()=>{
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return ()=>{
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleClickOutside(e) {
|
||||
// Close dropdown when clicked outside
|
||||
if(!myRef.current?.contains(e.target)) {
|
||||
handleDropdown(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDropdown(show) {
|
||||
setShowDropdown(show ?? !showDropdown);
|
||||
}
|
||||
|
||||
const dropdownChildren = React.Children.map(props.children, (child, i)=>{
|
||||
if(i < 1) return;
|
||||
return child;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`navDropdownContainer ${props.className ?? ''}`}
|
||||
ref={myRef}
|
||||
onMouseEnter = { props.trigger.includes('hover') ? ()=>handleDropdown(true) : undefined }
|
||||
onMouseLeave = { props.trigger.includes('hover') ? ()=>handleDropdown(false) : undefined }
|
||||
onClick = { props.trigger.includes('click') ? ()=>handleDropdown(true) : undefined }
|
||||
>
|
||||
{props.children[0] || props.children /*children is not an array when only one child*/}
|
||||
{showDropdown && <div className='navDropdown'>{dropdownChildren}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
export default Nav;
|
||||
46
client/homebrew/navbar/navbar.jsx
Normal file
46
client/homebrew/navbar/navbar.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import './navbar.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
import Nav from './nav.jsx';
|
||||
import PatreonNavItem from './patreon.navitem.jsx';
|
||||
|
||||
const Navbar = createReactClass({
|
||||
displayName : 'Navbar',
|
||||
getInitialState: function() {
|
||||
return {
|
||||
// showNonChromeWarning: false, // uncomment if needed
|
||||
ver: global.version || '0.0.0'
|
||||
};
|
||||
},
|
||||
|
||||
/*
|
||||
renderChromeWarning : function(){
|
||||
if(!this.state.showNonChromeWarning) return;
|
||||
return <Nav.item className='warning' icon='fa-exclamation-triangle'>
|
||||
Optimized for Chrome
|
||||
<div className='dropdown'>
|
||||
If you are experiencing rendering issues, use Chrome instead
|
||||
</div>
|
||||
</Nav.item>
|
||||
},
|
||||
*/
|
||||
render : function(){
|
||||
return <Nav.base>
|
||||
<Nav.section>
|
||||
<Nav.logo />
|
||||
<Nav.item href='/' className='homebrewLogo'>
|
||||
<div>The Homebrewery</div>
|
||||
</Nav.item>
|
||||
<Nav.item newTab={true} href='/changelog' color='purple' icon='far fa-file-alt'>
|
||||
{`v${this.state.ver}`}
|
||||
</Nav.item>
|
||||
<PatreonNavItem />
|
||||
{/*this.renderChromeWarning()*/}
|
||||
</Nav.section>
|
||||
{this.props.children}
|
||||
</Nav.base>;
|
||||
}
|
||||
});
|
||||
|
||||
export default Navbar;
|
||||
346
client/homebrew/navbar/navbar.less
Normal file
346
client/homebrew/navbar/navbar.less
Normal file
@@ -0,0 +1,346 @@
|
||||
@import './shared/naturalcrit/styles/core.less';
|
||||
|
||||
@navbarHeight : 28px;
|
||||
@viewerToolsHeight : 32px;
|
||||
|
||||
@keyframes pinkColoring {
|
||||
0% { color : pink; }
|
||||
50% { color : pink; }
|
||||
75% { color : red; }
|
||||
100% { color : pink; }
|
||||
}
|
||||
|
||||
@keyframes glideDropDown {
|
||||
0% {
|
||||
background-color : #333333;
|
||||
opacity : 0;
|
||||
transform : translate(0px, -100%);
|
||||
}
|
||||
100% {
|
||||
background-color : #333333;
|
||||
opacity : 1;
|
||||
transform : translate(0px, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.homebrew nav {
|
||||
position : relative;
|
||||
z-index : 2;
|
||||
display : flex;
|
||||
justify-content : space-between;
|
||||
background-color : #333333;
|
||||
|
||||
.navSection {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
&:last-child .navItem { border-left : 1px solid #666666; }
|
||||
|
||||
&:has(.brewTitle) {
|
||||
flex-grow : 1;
|
||||
min-width : 300px;
|
||||
}
|
||||
>.brewTitle {
|
||||
cursor:auto;
|
||||
}
|
||||
}
|
||||
// "NaturalCrit" logo
|
||||
.navLogo {
|
||||
display : block;
|
||||
margin-top : 0px;
|
||||
margin-right : 8px;
|
||||
margin-left : 8px;
|
||||
color : white;
|
||||
text-decoration : none;
|
||||
&:hover {
|
||||
.name { color : @orange; }
|
||||
svg { fill : @orange; }
|
||||
}
|
||||
svg {
|
||||
height : 13px;
|
||||
margin-right : 0.2em;
|
||||
cursor : pointer;
|
||||
fill : white;
|
||||
}
|
||||
span.name {
|
||||
font-family : 'CodeLight';
|
||||
font-size : 15px;
|
||||
span.crit { font-family : 'CodeBold'; }
|
||||
small {
|
||||
font-family : 'Open Sans';
|
||||
font-size : 0.3em;
|
||||
font-weight : 800;
|
||||
text-transform : uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
.navItem {
|
||||
#backgroundColorsHover;
|
||||
.animate(background-color);
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
height : 100%;
|
||||
padding : 8px 12px;
|
||||
font-size : 10px;
|
||||
font-weight : 800;
|
||||
line-height : 13px;
|
||||
color : white;
|
||||
text-transform : uppercase;
|
||||
text-decoration : none;
|
||||
cursor : pointer;
|
||||
background-color : #333333;
|
||||
i {
|
||||
float : right;
|
||||
margin-left : 5px;
|
||||
font-size : 13px;
|
||||
}
|
||||
&.patreon {
|
||||
border-right : 1px solid #666666;
|
||||
border-left : 1px solid #666666;
|
||||
&:hover i { color : red; }
|
||||
i {
|
||||
color : pink;
|
||||
.animate(color);
|
||||
animation-name : pinkColoring;
|
||||
animation-duration : 2s;
|
||||
}
|
||||
}
|
||||
&.brewTitle {
|
||||
display : block;
|
||||
width : 100%;
|
||||
overflow : hidden;
|
||||
text-overflow : ellipsis;
|
||||
font-size : 12px;
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
text-align : center;
|
||||
text-transform : initial;
|
||||
white-space : nowrap;
|
||||
background-color : transparent;
|
||||
}
|
||||
|
||||
// "The Homebrewery" logo
|
||||
&.homebrewLogo {
|
||||
.animate(color);
|
||||
font-family : 'CodeBold';
|
||||
font-size : 12px;
|
||||
color : white;
|
||||
div {
|
||||
margin-top : 2px;
|
||||
margin-bottom : -2px;
|
||||
}
|
||||
&:hover { color : @blue; }
|
||||
}
|
||||
&.metadata {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-grow : 1;
|
||||
align-items : center;
|
||||
height : 100%;
|
||||
padding : 0;
|
||||
i { margin-right : 10px;}
|
||||
.window {
|
||||
position : absolute;
|
||||
bottom : 0;
|
||||
left : 50%;
|
||||
z-index : -1;
|
||||
display : flex;
|
||||
flex-flow : row wrap;
|
||||
align-content : baseline;
|
||||
justify-content : flex-start;
|
||||
width : 440px;
|
||||
max-height : ~'calc(100vh - 28px)';
|
||||
padding : 0 10px 5px;
|
||||
margin : 0 auto;
|
||||
background-color : #333333;
|
||||
border : 3px solid #444444;
|
||||
border-top : unset;
|
||||
border-radius : 0 0 5px 5px;
|
||||
box-shadow : inset 0 7px 9px -7px #111111;
|
||||
transition : transform 0.4s, opacity 0.4s;
|
||||
&.active {
|
||||
opacity : 1;
|
||||
transform : translateX(-50%) translateY(100%);
|
||||
}
|
||||
&.inactive {
|
||||
opacity : 0;
|
||||
transform : translateX(-50%) translateY(0%);
|
||||
}
|
||||
.row {
|
||||
display : flex;
|
||||
flex-flow : row wrap;
|
||||
width : 100%;
|
||||
h4 {
|
||||
box-sizing : border-box;
|
||||
display : block;
|
||||
flex-grow : 1;
|
||||
flex-basis : 20%;
|
||||
min-width : 76px;
|
||||
padding : 5px 0;
|
||||
color : #BBBBBB;
|
||||
text-align : center;
|
||||
}
|
||||
p {
|
||||
flex-grow : 1;
|
||||
flex-basis : 80%;
|
||||
padding : 5px 0;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 10px;
|
||||
font-weight : normal;
|
||||
text-transform : initial;
|
||||
.tag {
|
||||
display : inline-block;
|
||||
padding : 2px;
|
||||
margin : 2px 2px;
|
||||
background-color : #444444;
|
||||
border : 2px solid grey;
|
||||
border-radius : 5px;
|
||||
}
|
||||
a.userPageLink {
|
||||
color : white;
|
||||
text-decoration : none;
|
||||
&:hover { text-decoration : underline; }
|
||||
}
|
||||
}
|
||||
&:nth-of-type(even) { background-color : #555555; }
|
||||
}
|
||||
}
|
||||
}
|
||||
&.warning {
|
||||
position : relative;
|
||||
color : white;
|
||||
background-color : @orange;
|
||||
&:hover > .dropdown { visibility : visible; }
|
||||
.dropdown {
|
||||
position : absolute;
|
||||
top : 28px;
|
||||
left : 0;
|
||||
z-index : 10000;
|
||||
box-sizing : border-box;
|
||||
display : block;
|
||||
visibility : hidden;
|
||||
width : 100%;
|
||||
padding : 13px 5px;
|
||||
text-align : center;
|
||||
background-color : #333333;
|
||||
}
|
||||
}
|
||||
&.account {
|
||||
min-width : 100px;
|
||||
&.username { text-transform : none;}
|
||||
}
|
||||
}
|
||||
.navDropdownContainer {
|
||||
position : relative;
|
||||
height : 100%;
|
||||
|
||||
.navDropdown {
|
||||
position : absolute;
|
||||
//top: 28px;
|
||||
right : 0px;
|
||||
z-index : 10000;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
align-items : flex-end;
|
||||
width : max-content;
|
||||
min-width : 100%;
|
||||
max-height : calc(100vh - 28px);
|
||||
overflow : hidden auto;
|
||||
.navItem {
|
||||
position : relative;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : space-between;
|
||||
width : 100%;
|
||||
border : 1px solid #888888;
|
||||
border-bottom : 0;
|
||||
animation-name : glideDropDown;
|
||||
animation-duration : 0.4s;
|
||||
}
|
||||
}
|
||||
&.recent {
|
||||
position : relative;
|
||||
.navDropdown .navItem {
|
||||
#backgroundColorsHover;
|
||||
.animate(background-color);
|
||||
position : relative;
|
||||
box-sizing : border-box;
|
||||
display : block;
|
||||
max-width : 15em;
|
||||
max-height : ~'calc(100vh - 28px)';
|
||||
padding : 8px 5px 13px;
|
||||
overflow : hidden auto;
|
||||
color : white;
|
||||
text-decoration : none;
|
||||
scrollbar-color : #666666 #333333;
|
||||
scrollbar-width : thin;
|
||||
background-color : #333333;
|
||||
border-top : 1px solid #888888;
|
||||
.clear {
|
||||
position : absolute;
|
||||
top : 50%;
|
||||
right : 0;
|
||||
display : none;
|
||||
width : 20px;
|
||||
height : 100%;
|
||||
background-color : #333333;
|
||||
border-radius : 3px;
|
||||
opacity : 70%;
|
||||
transform : translateY(-50%);
|
||||
&:hover { opacity : 100%; }
|
||||
i {
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
margin : 0;
|
||||
font-size : 10px;
|
||||
text-align : center;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color : @blue;
|
||||
.clear {
|
||||
display : grid;
|
||||
place-content : center;
|
||||
}
|
||||
}
|
||||
.title {
|
||||
display : inline-block;
|
||||
width : 100%;
|
||||
overflow : hidden auto;
|
||||
text-overflow : ellipsis;
|
||||
white-space : nowrap;
|
||||
}
|
||||
.time {
|
||||
position : absolute;
|
||||
right : 2px;
|
||||
bottom : 2px;
|
||||
font-size : 0.7em;
|
||||
color : #888888;
|
||||
}
|
||||
&.header {
|
||||
box-sizing : border-box;
|
||||
display : block;
|
||||
padding : 5px 0;
|
||||
color : #BBBBBB;
|
||||
text-align : center;
|
||||
background-color : #333333;
|
||||
border-top : 1px solid #888888;
|
||||
&:nth-of-type(1) { background-color : darken(@teal, 20%); }
|
||||
&:nth-of-type(2) { background-color : darken(@purple, 30%); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this should likely be refactored into .navDropdownContainer
|
||||
.save-menu {
|
||||
.dropdown { z-index : 1000; }
|
||||
.navItem i.fa-power-off {
|
||||
color : red;
|
||||
&.active {
|
||||
color : rgb(0, 182, 52);
|
||||
filter : drop-shadow(0 0 2px rgba(0, 182, 52, 0.765));
|
||||
}
|
||||
}
|
||||
}
|
||||
103
client/homebrew/navbar/newbrew.navitem.jsx
Normal file
103
client/homebrew/navbar/newbrew.navitem.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import Nav from './nav.jsx';
|
||||
import { splitTextStyleAndMetadata } from '../../../shared/helpers.js';
|
||||
|
||||
const BREWKEY = 'HB_newPage_content';
|
||||
const STYLEKEY = 'HB_newPage_style';
|
||||
const METAKEY = 'HB_newPage_meta';
|
||||
|
||||
const NewBrew = ()=>{
|
||||
const handleFileChange = (e)=>{
|
||||
const file = e.target.files[0];
|
||||
if(!file) return;
|
||||
|
||||
if(!confirmLocalStorageChange()) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e)=>{
|
||||
const fileContent = e.target.result;
|
||||
const newBrew = { text: fileContent, style: '' };
|
||||
|
||||
if(fileContent.startsWith('```metadata')) {
|
||||
splitTextStyleAndMetadata(newBrew);
|
||||
localStorage.setItem(BREWKEY, newBrew.text);
|
||||
localStorage.setItem(STYLEKEY, newBrew.style);
|
||||
localStorage.setItem(METAKEY, JSON.stringify(
|
||||
_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang'])
|
||||
));
|
||||
window.location.href = '/new';
|
||||
return;
|
||||
}
|
||||
|
||||
const type = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
alert(`This file is invalid: ${!type ? 'Missing file extension' :`.${type} files are not supported`}. Only .txt files exported from the Homebrewery are allowed.`);
|
||||
|
||||
console.log(file);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const confirmLocalStorageChange = ()=>{
|
||||
const currentText = localStorage.getItem(BREWKEY);
|
||||
const currentStyle = localStorage.getItem(STYLEKEY);
|
||||
const currentMeta = localStorage.getItem(METAKEY);
|
||||
|
||||
// TRUE if no data in any local storage key
|
||||
// TRUE if data in any local storage key AND approval given
|
||||
// FALSE if data in any local storage key AND approval declined
|
||||
return (!(currentText || currentStyle || currentMeta) || confirm(
|
||||
`You have made changes in the new brew space. If you continue, that information will be PERMANENTLY LOST.\nAre you sure you wish to continue?`
|
||||
));
|
||||
};
|
||||
|
||||
const clearLocalStorage = ()=>{
|
||||
if(!confirmLocalStorageChange()) return;
|
||||
|
||||
localStorage.removeItem(BREWKEY);
|
||||
localStorage.removeItem(STYLEKEY);
|
||||
localStorage.removeItem(METAKEY);
|
||||
|
||||
window.location.href = '/new';
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Nav.dropdown>
|
||||
<Nav.item
|
||||
className='new'
|
||||
color='purple'
|
||||
icon='fa-solid fa-plus-square'>
|
||||
new
|
||||
</Nav.item>
|
||||
<Nav.item
|
||||
className='new'
|
||||
href='/new'
|
||||
newTab={true}
|
||||
color='purple'
|
||||
icon='fa-solid fa-file'>
|
||||
resume draft
|
||||
</Nav.item>
|
||||
<Nav.item
|
||||
className='fromBlank'
|
||||
newTab={true}
|
||||
color='yellow'
|
||||
icon='fa-solid fa-file-circle-plus'
|
||||
onClick={()=>{ clearLocalStorage(); }}>
|
||||
from blank
|
||||
</Nav.item>
|
||||
<Nav.item
|
||||
className='fromFile'
|
||||
color='green'
|
||||
icon='fa-solid fa-upload'
|
||||
onClick={()=>{ document.getElementById('uploadTxt').click(); }}>
|
||||
<input id='uploadTxt' className='newFromLocal' type='file' onChange={handleFileChange} style={{ display: 'none' }} />
|
||||
from file
|
||||
</Nav.item>
|
||||
</Nav.dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewBrew;
|
||||
13
client/homebrew/navbar/patreon.navitem.jsx
Normal file
13
client/homebrew/navbar/patreon.navitem.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import Nav from './nav.jsx';
|
||||
|
||||
export default function(props){
|
||||
return <Nav.item
|
||||
className='patreon'
|
||||
newTab={true}
|
||||
href='https://www.patreon.com/NaturalCrit'
|
||||
color='green'
|
||||
icon='fas fa-heart'>
|
||||
help out
|
||||
</Nav.item>;
|
||||
};
|
||||
9
client/homebrew/navbar/print.navitem.jsx
Normal file
9
client/homebrew/navbar/print.navitem.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import Nav from './nav.jsx';
|
||||
import { printCurrentBrew } from '../../../shared/helpers.js';
|
||||
|
||||
export default function(){
|
||||
return <Nav.item onClick={printCurrentBrew} color='purple' icon='far fa-file-pdf'>
|
||||
get PDF
|
||||
</Nav.item>;
|
||||
};
|
||||
207
client/homebrew/navbar/recent.navitem.jsx
Normal file
207
client/homebrew/navbar/recent.navitem.jsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import Moment from 'moment';
|
||||
|
||||
import Nav from './nav.jsx';
|
||||
|
||||
const EDIT_KEY = 'HB_nav_recentlyEdited';
|
||||
const VIEW_KEY = 'HB_nav_recentlyViewed';
|
||||
|
||||
|
||||
const RecentItems = createReactClass({
|
||||
DisplayName : 'RecentItems',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
storageKey : '',
|
||||
showEdit : false,
|
||||
showView : false
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
showDropdown : false,
|
||||
edit : [],
|
||||
view : []
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount : function() {
|
||||
|
||||
//== Load recent items list ==//
|
||||
let edited = JSON.parse(localStorage.getItem(EDIT_KEY) || '[]');
|
||||
let viewed = JSON.parse(localStorage.getItem(VIEW_KEY) || '[]');
|
||||
|
||||
//== Add current brew to appropriate recent items list (depending on storageKey) ==//
|
||||
if(this.props.storageKey == 'edit'){
|
||||
let editId = this.props.brew.editId;
|
||||
if(this.props.brew.googleId && !this.props.brew.stubbed){
|
||||
editId = `${this.props.brew.googleId}${this.props.brew.editId}`;
|
||||
}
|
||||
edited = _.filter(edited, (brew)=>{
|
||||
return brew.id !== editId;
|
||||
});
|
||||
edited.unshift({
|
||||
id : editId,
|
||||
title : this.props.brew.title,
|
||||
url : `/edit/${editId}`,
|
||||
ts : Date.now()
|
||||
});
|
||||
}
|
||||
if(this.props.storageKey == 'view'){
|
||||
let shareId = this.props.brew.shareId;
|
||||
if(this.props.brew.googleId && !this.props.brew.stubbed){
|
||||
shareId = `${this.props.brew.googleId}${this.props.brew.shareId}`;
|
||||
}
|
||||
viewed = _.filter(viewed, (brew)=>{
|
||||
return brew.id !== shareId;
|
||||
});
|
||||
viewed.unshift({
|
||||
id : shareId,
|
||||
title : this.props.brew.title,
|
||||
url : `/share/${shareId}`,
|
||||
ts : Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
//== Store the updated lists (up to 8 items each) ==//
|
||||
edited = _.slice(edited, 0, 8);
|
||||
viewed = _.slice(viewed, 0, 8);
|
||||
|
||||
localStorage.setItem(EDIT_KEY, JSON.stringify(edited));
|
||||
localStorage.setItem(VIEW_KEY, JSON.stringify(viewed));
|
||||
|
||||
this.setState({
|
||||
edit : edited,
|
||||
view : viewed
|
||||
});
|
||||
},
|
||||
|
||||
componentDidUpdate : function(prevProps) {
|
||||
if(prevProps.brew && this.props.brew.editId !== prevProps.brew.editId) {
|
||||
let edited = JSON.parse(localStorage.getItem(EDIT_KEY) || '[]');
|
||||
if(this.props.storageKey == 'edit') {
|
||||
let prevEditId = prevProps.brew.editId;
|
||||
if(prevProps.brew.googleId && !this.props.brew.stubbed){
|
||||
prevEditId = `${prevProps.brew.googleId}${prevProps.brew.editId}`;
|
||||
}
|
||||
|
||||
edited = _.filter(this.state.edit, (brew)=>{
|
||||
return brew.id !== prevEditId;
|
||||
});
|
||||
let editId = this.props.brew.editId;
|
||||
if(this.props.brew.googleId && !this.props.brew.stubbed){
|
||||
editId = `${this.props.brew.googleId}${this.props.brew.editId}`;
|
||||
}
|
||||
edited.unshift({
|
||||
id : editId,
|
||||
title : this.props.brew.title,
|
||||
url : `/edit/${editId}`,
|
||||
ts : Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
//== Store the updated lists (up to 8 items each) ==//
|
||||
edited = _.slice(edited, 0, 8);
|
||||
|
||||
localStorage.setItem(EDIT_KEY, JSON.stringify(edited));
|
||||
|
||||
this.setState({
|
||||
edit : edited
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleDropdown : function(show){
|
||||
this.setState({
|
||||
showDropdown : show
|
||||
});
|
||||
},
|
||||
|
||||
removeItem : function(url, evt){
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
let edited = JSON.parse(localStorage.getItem(EDIT_KEY) || '[]');
|
||||
let viewed = JSON.parse(localStorage.getItem(VIEW_KEY) || '[]');
|
||||
|
||||
edited = edited.filter((item)=>{ return (item.url !== url);});
|
||||
viewed = viewed.filter((item)=>{ return (item.url !== url);});
|
||||
|
||||
localStorage.setItem(EDIT_KEY, JSON.stringify(edited));
|
||||
localStorage.setItem(VIEW_KEY, JSON.stringify(viewed));
|
||||
|
||||
this.setState({
|
||||
edit : edited,
|
||||
view : viewed
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
renderDropdown : function(){
|
||||
// if(!this.state.showDropdown) return null;
|
||||
|
||||
const makeItems = (brews)=>{
|
||||
return _.map(brews, (brew, i)=>{
|
||||
return <a className='navItem' href={brew.url} key={`${brew.id}-${i}`} target='_blank' rel='noopener noreferrer' title={brew.title || '[ no title ]'}>
|
||||
<span className='title'>{brew.title || '[ no title ]'}</span>
|
||||
<span className='time'>{Moment(brew.ts).fromNow()}</span>
|
||||
<div className='clear' title='Remove from Recents' onClick={(e)=>{this.removeItem(`${brew.url}`, e);}}><i className='fas fa-times'></i></div>
|
||||
</a>;
|
||||
});
|
||||
};
|
||||
|
||||
return <>
|
||||
{(this.props.showEdit && this.props.showView) ?
|
||||
<Nav.item className='header'>edited</Nav.item> : null }
|
||||
{this.props.showEdit ?
|
||||
makeItems(this.state.edit) : null }
|
||||
{(this.props.showEdit && this.props.showView) ?
|
||||
<Nav.item className='header'>viewed</Nav.item> : null }
|
||||
{this.props.showView ?
|
||||
makeItems(this.state.view) : null }
|
||||
</>;
|
||||
},
|
||||
|
||||
render : function(){
|
||||
return <Nav.dropdown className='recent'>
|
||||
<Nav.item icon='fas fa-history' color='grey' >
|
||||
{this.props.text}
|
||||
</Nav.item>
|
||||
{this.renderDropdown()}
|
||||
</Nav.dropdown>;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default {
|
||||
|
||||
edited : (props)=>{
|
||||
return <RecentItems
|
||||
brew={props.brew}
|
||||
storageKey={props.storageKey}
|
||||
text='recently edited'
|
||||
showEdit={true}
|
||||
/>;
|
||||
},
|
||||
|
||||
viewed : (props)=>{
|
||||
return <RecentItems
|
||||
brew={props.brew}
|
||||
storageKey={props.storageKey}
|
||||
text='recently viewed'
|
||||
showView={true}
|
||||
/>;
|
||||
},
|
||||
|
||||
both : (props)=>{
|
||||
return <RecentItems
|
||||
brew={props.brew}
|
||||
storageKey={props.storageKey}
|
||||
text='recent brews'
|
||||
showEdit={true}
|
||||
showView={true}
|
||||
/>;
|
||||
}
|
||||
};
|
||||
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';
|
||||
import Nav from './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>
|
||||
);
|
||||
17
client/homebrew/navbar/vault.navitem.jsx
Normal file
17
client/homebrew/navbar/vault.navitem.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import Nav from './nav.jsx';
|
||||
|
||||
export default function (props) {
|
||||
return (
|
||||
<Nav.item
|
||||
color='purple'
|
||||
icon='fas fa-dungeon'
|
||||
href='/vault'
|
||||
newTab={false}
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
Vault
|
||||
</Nav.item>
|
||||
);
|
||||
};
|
||||
82
client/homebrew/pages/accountPage/accountPage.jsx
Normal file
82
client/homebrew/pages/accountPage/accountPage.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import UIPage from '../basePages/uiPage/uiPage.jsx';
|
||||
import NaturalCritIcon from '../../../components/svg/naturalcrit-d20.svg.jsx';
|
||||
|
||||
let SAVEKEY = '';
|
||||
|
||||
const AccountPage = (props)=>{
|
||||
// destructure props and set state for save location
|
||||
const { accountDetails, brew } = props;
|
||||
const [saveLocation, setSaveLocation] = React.useState('');
|
||||
|
||||
// initialize save location from local storage based on user id
|
||||
React.useEffect(()=>{
|
||||
if(!saveLocation && 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');
|
||||
setActiveSaveLocation(saveLocation);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setActiveSaveLocation = (newSelection)=>{
|
||||
if(saveLocation === newSelection) return;
|
||||
window.localStorage.setItem(SAVEKEY, newSelection);
|
||||
setSaveLocation(newSelection);
|
||||
};
|
||||
|
||||
// todo: should this be a set of radio buttons (well styled) since it's either/or choice?
|
||||
const renderSaveLocationButton = (name, key, shouldRender = true)=>{
|
||||
if(!shouldRender) return null;
|
||||
return (
|
||||
<button className={saveLocation === key ? 'active' : ''} onClick={()=>{setActiveSaveLocation(key);}}>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// render the entirety of the account page content
|
||||
const renderAccountPage = ()=>{
|
||||
return (
|
||||
<>
|
||||
<div className='dataGroup'>
|
||||
<h1>Account Information <i className='fas fa-user'></i></h1>
|
||||
<p><strong>Username: </strong>{accountDetails.username || 'No user currently logged in'}</p>
|
||||
<p><strong>Last Login: </strong>{moment(accountDetails.issued).format('dddd, MMMM Do YYYY, h:mm:ss a ZZ') || '-'}</p>
|
||||
</div>
|
||||
<div className='dataGroup'>
|
||||
<h3>Homebrewery Information <NaturalCritIcon /></h3>
|
||||
<p><strong>Brews on Homebrewery: </strong>{accountDetails.mongoCount}</p>
|
||||
</div>
|
||||
<div className='dataGroup'>
|
||||
<h3>Google Information <i className='fab fa-google-drive'></i></h3>
|
||||
<p><strong>Linked to Google: </strong>{accountDetails.googleId ? 'YES' : 'NO'}</p>
|
||||
{accountDetails.googleId && (
|
||||
<p>
|
||||
<strong>Brews on Google Drive: </strong>{accountDetails.googleCount ?? (
|
||||
<>
|
||||
Unable to retrieve files - <a href='https://github.com/naturalcrit/homebrewery/discussions/1580'>follow these steps to renew your Google credentials.</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='dataGroup'>
|
||||
<h4>Default Save Location</h4>
|
||||
{renderSaveLocationButton('Homebrewery', 'HOMEBREWERY')}
|
||||
{renderSaveLocationButton('Google Drive', 'GOOGLE-DRIVE', accountDetails.googleId)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// return the account page inside the base layout wrapper (with navbar etc).
|
||||
return (
|
||||
<UIPage brew={brew}>
|
||||
{renderAccountPage()}
|
||||
</UIPage>);
|
||||
};
|
||||
|
||||
export default AccountPage;
|
||||
178
client/homebrew/pages/basePages/listPage/brewItem/brewItem.jsx
Normal file
178
client/homebrew/pages/basePages/listPage/brewItem/brewItem.jsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import './brewItem.less';
|
||||
import React, { useCallback } from 'react';
|
||||
import moment from 'moment';
|
||||
import request from '../../../../utils/request-middleware.js';
|
||||
|
||||
import googleDriveIcon from '../../../../googleDrive.svg';
|
||||
import homebreweryIcon from '../../../../thumbnail.svg';
|
||||
import dedent from 'dedent';
|
||||
|
||||
const BrewItem = ({
|
||||
brew = {
|
||||
title : '',
|
||||
description : '',
|
||||
authors : [],
|
||||
stubbed : true,
|
||||
},
|
||||
updateListFilter = ()=>{},
|
||||
reportError = ()=>{},
|
||||
renderStorage = true,
|
||||
})=>{
|
||||
|
||||
const deleteBrew = useCallback(()=>{
|
||||
if(brew.authors.length <= 1) {
|
||||
if(!window.confirm('Are you sure you want to delete this brew? Because you are the only owner of this brew, the document will be deleted permanently.')) return;
|
||||
if(!window.confirm('Are you REALLY sure? You will not be able to recover the document.')) return;
|
||||
} else {
|
||||
if(!window.confirm('Are you sure you want to remove this brew from your collection? This will remove you as an editor, but other owners will still be able to access the document.')) return;
|
||||
if(!window.confirm('Are you REALLY sure? You will lose editor access to this document.')) return;
|
||||
}
|
||||
|
||||
request.delete(`/api/${brew.googleId ?? ''}${brew.editId}`).send().end((err, res)=>{
|
||||
if(err) reportError(err); else window.location.reload();
|
||||
});
|
||||
}, [brew, reportError]);
|
||||
|
||||
const updateFilter = useCallback((type, term)=>updateListFilter(type, term), [updateListFilter]);
|
||||
|
||||
const renderDeleteBrewLink = ()=>{
|
||||
if(!brew.editId) return null;
|
||||
|
||||
return (
|
||||
<a className='deleteLink' onClick={deleteBrew}>
|
||||
<i className='fas fa-trash-alt' title='Delete' />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEditLink = ()=>{
|
||||
if(!brew.editId) return null;
|
||||
|
||||
let editLink = brew.editId;
|
||||
if(brew.googleId && !brew.stubbed) editLink = brew.googleId + editLink;
|
||||
|
||||
return (
|
||||
<a className='editLink' href={`/edit/${editLink}`} target='_blank' rel='noopener noreferrer'>
|
||||
<i className='fas fa-pencil-alt' title='Edit' />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const renderShareLink = ()=>{
|
||||
if(!brew.shareId) return null;
|
||||
|
||||
let shareLink = brew.shareId;
|
||||
if(brew.googleId && !brew.stubbed) {
|
||||
shareLink = brew.googleId + shareLink;
|
||||
}
|
||||
|
||||
return (
|
||||
<a className='shareLink' href={`/share/${shareLink}`} target='_blank' rel='noopener noreferrer'>
|
||||
<i className='fas fa-share-alt' title='Share' />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDownloadLink = ()=>{
|
||||
if(!brew.shareId) return null;
|
||||
|
||||
let shareLink = brew.shareId;
|
||||
if(brew.googleId && !brew.stubbed) {
|
||||
shareLink = brew.googleId + shareLink;
|
||||
}
|
||||
|
||||
return (
|
||||
<a className='downloadLink' href={`/download/${shareLink}`}>
|
||||
<i className='fas fa-download' title='Download' />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStorageIcon = ()=>{
|
||||
if(!renderStorage) return null;
|
||||
if(brew.googleId) {
|
||||
return (
|
||||
<span title={brew.webViewLink ? 'Your Google Drive Storage' : 'Another User\'s Google Drive Storage'}>
|
||||
<a href={brew.webViewLink} target='_blank'>
|
||||
<img className='googleDriveIcon' src={googleDriveIcon} alt='googleDriveIcon' />
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span title='Homebrewery Storage'>
|
||||
<img className='homebreweryIcon' src={homebreweryIcon} alt='homebreweryIcon' />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if(Array.isArray(brew.tags)) {
|
||||
brew.tags = brew.tags?.filter((tag)=>tag); // remove tags that are empty strings
|
||||
brew.tags.sort((a, b)=>{
|
||||
return a.indexOf(':') - b.indexOf(':') !== 0 ? a.indexOf(':') - b.indexOf(':') : a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
const dateFormatString = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
return (
|
||||
<div className='brewItem'>
|
||||
{brew.thumbnail && <div className='thumbnail' style={{ backgroundImage: `url(${brew.thumbnail})` }}></div>}
|
||||
<div className='text'>
|
||||
<h2>{brew.title}</h2>
|
||||
<p className='description'>{brew.description}</p>
|
||||
</div>
|
||||
<hr />
|
||||
<div className='info'>
|
||||
{brew.tags?.length ? (
|
||||
<div className='brewTags' title={`${brew.tags.length} tags:\n${brew.tags.join('\n')}`}>
|
||||
<i className='fas fa-tags' />
|
||||
{brew.tags.map((tag, idx)=>{
|
||||
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
|
||||
return <span key={idx} className={matches[1]} onClick={()=>updateFilter(tag)}>{matches[2]}</span>;
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<span title={`Authors:\n${brew.authors?.join('\n')}`}>
|
||||
<i className='fas fa-user' />{' '}
|
||||
{brew.authors?.map((author, index)=>(
|
||||
<React.Fragment key={index}>
|
||||
{author === 'hidden' ? (
|
||||
<span title="Username contained an email address; hidden to protect user's privacy">
|
||||
{author}
|
||||
</span>
|
||||
) : (<a href={`/user/${encodeURIComponent(author)}`}>{author}</a>)}
|
||||
{index < brew.authors.length - 1 && ', '}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
<br />
|
||||
<span title={`Last viewed: ${moment(brew.lastViewed).local().format(dateFormatString)}`}>
|
||||
<i className='fas fa-eye' /> {brew.views}
|
||||
</span>
|
||||
{brew.pageCount && (
|
||||
<span title={`Page count: ${brew.pageCount}`}>
|
||||
<i className='far fa-file' /> {brew.pageCount}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
title={dedent` Created: ${moment(brew.createdAt).local().format(dateFormatString)}
|
||||
Last updated: ${moment(brew.updatedAt).local().format(dateFormatString)}`}
|
||||
>
|
||||
<i className='fas fa-sync-alt' /> {moment(brew.updatedAt).fromNow()}
|
||||
</span>
|
||||
{renderStorageIcon()}
|
||||
</div>
|
||||
|
||||
<div className='links'>
|
||||
{renderShareLink()}
|
||||
{renderEditLink()}
|
||||
{renderDownloadLink()}
|
||||
{renderDeleteBrewLink()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrewItem;
|
||||
130
client/homebrew/pages/basePages/listPage/brewItem/brewItem.less
Normal file
130
client/homebrew/pages/basePages/listPage/brewItem/brewItem.less
Normal file
@@ -0,0 +1,130 @@
|
||||
@import './shared/naturalcrit/styles/core.less';
|
||||
|
||||
.brewItem {
|
||||
position : relative;
|
||||
box-sizing : border-box;
|
||||
display : inline-block;
|
||||
width : 48%;
|
||||
min-height : 105px;
|
||||
padding : 5px 15px 2px 6px;
|
||||
padding-right : 15px;
|
||||
margin-right : 15px;
|
||||
margin-bottom : 15px;
|
||||
overflow : hidden;
|
||||
vertical-align : top;
|
||||
background-color : #CAB2802E;
|
||||
border : 1px solid #C9AD6A;
|
||||
border-radius : 5px;
|
||||
box-shadow : 0px 4px 5px 0px #333333;
|
||||
break-inside : avoid;
|
||||
-webkit-column-break-inside : avoid;
|
||||
page-break-inside : avoid;
|
||||
.thumbnail {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
right : 0;
|
||||
z-index : -1;
|
||||
width : 150px;
|
||||
height : 100%;
|
||||
background-repeat : no-repeat;
|
||||
background-position : right top;
|
||||
background-size : contain;
|
||||
opacity : 50%;
|
||||
-webkit-mask-image : linear-gradient(80deg, #00000000 20%, #005500 40%);
|
||||
mask-image : linear-gradient(80deg, #00000000 20%, #005500 40%);
|
||||
}
|
||||
.text {
|
||||
min-height : 54px;
|
||||
h4 {
|
||||
margin-bottom : 5px;
|
||||
font-size : 2.2em;
|
||||
}
|
||||
}
|
||||
.info {
|
||||
position : initial;
|
||||
bottom : 2px;
|
||||
font-family : "ScalySansRemake";
|
||||
font-size : 1.2em;
|
||||
& > span {
|
||||
margin-right : 12px;
|
||||
line-height : 1.5em;
|
||||
|
||||
a { color : inherit; }
|
||||
}
|
||||
}
|
||||
.brewTags span {
|
||||
display : inline-block;
|
||||
padding : 2px;
|
||||
margin : 2px;
|
||||
font-weight : bold;
|
||||
white-space : nowrap;
|
||||
cursor : pointer;
|
||||
background-color : #C8AC6E3B;
|
||||
border : 1px solid #C8AC6E;
|
||||
border-color : currentColor;
|
||||
border-radius : 4px;
|
||||
&::before {
|
||||
margin-right : 3px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 12px;
|
||||
}
|
||||
&.type {
|
||||
color : #008000;
|
||||
background-color : #0080003B;
|
||||
&::before { content : '\f0ad'; }
|
||||
}
|
||||
&.group {
|
||||
color : #000000;
|
||||
background-color : #5050503B;
|
||||
&::before { content : '\f500'; }
|
||||
}
|
||||
&.meta {
|
||||
color : #000080;
|
||||
background-color : #0000803B;
|
||||
&::before { content : '\f05a'; }
|
||||
}
|
||||
&.system {
|
||||
color : #800000;
|
||||
background-color : #8000003B;
|
||||
&::before { content : '\f518'; }
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.links { opacity : 1; }
|
||||
}
|
||||
&:nth-child(2n + 1) { margin-right : 0px; }
|
||||
.links {
|
||||
.animate(opacity);
|
||||
position : absolute;
|
||||
top : 0px;
|
||||
right : 0px;
|
||||
width : 2em;
|
||||
height : 100%;
|
||||
text-align : center;
|
||||
background-color : fade(black, 60%);
|
||||
opacity : 0;
|
||||
a {
|
||||
.animate(opacity);
|
||||
display : block;
|
||||
margin : 8px 0px;
|
||||
font-size : 1.3em;
|
||||
color : white;
|
||||
text-decoration : unset;
|
||||
opacity : 0.6;
|
||||
&:hover { opacity : 1; }
|
||||
i { cursor : pointer; }
|
||||
}
|
||||
}
|
||||
.googleDriveIcon {
|
||||
padding : 0px;
|
||||
margin : -5px;
|
||||
height : 18px;
|
||||
}
|
||||
.homebreweryIcon {
|
||||
position : relative;
|
||||
padding : 0px;
|
||||
top : 5px;
|
||||
left : -7.5px;
|
||||
height : 18px;
|
||||
}
|
||||
}
|
||||
282
client/homebrew/pages/basePages/listPage/listPage.jsx
Normal file
282
client/homebrew/pages/basePages/listPage/listPage.jsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
||||
import './listPage.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
import BrewItem from './brewItem/brewItem.jsx';
|
||||
|
||||
const USERPAGE_SORT_DIR = 'HB_listPage_sortDir';
|
||||
const USERPAGE_SORT_TYPE = 'HB_listPage_sortType';
|
||||
const USERPAGE_GROUP_VISIBILITY_PREFIX = 'HB_listPage_visibility_group';
|
||||
|
||||
const DEFAULT_SORT_TYPE = 'alpha';
|
||||
const DEFAULT_SORT_DIR = 'asc';
|
||||
|
||||
const ListPage = createReactClass({
|
||||
displayName : 'ListPage',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
brewCollection : [
|
||||
{
|
||||
title : '',
|
||||
class : '',
|
||||
brews : []
|
||||
}
|
||||
],
|
||||
navItems : <></>,
|
||||
reportError : null
|
||||
};
|
||||
},
|
||||
getInitialState : function() {
|
||||
// HIDE ALL GROUPS UNTIL LOADED
|
||||
const brewCollection = this.props.brewCollection.map((brewGroup)=>{
|
||||
brewGroup.visible = false;
|
||||
return brewGroup;
|
||||
});
|
||||
|
||||
return {
|
||||
filterString : this.props.query?.filter || '',
|
||||
filterTags : [],
|
||||
sortType : this.props.query?.sort || null,
|
||||
sortDir : this.props.query?.dir || null,
|
||||
query : this.props.query,
|
||||
brewCollection : brewCollection
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount : function() {
|
||||
// SAVE TO LOCAL STORAGE WHEN LEAVING PAGE
|
||||
window.onbeforeunload = this.saveToLocalStorage;
|
||||
|
||||
// LOAD FROM LOCAL STORAGE
|
||||
if(typeof window !== 'undefined') {
|
||||
const newSortType = (this.state.sortType ?? (localStorage.getItem(USERPAGE_SORT_TYPE) || DEFAULT_SORT_TYPE));
|
||||
const newSortDir = (this.state.sortDir ?? (localStorage.getItem(USERPAGE_SORT_DIR) || DEFAULT_SORT_DIR));
|
||||
this.updateUrl(this.state.filterString, newSortType, newSortDir);
|
||||
|
||||
const brewCollection = this.props.brewCollection.map((brewGroup)=>{
|
||||
brewGroup.visible = (localStorage.getItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`) ?? 'true')=='true';
|
||||
return brewGroup;
|
||||
});
|
||||
|
||||
this.setState({
|
||||
brewCollection : brewCollection,
|
||||
sortType : newSortType,
|
||||
sortDir : newSortDir
|
||||
});
|
||||
};
|
||||
},
|
||||
|
||||
componentWillUnmount : function() {
|
||||
window.onbeforeunload = function(){};
|
||||
},
|
||||
|
||||
saveToLocalStorage : function() {
|
||||
this.state.brewCollection.map((brewGroup)=>{
|
||||
localStorage.setItem(`${USERPAGE_GROUP_VISIBILITY_PREFIX}_${brewGroup.class}`, `${brewGroup.visible}`);
|
||||
});
|
||||
localStorage.setItem(USERPAGE_SORT_TYPE, this.state.sortType);
|
||||
localStorage.setItem(USERPAGE_SORT_DIR, this.state.sortDir);
|
||||
},
|
||||
|
||||
renderBrews : function(brews){
|
||||
if(!brews || !brews.length) return <div className='noBrews'>No Brews.</div>;
|
||||
|
||||
return _.map(brews, (brew, idx)=>{
|
||||
return <BrewItem brew={brew} key={idx} reportError={this.props.reportError} updateListFilter={ (tag)=>{ this.updateUrl(this.state.filterString, this.state.sortType, this.state.sortDir, tag); }}/>;
|
||||
});
|
||||
},
|
||||
|
||||
sortBrewOrder : function(brew){
|
||||
if(!brew.title){brew.title = 'No Title';}
|
||||
const mapping = {
|
||||
'alpha' : _.deburr(brew.title.trim().toLowerCase()),
|
||||
'created' : moment(brew.createdAt).format(),
|
||||
'updated' : moment(brew.updatedAt).format(),
|
||||
'views' : brew.views,
|
||||
'latest' : moment(brew.lastViewed).format()
|
||||
};
|
||||
return mapping[this.state.sortType];
|
||||
},
|
||||
|
||||
handleSortOptionChange : function(event){
|
||||
this.updateUrl(this.state.filterString, event.target.value, this.state.sortDir);
|
||||
this.setState({
|
||||
sortType : event.target.value
|
||||
});
|
||||
},
|
||||
|
||||
handleSortDirChange : function(event){
|
||||
const newDir = this.state.sortDir == 'asc' ? 'desc' : 'asc';
|
||||
|
||||
this.updateUrl(this.state.filterString, this.state.sortType, newDir);
|
||||
this.setState({
|
||||
sortDir : newDir
|
||||
});
|
||||
},
|
||||
|
||||
renderSortOption : function(sortTitle, sortValue){
|
||||
return <div className={`sort-option ${(this.state.sortType == sortValue ? 'active' : '')}`}>
|
||||
<button
|
||||
value={`${sortValue}`}
|
||||
onClick={this.state.sortType == sortValue ? this.handleSortDirChange : this.handleSortOptionChange}
|
||||
>
|
||||
{`${sortTitle}`}
|
||||
</button>
|
||||
{this.state.sortType == sortValue &&
|
||||
<i className={`sortDir fas ${this.state.sortDir == 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`}></i>
|
||||
}
|
||||
</div>;
|
||||
},
|
||||
|
||||
handleFilterTextChange : function(e){
|
||||
this.setState({
|
||||
filterString : e.target.value,
|
||||
});
|
||||
this.updateUrl(e.target.value, this.state.sortType, this.state.sortDir);
|
||||
return;
|
||||
},
|
||||
|
||||
updateUrl : function(filterTerm, sortType, sortDir, filterTag=''){
|
||||
const url = new URL(window.location.href);
|
||||
const urlParams = new URLSearchParams(url.search);
|
||||
|
||||
urlParams.set('sort', sortType);
|
||||
urlParams.set('dir', sortDir);
|
||||
|
||||
let filterTags = urlParams.getAll('tag');
|
||||
if(filterTag != '') {
|
||||
if(filterTags.findIndex((tag)=>{return tag.toLowerCase()==filterTag.toLowerCase();}) == -1){
|
||||
filterTags.push(filterTag);
|
||||
} else {
|
||||
filterTags = filterTags.filter((tag)=>{ return tag.toLowerCase() != filterTag.toLowerCase(); });
|
||||
}
|
||||
}
|
||||
urlParams.delete('tag');
|
||||
// Add tags to URL in the order they were clicked
|
||||
filterTags.forEach((tag)=>{ urlParams.append('tag', tag); });
|
||||
// Sort tags before updating state
|
||||
filterTags.sort((a, b)=>{
|
||||
return a.indexOf(':') - b.indexOf(':') != 0 ? a.indexOf(':') - b.indexOf(':') : a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
});
|
||||
|
||||
this.setState({
|
||||
filterTags
|
||||
});
|
||||
|
||||
if(!filterTerm)
|
||||
urlParams.delete('filter');
|
||||
else
|
||||
urlParams.set('filter', filterTerm);
|
||||
|
||||
url.search = urlParams;
|
||||
window.history.replaceState(null, null, url);
|
||||
},
|
||||
|
||||
renderFilterOption : function(){
|
||||
return <div className='filter-option'>
|
||||
<label>
|
||||
<i className='fas fa-search'></i>
|
||||
<input
|
||||
type='search'
|
||||
placeholder='filter title/description'
|
||||
onChange={this.handleFilterTextChange}
|
||||
value={this.state.filterString}
|
||||
/>
|
||||
</label>
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderTagsOptions : function(){
|
||||
if(this.state.filterTags?.length == 0) return;
|
||||
return <div className='tags-container'>
|
||||
{_.map(this.state.filterTags, (tag, idx)=>{
|
||||
const matches = tag.match(/^(?:([^:]+):)?([^:]+)$/);
|
||||
return <span key={idx} className={matches[1]} onClick={()=>{ this.updateUrl(this.state.filterString, this.state.sortType, this.state.sortDir, tag); }}>{matches[2]}</span>;
|
||||
})}
|
||||
</div>;
|
||||
},
|
||||
|
||||
renderSortOptions : function(){
|
||||
return <div className='sort-container'>
|
||||
<h6>Sort by :</h6>
|
||||
{this.renderSortOption('Title', 'alpha')}
|
||||
{this.renderSortOption('Created Date', 'created')}
|
||||
{this.renderSortOption('Updated Date', 'updated')}
|
||||
{this.renderSortOption('Views', 'views')}
|
||||
{/* {this.renderSortOption('Latest', 'latest')} */}
|
||||
|
||||
{this.renderFilterOption()}
|
||||
</div>;
|
||||
},
|
||||
|
||||
getSortedBrews : function(brews){
|
||||
const testString = _.deburr(this.state.filterString).toLowerCase();
|
||||
|
||||
brews = _.filter(brews, (brew)=>{
|
||||
// Filter by user entered text
|
||||
const brewStrings = _.deburr([
|
||||
brew.title,
|
||||
brew.description,
|
||||
brew.tags].join('\n')
|
||||
.toLowerCase());
|
||||
|
||||
const filterTextTest = brewStrings.includes(testString);
|
||||
|
||||
// Filter by user selected tags
|
||||
let filterTagTest = true;
|
||||
if(this.state.filterTags.length > 0){
|
||||
filterTagTest = Array.isArray(brew.tags) && this.state.filterTags?.every((tag)=>{
|
||||
return brew.tags.findIndex((brewTag)=>{
|
||||
return brewTag.toLowerCase() == tag.toLowerCase();
|
||||
}) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
return filterTextTest && filterTagTest;
|
||||
});
|
||||
|
||||
return _.orderBy(brews, (brew)=>{ return this.sortBrewOrder(brew); }, this.state.sortDir);
|
||||
},
|
||||
|
||||
toggleBrewCollectionState : function(brewGroupClass) {
|
||||
this.setState((prevState)=>({
|
||||
brewCollection : prevState.brewCollection.map(
|
||||
(brewGroup)=>brewGroup.class === brewGroupClass ? { ...brewGroup, visible: !brewGroup.visible } : brewGroup
|
||||
)
|
||||
}));
|
||||
},
|
||||
|
||||
renderBrewCollection : function(brewCollection){
|
||||
if(brewCollection == []) return <div className='brewCollection'>
|
||||
<h1>No Brews</h1>
|
||||
</div>;
|
||||
return _.map(brewCollection, (brewGroup, idx)=>{
|
||||
return <div key={idx} className={`brewCollection ${brewGroup.class ?? ''}`}>
|
||||
<h1 className={brewGroup.visible ? 'active' : 'inactive'} onClick={()=>{this.toggleBrewCollectionState(brewGroup.class);}}>{brewGroup.title || 'No Title'}</h1>
|
||||
{brewGroup.visible ? this.renderBrews(this.getSortedBrews(brewGroup.brews)) : <></>}
|
||||
</div>;
|
||||
});
|
||||
},
|
||||
|
||||
render : function(){
|
||||
return <div className='listPage sitePage'>
|
||||
{/*<style>@layer V3_5ePHB, bundle;</style>*/}
|
||||
<link href='/themes/V3/Blank/style.css' type='text/css' rel='stylesheet'/>
|
||||
<link href='/themes/V3/5ePHB/style.css' type='text/css' rel='stylesheet'/>
|
||||
{this.props.navItems}
|
||||
{this.renderSortOptions()}
|
||||
{this.renderTagsOptions()}
|
||||
|
||||
<div className='content V3'>
|
||||
<div className='page'>
|
||||
{this.renderBrewCollection(this.state.brewCollection)}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default ListPage;
|
||||
164
client/homebrew/pages/basePages/listPage/listPage.less
Normal file
164
client/homebrew/pages/basePages/listPage/listPage.less
Normal file
@@ -0,0 +1,164 @@
|
||||
|
||||
.noColumns() {
|
||||
column-count : auto;
|
||||
column-fill : auto;
|
||||
column-gap : normal;
|
||||
column-width : auto;
|
||||
-webkit-column-count : auto;
|
||||
-moz-column-count : auto;
|
||||
-webkit-column-width : auto;
|
||||
-moz-column-width : auto;
|
||||
-webkit-column-gap : normal;
|
||||
-moz-column-gap : normal;
|
||||
height : auto;
|
||||
min-height : 279.4mm;
|
||||
margin : 20px auto;
|
||||
contain : unset;
|
||||
}
|
||||
.listPage {
|
||||
.content {
|
||||
z-index : 1;
|
||||
.page {
|
||||
.noColumns() !important; //Needed to override PHB Theme since this is on a lower @layer
|
||||
&::after { display : none; }
|
||||
.noBrews {
|
||||
margin : 10px 0px;
|
||||
font-size : 1.3em;
|
||||
font-style : italic;
|
||||
}
|
||||
.brewCollection {
|
||||
h1:hover { cursor : pointer; }
|
||||
.active::before, .inactive::before {
|
||||
padding-right : 0.5em;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 0.6cm;
|
||||
font-weight : 900;
|
||||
}
|
||||
.active { color : var(--HB_Color_HeaderText); }
|
||||
.active::before { content : '\f107'; }
|
||||
.inactive { color : #707070; }
|
||||
.inactive::before { content : '\f105'; }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sort-container {
|
||||
position : sticky;
|
||||
top : 0;
|
||||
left : 0;
|
||||
z-index : 1;
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
row-gap : 5px;
|
||||
column-gap : 15px;
|
||||
align-items : baseline;
|
||||
justify-content : center;
|
||||
width : 100%;
|
||||
height : 30px;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
color : white;
|
||||
text-align : center;
|
||||
background-color : #555555;
|
||||
border-top : 1px solid #666666;
|
||||
border-bottom : 1px solid #666666;
|
||||
h6 {
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 11px;
|
||||
font-weight : bold;
|
||||
text-transform : uppercase;
|
||||
}
|
||||
.sort-option {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
height : 100%;
|
||||
padding : 0 8px;
|
||||
color : #CCCCCC;
|
||||
|
||||
&:hover { background-color : #444444; }
|
||||
|
||||
&.active {
|
||||
font-weight : bold;
|
||||
color : #DDDDDD;
|
||||
background-color : #333333;
|
||||
|
||||
button {
|
||||
height : 100%;
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
& + .sortDir { padding-left : 5px; }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.filter-option {
|
||||
margin-left : 20px;
|
||||
font-size : 11px;
|
||||
background-color : transparent !important;
|
||||
i { padding-right : 5px; }
|
||||
}
|
||||
button {
|
||||
padding : 0;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 11px;
|
||||
font-weight : normal;
|
||||
color : #CCCCCC;
|
||||
text-transform : uppercase;
|
||||
background-color : transparent;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
.tags-container {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
row-gap : 5px;
|
||||
column-gap : 15px;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
height : 30px;
|
||||
color : white;
|
||||
background-color : #555555;
|
||||
border-top : 1px solid #666666;
|
||||
border-bottom : 1px solid #666666;
|
||||
span {
|
||||
padding : 3px;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 11px;
|
||||
font-weight : bold;
|
||||
color : #DFDFDF;
|
||||
cursor : pointer;
|
||||
border : 1px solid;
|
||||
border-radius : 3px;
|
||||
&::before {
|
||||
margin-right : 3px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 12px;
|
||||
}
|
||||
&::after {
|
||||
margin-left : 3px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-size : 12px;
|
||||
content : '\f00d';
|
||||
}
|
||||
&.type {
|
||||
background-color : #008000;
|
||||
border-color : #00A000;
|
||||
&::before { content : '\f0ad'; }
|
||||
}
|
||||
&.group {
|
||||
background-color : #505050;
|
||||
border-color : #000000;
|
||||
&::before { content : '\f500'; }
|
||||
}
|
||||
&.meta {
|
||||
background-color : #000080;
|
||||
border-color : #0000A0;
|
||||
&::before { content : '\f05a'; }
|
||||
}
|
||||
&.system {
|
||||
background-color : #800000;
|
||||
border-color : #A00000;
|
||||
&::before { content : '\f518'; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
client/homebrew/pages/basePages/uiPage/uiPage.jsx
Normal file
39
client/homebrew/pages/basePages/uiPage/uiPage.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import './uiPage.less';
|
||||
import React from 'react';
|
||||
import createReactClass from 'create-react-class';
|
||||
|
||||
import Nav from '../../../navbar/nav.jsx';
|
||||
import Navbar from '../../../navbar/navbar.jsx';
|
||||
import NewBrewItem from '../../../navbar/newbrew.navitem.jsx';
|
||||
import HelpNavItem from '../../../navbar/help.navitem.jsx';
|
||||
import RecentNavItems from '../../../navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
import Account from '../../../navbar/account.navitem.jsx';
|
||||
|
||||
|
||||
const UIPage = createReactClass({
|
||||
displayName : 'UIPage',
|
||||
|
||||
render : function(){
|
||||
return <div className='uiPage sitePage'>
|
||||
<Navbar>
|
||||
<Nav.section>
|
||||
<Nav.item className='brewTitle'>{this.props.brew.title}</Nav.item>
|
||||
</Nav.section>
|
||||
|
||||
<Nav.section>
|
||||
<NewBrewItem />
|
||||
<HelpNavItem />
|
||||
<RecentNavItem />
|
||||
<Account />
|
||||
</Nav.section>
|
||||
</Navbar>
|
||||
|
||||
<div className='content'>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default UIPage;
|
||||
70
client/homebrew/pages/basePages/uiPage/uiPage.less
Normal file
70
client/homebrew/pages/basePages/uiPage/uiPage.less
Normal file
@@ -0,0 +1,70 @@
|
||||
.homebrew {
|
||||
.uiPage.sitePage {
|
||||
.content {
|
||||
width : ~'min(90vw, 1000px)';
|
||||
padding : 2% 4%;
|
||||
margin-top : 25px;
|
||||
margin-right : auto;
|
||||
margin-left : auto;
|
||||
overflow-y : scroll;
|
||||
font-family : 'Open Sans';
|
||||
font-size : 0.8em;
|
||||
line-height : 1.8em;
|
||||
background-color : #F0F0F0;
|
||||
.dataGroup {
|
||||
padding : 6px 20px 15px;
|
||||
margin : 5px 0px;
|
||||
border : 2px solid black;
|
||||
border-radius : 5px;
|
||||
button {
|
||||
width : 125px;
|
||||
margin-right : 5px;
|
||||
color : black;
|
||||
background-color : transparent;
|
||||
border : 1px solid black;
|
||||
border-radius : 5px;
|
||||
&.active {
|
||||
color : white;
|
||||
background-color : #00000077;
|
||||
&::before {
|
||||
margin-right : 5px;
|
||||
font-family : 'Font Awesome 6 Free';
|
||||
font-weight : 900;
|
||||
content : '\f00c';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h1, h2, h3, h4 {
|
||||
width : 100%;
|
||||
margin : 0.5em 30% 0.25em 0;
|
||||
font-weight : 900;
|
||||
text-transform : uppercase;
|
||||
border-bottom : 2px solid slategrey;
|
||||
}
|
||||
h1 {
|
||||
margin-right : 0;
|
||||
margin-bottom : 0.5em;
|
||||
font-size : 2em;
|
||||
border-bottom : 2px solid darkslategrey;
|
||||
}
|
||||
h2 { font-size : 1.75em; }
|
||||
h3 {
|
||||
font-size : 1.5em;
|
||||
svg { width : 19px; }
|
||||
}
|
||||
h4 { font-size : 1.25em; }
|
||||
strong { font-weight : bold; }
|
||||
em { font-style : italic; }
|
||||
ul {
|
||||
padding-left : 1.25em;
|
||||
list-style : square;
|
||||
}
|
||||
.blank {
|
||||
height : 1em;
|
||||
margin-top : 0;
|
||||
& + * { margin-top : 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
420
client/homebrew/pages/editPage/editPage.jsx
Normal file
420
client/homebrew/pages/editPage/editPage.jsx
Normal file
@@ -0,0 +1,420 @@
|
||||
/* eslint-disable max-lines */
|
||||
import './editPage.less';
|
||||
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '../../../../shared/markdown.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
|
||||
|
||||
import SplitPane from '../../../components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import Nav from '../../navbar/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 RecentNavItems from '../../navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
|
||||
// Page specific imports
|
||||
import Headtags from '../../../../vitreum/headtags.js';
|
||||
const Meta = Headtags.Meta;
|
||||
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';
|
||||
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 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';
|
||||
|
||||
const useLocalStorage = false;
|
||||
const neverSaved = false;
|
||||
|
||||
const EditPage = (props)=>{
|
||||
props = {
|
||||
brew : DEFAULT_BREW_LOAD,
|
||||
...props
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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(unsavedChangesRef.current)
|
||||
return 'You have unsaved changes!';
|
||||
};
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
window.onBeforeUnload = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
trySaveRef.current = trySave;
|
||||
unsavedChangesRef.current = unsavedChanges;
|
||||
});
|
||||
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange);
|
||||
}, [currentBrew]);
|
||||
|
||||
useEffect(()=>{
|
||||
trySave(true);
|
||||
}, [saveGoogle]);
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current?.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(Markdown.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({
|
||||
...prevBrew,
|
||||
style : newData.style,
|
||||
text : newData.text,
|
||||
snippets : newData.snippets
|
||||
}));
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
const handleGoogleClick = ()=>{
|
||||
if(!global.account?.googleId) {
|
||||
setAlertLoginToTransfer(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmGoogleTransfer((prev)=>!prev);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const closeAlerts = (e)=>{
|
||||
e.stopPropagation(); //Only handle click once so alert doesn't reopen
|
||||
setAlertTrashedGoogleBrew(false);
|
||||
setAlertLoginToTransfer(false);
|
||||
setConfirmGoogleTransfer(false);
|
||||
};
|
||||
|
||||
const toggleGoogleStorage = ()=>{
|
||||
setSaveGoogle((prev)=>!prev);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const trySave = (immediate = false, hasChanges = true)=>{
|
||||
clearTimeout(saveTimeout.current);
|
||||
if(isSaving) return;
|
||||
if(!hasChanges && !immediate) return;
|
||||
const newTimeout = immediate ? 0 : SAVE_TIMEOUT;
|
||||
|
||||
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);
|
||||
|
||||
//Prepare content to send to server
|
||||
const brewToSave = {
|
||||
...brew,
|
||||
text : brew.text.normalize('NFC'),
|
||||
pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1,
|
||||
patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))),
|
||||
hash : await md5(lastSavedBrew.current.text.normalize('NFC')),
|
||||
textBin : undefined,
|
||||
version : lastSavedBrew.current.version
|
||||
};
|
||||
|
||||
const compressedBrew = gzipSync(strToU8(JSON.stringify(brewToSave)));
|
||||
const transfer = saveToGoogle === _.isNil(brew.googleId);
|
||||
const params = transfer ? `?${saveToGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : '';
|
||||
|
||||
const res = await request
|
||||
.put(`/api/update/${brewToSave.editId}${params}`)
|
||||
.set('Content-Encoding', 'gzip')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(compressedBrew)
|
||||
.catch((err)=>{
|
||||
console.error('Error Updating Local Brew');
|
||||
setError(err);
|
||||
});
|
||||
if(!res) return;
|
||||
|
||||
const updatedFields = {
|
||||
googleId : res.body.googleId ?? null,
|
||||
editId : res.body.editId,
|
||||
shareId : res.body.shareId,
|
||||
version : res.body.version
|
||||
};
|
||||
|
||||
lastSavedBrew.current = {
|
||||
...brew,
|
||||
...updatedFields
|
||||
};
|
||||
|
||||
setCurrentBrew((prevBrew)=>({
|
||||
...prevBrew,
|
||||
...updatedFields
|
||||
}));
|
||||
|
||||
history.replaceState(null, null, `/edit/${res.body.editId}`);
|
||||
};
|
||||
|
||||
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={toggleGoogleStorage}> Yes </div>
|
||||
<div className='deny'> No </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertLoginToTransfer && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
You must be signed in to a Google account to transfer between the homebrewery and Google Drive!
|
||||
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
|
||||
<div className='confirm'> Sign In </div>
|
||||
</a>
|
||||
<div className='deny'> Not Now </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertTrashedGoogleBrew && (
|
||||
<div className='errorContainer' onClick={closeAlerts}>
|
||||
This brew is currently in your Trash folder on Google Drive!<br />
|
||||
If you want to keep it, make sure to move it before it is deleted permanently!<br />
|
||||
<div className='confirm'> OK </div>
|
||||
</div>
|
||||
)}
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
const renderSaveButton = ()=>{
|
||||
// #1 - Currently saving, show SAVING
|
||||
if(isSaving)
|
||||
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
|
||||
|
||||
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
|
||||
if(unsavedChanges && warnUnsavedChanges) {
|
||||
resetWarnUnsavedTimer();
|
||||
const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60);
|
||||
const text = elapsedTime === 0
|
||||
? 'Autosave is OFF.'
|
||||
: `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`;
|
||||
|
||||
return <Nav.item className='save error' icon='fas fa-exclamation-circle'>
|
||||
Reminder...
|
||||
<div className='errorContainer'>{text}</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
// #3 - Unsaved changes exist, click to save, show SAVE NOW
|
||||
if(unsavedChanges)
|
||||
return <Nav.item className='save' onClick={()=>trySave(true)} 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 toggleAutoSave = ()=>{
|
||||
clearTimeout(warnUnsavedTimeout.current);
|
||||
clearTimeout(saveTimeout.current);
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(!autoSaveEnabled));
|
||||
setAutoSaveEnabled(!autoSaveEnabled);
|
||||
setWarnUnsavedChanges(autoSaveEnabled);
|
||||
};
|
||||
|
||||
const renderAutoSaveButton = ()=>(
|
||||
<Nav.item onClick={toggleAutoSave}>
|
||||
Autosave <i className={autoSaveEnabled ? 'fas fa-power-off active' : 'fas fa-power-off'}></i>
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const renderNavbar = ()=>{
|
||||
return <Navbar>
|
||||
<Nav.section>
|
||||
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
|
||||
</Nav.section>
|
||||
|
||||
<Nav.section>
|
||||
{renderGoogleDriveIcon()}
|
||||
{error
|
||||
? <ErrorNavItem error={error} clearError={clearError} />
|
||||
: <Nav.dropdown className='save-menu'>
|
||||
{renderSaveButton()}
|
||||
{renderAutoSaveButton()}
|
||||
</Nav.dropdown>}
|
||||
<NewBrewItem />
|
||||
<PrintNavItem />
|
||||
<HelpNavItem />
|
||||
<VaultNavItem />
|
||||
<ShareNavItem brew={currentBrew} />
|
||||
<RecentNavItem brew={currentBrew} storageKey='edit' />
|
||||
<AccountNavItem/>
|
||||
</Nav.section>
|
||||
</Navbar>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='editPage sitePage'>
|
||||
<Meta name='robots' content='noindex, nofollow' />
|
||||
|
||||
{renderNavbar()}
|
||||
|
||||
{currentBrew.lock && <LockNotification shareId={currentBrew.shareId} message={currentBrew.lock.editMessage} reviewRequested={currentBrew.lock.reviewRequested}/>}
|
||||
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={handleSplitMove}>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
brew={currentBrew}
|
||||
onBrewChange={handleBrewChange}
|
||||
reportError={setError}
|
||||
renderer={currentBrew.renderer}
|
||||
userThemes={props.userThemes}
|
||||
themeBundle={themeBundle}
|
||||
updateBrew={updateBrew}
|
||||
onCursorPageChange={setCurrentEditorCursorPageNum}
|
||||
onViewPageChange={setCurrentEditorViewPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
theme={currentBrew.theme}
|
||||
themeBundle={themeBundle}
|
||||
errors={HTMLErrors}
|
||||
lang={currentBrew.lang}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPage;
|
||||
25
client/homebrew/pages/editPage/editPage.less
Normal file
25
client/homebrew/pages/editPage/editPage.less
Normal file
@@ -0,0 +1,25 @@
|
||||
@keyframes glideDown {
|
||||
0% {
|
||||
opacity : 0;transform : translate(-50% + 3px, 0px);}
|
||||
100% {
|
||||
opacity : 1;transform : translate(-50% + 3px, 10px);}
|
||||
}
|
||||
.editPage {
|
||||
.navItem.save {
|
||||
position : relative;
|
||||
width : 106px;
|
||||
text-align : center;
|
||||
&.saved {
|
||||
color : #666666;
|
||||
cursor : initial;
|
||||
}
|
||||
}
|
||||
.googleDriveStorage { position : relative; }
|
||||
.googleDriveStorage img {
|
||||
height : 18px;
|
||||
padding : 0px;
|
||||
margin : -5px;
|
||||
|
||||
&.inactive { filter : grayscale(1); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import './lockNotification.less';
|
||||
import * as React from 'react';
|
||||
import request from '../../../utils/request-middleware.js';
|
||||
import Dialog from '../../../../components/dialog.jsx';
|
||||
|
||||
function LockNotification(props) {
|
||||
props = {
|
||||
shareId : 0,
|
||||
disableLock : ()=>{},
|
||||
lock : {},
|
||||
message : 'Unable to retrieve Lock Message',
|
||||
reviewRequested : false,
|
||||
...props
|
||||
};
|
||||
|
||||
const [reviewState, setReviewState] = React.useState(props.reviewRequested);
|
||||
|
||||
const removeLock = async ()=>{
|
||||
await request.put(`/api/lock/review/request/${props.shareId}`)
|
||||
.then(()=>{
|
||||
setReviewState(true);
|
||||
});
|
||||
};
|
||||
|
||||
const renderReviewButton = function(){
|
||||
if(reviewState){ return <button className='inactive'>REVIEW REQUESTED</button>; };
|
||||
return <button onClick={removeLock}>REQUEST LOCK REMOVAL</button>;
|
||||
};
|
||||
|
||||
return <Dialog className='lockNotification' blocking closeText='CONTINUE TO EDITOR' >
|
||||
<h1>BREW LOCKED</h1>
|
||||
<p>This brew been locked by the Administrators. It will not be accessible by any method other than the Editor until the lock is removed.</p>
|
||||
<hr />
|
||||
<h3>LOCK REASON</h3>
|
||||
<p>{props.message}</p>
|
||||
<hr />
|
||||
<p>Once you have resolved this issue, click REQUEST LOCK REMOVAL to notify the Administrators for review.</p>
|
||||
<p>Click CONTINUE TO EDITOR to temporarily hide this notification; it will reappear the next time the page is reloaded.</p>
|
||||
{renderReviewButton()}
|
||||
</Dialog>;
|
||||
};
|
||||
|
||||
export default LockNotification;
|
||||
@@ -0,0 +1,29 @@
|
||||
.lockNotification {
|
||||
z-index : 1;
|
||||
width : 80%;
|
||||
padding : 10px;
|
||||
margin : 5% 10%;
|
||||
line-height : 1.5em;
|
||||
color : black;
|
||||
text-align : center;
|
||||
background-color : #CCCCCC;
|
||||
|
||||
&::backdrop { background-color : #000000AA; }
|
||||
|
||||
button {
|
||||
padding : 2px 15px;
|
||||
margin : 10px;
|
||||
color : white;
|
||||
background-color : #333333;
|
||||
|
||||
&.inactive,
|
||||
&:hover { background-color : #777777; }
|
||||
}
|
||||
|
||||
h1, h3 {
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-weight : 800;
|
||||
}
|
||||
h1 { font-size : 24px; }
|
||||
h3 { font-size : 18px; }
|
||||
}
|
||||
25
client/homebrew/pages/errorPage/errorPage.jsx
Normal file
25
client/homebrew/pages/errorPage/errorPage.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import './errorPage.less';
|
||||
import React from 'react';
|
||||
import UIPage from '../basePages/uiPage/uiPage.jsx';
|
||||
import Markdown from '../../../../shared/markdown.js';
|
||||
import ErrorIndex from './errors/errorIndex.js';
|
||||
|
||||
const ErrorPage = ({ brew })=>{
|
||||
// Retrieving the error text based on the brew's error code from ErrorIndex
|
||||
const errorText = ErrorIndex({ brew })[brew.HBErrorCode.toString()] || '';
|
||||
|
||||
return (
|
||||
<UIPage brew={{ title: 'Crit Fail!' }}>
|
||||
<div className='dataGroup'>
|
||||
<div className='errorTitle'>
|
||||
<h1>{`Error ${brew?.status || '000'}`}</h1>
|
||||
<h4>{brew?.text || 'No error text'}</h4>
|
||||
</div>
|
||||
<hr />
|
||||
<div dangerouslySetInnerHTML={{ __html: Markdown.render(errorText) }} />
|
||||
</div>
|
||||
</UIPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
13
client/homebrew/pages/errorPage/errorPage.less
Normal file
13
client/homebrew/pages/errorPage/errorPage.less
Normal file
@@ -0,0 +1,13 @@
|
||||
.homebrew {
|
||||
.uiPage.sitePage {
|
||||
.errorTitle {
|
||||
//background-color: @orange;
|
||||
color : #D02727;
|
||||
text-align : center;
|
||||
}
|
||||
.content {
|
||||
h1, h2, h3, h4 { border-bottom : none; }
|
||||
hr { border-bottom : 2px solid slategrey; }
|
||||
}
|
||||
}
|
||||
}
|
||||
271
client/homebrew/pages/errorPage/errors/errorIndex.js
Normal file
271
client/homebrew/pages/errorPage/errors/errorIndex.js
Normal file
@@ -0,0 +1,271 @@
|
||||
import dedent from 'dedent';
|
||||
|
||||
const loginUrl = 'https://www.naturalcrit.com/login';
|
||||
|
||||
// Prevent parsing text (e.g. document titles) as markdown
|
||||
const escape = (text = '')=>{
|
||||
return text.split('').map((char)=>`&#${char.charCodeAt(0)};`).join('');
|
||||
};
|
||||
|
||||
//001-050 : Brew errors
|
||||
//050-100 : Other pages errors
|
||||
|
||||
const errorIndex = (props)=>{
|
||||
return {
|
||||
// Default catch all
|
||||
'00' : dedent`
|
||||
## An unknown error occurred!
|
||||
|
||||
We aren't sure what happened, but our server wasn't able to find what you
|
||||
were looking for.`,
|
||||
|
||||
// General Google load error
|
||||
'01' : dedent`
|
||||
## An error occurred while retrieving this brew from Google Drive!
|
||||
|
||||
Google is able to see the brew at this link, but reported an error while attempting to retrieve it.
|
||||
|
||||
### Refreshing your Google Credentials
|
||||
|
||||
This issue is likely caused by an issue with your Google credentials; if you are the owner of this file, the following steps may resolve the issue:
|
||||
|
||||
- Go to https://www.naturalcrit.com/login and click logout if present (in small text at the bottom of the page).
|
||||
- Click "Sign In with Google", which will refresh your Google credentials.
|
||||
- After completing the sign in process, return to Homebrewery and refresh/reload the page so that it can pick up the updated credentials.
|
||||
- If this was the source of the issue, it should now be resolved.
|
||||
|
||||
If following these steps does not resolve the issue, please let us know!`,
|
||||
|
||||
// Google Drive - 404 : brew deleted or access denied
|
||||
'02' : dedent`
|
||||
## We can't find this brew in Google Drive!
|
||||
|
||||
This file was saved on Google Drive, but this link doesn't work anymore.
|
||||
${props.brew.authors?.length > 0
|
||||
? `Note that this brew belongs to the Homebrewery account **${props.brew.authors[0]}**,
|
||||
${props.brew.account
|
||||
? `which is
|
||||
${props.brew.authors[0] == props.brew.account
|
||||
? `your account.`
|
||||
: `not your account (you are currently signed in as **${props.brew.account}**).`
|
||||
}`
|
||||
: 'and you are not currently signed in to any account.'
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
The Homebrewery cannot delete files from Google Drive on its own, so there
|
||||
are three most likely possibilities:
|
||||
:
|
||||
- **The Google Drive files may have been accidentally deleted.** Look in
|
||||
the Google Drive account that owns this brew (or ask the owner to do so),
|
||||
and make sure the Homebrewery folder is still there, and that it holds your brews
|
||||
as text files.
|
||||
- **You may have changed the sharing settings for your files.** If the files
|
||||
are still on Google Drive, change all of them to be shared *with everyone who has
|
||||
the link* so the Homebrewery can access them.
|
||||
- **The Google Account may be closed.** Google may have removed the account
|
||||
due to inactivity or violating a Google policy. Make sure the owner can
|
||||
still access Google Drive normally and upload/download files to it.
|
||||
|
||||
If the file isn't found, Google Drive usually puts your file in your Trash folder for
|
||||
30 days. Assuming the trash hasn't been emptied yet, it might be worth checking.
|
||||
You can also find the Activity tab on the right side of the Google Drive page, which
|
||||
shows the recent activity on Google Drive. This can help you pin down the exact date
|
||||
the brew was deleted or moved, and by whom.
|
||||
:
|
||||
If the brew still isn't found, some people have had success asking Google to recover
|
||||
accidentally deleted files at this link:
|
||||
https://support.google.com/drive/answer/1716222?hl=en&ref_topic=7000946.
|
||||
At the bottom of the page there is a button that says *Send yourself an Email*
|
||||
and you will receive instructions on how to request the files be restored.
|
||||
:
|
||||
Also note, if you prefer not to use your Google Drive for storage, you can always
|
||||
change the storage location of a brew by clicking the Google drive icon by the
|
||||
brew title and choosing *transfer my brew to/from Google Drive*.`,
|
||||
|
||||
// User is not Authors list
|
||||
'03' : dedent`
|
||||
## Current signed-in user does not have editor access to this brew.
|
||||
|
||||
If you believe you should have access to this brew, ask one of its authors to invite you
|
||||
as an author by opening the Edit page for the brew, viewing the {{fa,fa-info-circle}}
|
||||
**Properties** tab, and adding your username to the "invited authors" list. You can
|
||||
then try to access this document again.
|
||||
|
||||
:
|
||||
|
||||
**Brew Title:** ${escape(props.brew.brewTitle) || 'Unable to show title'}
|
||||
|
||||
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${encodeURIComponent(author)})`;}).join(', ') || 'Unable to list authors'}
|
||||
|
||||
[Click here to be redirected to the brew's share page.](/share/${props.brew.shareId})`,
|
||||
|
||||
// User is not signed in; must be a user on the Authors List
|
||||
'04' : dedent`
|
||||
## Sign-in required to edit this brew.
|
||||
|
||||
You must be logged in to one of the accounts listed as an author of this brew.
|
||||
User is not logged in. Please log in [here](${loginUrl}).
|
||||
|
||||
:
|
||||
|
||||
**Brew Title:** ${escape(props.brew.brewTitle) || 'Unable to show title'}
|
||||
|
||||
**Current Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${encodeURIComponent(author)})`;}).join(', ') || 'Unable to list authors'}
|
||||
|
||||
[Click here to be redirected to the brew's share page.](/share/${props.brew.shareId})`,
|
||||
|
||||
|
||||
// Brew load error
|
||||
'05' : dedent`
|
||||
## No Homebrewery document could be found.
|
||||
|
||||
The server could not locate the Homebrewery document. It was likely deleted by
|
||||
its owner.
|
||||
|
||||
:
|
||||
|
||||
**Requested access:** ${props.brew.accessType}
|
||||
|
||||
**Brew ID:** ${props.brew.brewId}`,
|
||||
|
||||
// Brew save error
|
||||
'06' : dedent`
|
||||
## Unable to save Homebrewery document.
|
||||
|
||||
An error occurred wil attempting to save the Homebrewery document.`,
|
||||
|
||||
// Brew delete error
|
||||
'07' : dedent`
|
||||
## Unable to delete Homebrewery document.
|
||||
|
||||
An error occurred while attempting to remove the Homebrewery document.
|
||||
|
||||
:
|
||||
|
||||
**Brew ID:** ${props.brew.brewId}`,
|
||||
|
||||
// Author delete error
|
||||
'08' : dedent`
|
||||
## Unable to remove user from Homebrewery document.
|
||||
|
||||
An error occurred while attempting to remove the user from the Homebrewery document author list!
|
||||
|
||||
:
|
||||
|
||||
**Brew ID:** ${props.brew.brewId}`,
|
||||
|
||||
// Theme load error
|
||||
'09' : dedent`
|
||||
## No Homebrewery theme document could be found.
|
||||
|
||||
The server could not locate the Homebrewery document. It was likely deleted by
|
||||
its owner.
|
||||
|
||||
:
|
||||
|
||||
**Requested access:** ${props.brew.accessType}
|
||||
|
||||
**Brew ID:** ${props.brew.brewId}`,
|
||||
|
||||
// Theme Not Valid
|
||||
'10' : dedent`
|
||||
## The selected theme is not tagged as a theme.
|
||||
|
||||
The brew selected as a theme exists, but has not been marked for use as a theme with the \`theme:meta\` tag.
|
||||
|
||||
If the selected brew is your document, you may designate it as a theme by adding the \`theme:meta\` tag.`,
|
||||
|
||||
// ID validation error
|
||||
'11' : dedent`
|
||||
## No Homebrewery document could be found.
|
||||
|
||||
The server could not locate the Homebrewery document. The Brew ID failed the validation check.
|
||||
|
||||
:
|
||||
|
||||
**Brew ID:** ${props.brew.brewId}`,
|
||||
|
||||
// Google ID validation error
|
||||
'12' : dedent`
|
||||
## No Google document could be found.
|
||||
|
||||
The server could not locate the Google document. The Google ID failed the validation check.
|
||||
|
||||
:
|
||||
|
||||
**Brew ID:** ${props.brew.brewId}`,
|
||||
|
||||
// Database Connection Lost
|
||||
'13' : dedent`
|
||||
## Database connection has been lost.
|
||||
|
||||
The server could not communicate with the database.`,
|
||||
|
||||
//account page when account is not defined
|
||||
'50' : dedent`
|
||||
## You are not signed in
|
||||
|
||||
You are trying to access the account page, but are not signed in to an account.
|
||||
|
||||
Please login or signup at our [login page](https://www.naturalcrit.com/login?redirect=https://homebrewery.naturalcrit.com/account).`,
|
||||
|
||||
// Brew locked by Administrators error
|
||||
'51' : dedent`
|
||||
## This brew has been locked.
|
||||
|
||||
Only an author may request that this lock is removed.
|
||||
|
||||
:
|
||||
|
||||
**Brew ID:** ${props.brew.brewId}
|
||||
|
||||
**Brew Title:** ${escape(props.brew.brewTitle)}
|
||||
|
||||
**Brew Authors:** ${props.brew.authors?.map((author)=>{return `[${author}](/user/${encodeURIComponent(author)})`;}).join(', ') || 'Unable to list authors'}`,
|
||||
|
||||
// ####### Admin page error #######
|
||||
'52' : dedent`
|
||||
## Access Denied
|
||||
You need to provide correct administrator credentials to access this page.`,
|
||||
|
||||
// ####### Lock Errors
|
||||
|
||||
'60' : dedent`Lock Error: General`,
|
||||
|
||||
'61' : dedent`Lock Get Error: Unable to get lock count`,
|
||||
|
||||
'62' : dedent`Lock Set Error: Cannot lock`,
|
||||
|
||||
'63' : dedent`Lock Set Error: Brew not found`,
|
||||
|
||||
'64' : dedent`Lock Set Error: Already locked`,
|
||||
|
||||
'65' : dedent`Lock Remove Error: Cannot unlock`,
|
||||
|
||||
'66' : dedent`Lock Remove Error: Brew not found`,
|
||||
|
||||
'67' : dedent`Lock Remove Error: Not locked`,
|
||||
|
||||
'68' : dedent`Lock Get Review Error: Cannot get review requests`,
|
||||
|
||||
'69' : dedent`Lock Set Review Error: Cannot set review request`,
|
||||
|
||||
'70' : dedent`Lock Set Review Error: Brew not found`,
|
||||
|
||||
'71' : dedent`Lock Set Review Error: Review already requested`,
|
||||
|
||||
'72' : dedent`Lock Remove Review Error: Cannot clear review request`,
|
||||
|
||||
'73' : dedent`Lock Remove Review Error: Brew not found`,
|
||||
|
||||
// ####### Other Errors
|
||||
|
||||
'90' : dedent` An unexpected error occurred while looking for these brews.
|
||||
Try again in a few minutes.`,
|
||||
|
||||
'91' : dedent` An unexpected error occurred while trying to get the total of brews.`,
|
||||
};
|
||||
};
|
||||
|
||||
export default errorIndex;
|
||||
236
client/homebrew/pages/homePage/homePage.jsx
Normal file
236
client/homebrew/pages/homePage/homePage.jsx
Normal file
@@ -0,0 +1,236 @@
|
||||
/* eslint-disable max-lines */
|
||||
import './homePage.less';
|
||||
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '../../../../shared/markdown.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
|
||||
|
||||
import SplitPane from '../../../components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import Nav from '../../navbar/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 RecentNavItems from '../../navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
|
||||
|
||||
// Page specific imports
|
||||
import Headtags from '../../../../vitreum/headtags.js';
|
||||
const Meta = Headtags.Meta;
|
||||
|
||||
const BREWKEY = 'homebrewery-new';
|
||||
const STYLEKEY = 'homebrewery-new-style';
|
||||
const SNIPKEY = 'homebrewery-new-snippets';
|
||||
const METAKEY = 'homebrewery-new-meta';
|
||||
|
||||
const useLocalStorage = false;
|
||||
const neverSaved = true;
|
||||
|
||||
const HomePage =(props)=>{
|
||||
props = {
|
||||
brew : DEFAULT_BREW,
|
||||
ver : '0.0.0',
|
||||
...props
|
||||
};
|
||||
|
||||
const [currentBrew , setCurrentBrew] = useState(props.brew);
|
||||
const [error , setError] = useState(undefined);
|
||||
const [HTMLErrors , setHTMLErrors] = useState(Markdown.validate(props.brew.text));
|
||||
const [currentEditorViewPageNum , setCurrentEditorViewPageNum] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
const [themeBundle , setThemeBundle] = useState({});
|
||||
const [unsavedChanges , setUnsavedChanges] = useState(false);
|
||||
const [isSaving , setIsSaving] = useState(false);
|
||||
const [autoSaveEnabled , setAutoSaveEnable] = useState(false);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
const unsavedChangesRef = useRef(unsavedChanges);
|
||||
|
||||
useEffect(()=>{
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySaveRef.current(true);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = ()=>{
|
||||
if(unsavedChangesRef.current)
|
||||
return 'You have unsaved changes!';
|
||||
};
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
unsavedChangesRef.current = unsavedChanges;
|
||||
}, [unsavedChanges]);
|
||||
|
||||
const save = ()=>{
|
||||
request.post('/api')
|
||||
.send(currentBrew)
|
||||
.end((err, res)=>{
|
||||
if(err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
const saved = res.body;
|
||||
window.location = `/edit/${saved.editId}`;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange);
|
||||
}, [currentBrew]);
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(Markdown.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
{error
|
||||
? <ErrorNavItem error={error} clearError={clearError} />
|
||||
: renderSaveButton()}
|
||||
<NewBrewItem />
|
||||
<PrintNavItem />
|
||||
<HelpNavItem />
|
||||
<VaultNavItem />
|
||||
<RecentNavItem />
|
||||
<AccountNavItem />
|
||||
</Nav.section>
|
||||
</Navbar>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='homePage sitePage'>
|
||||
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
|
||||
{renderNavbar()}
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={handleSplitMove}>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
brew={currentBrew}
|
||||
onBrewChange={handleBrewChange}
|
||||
renderer={currentBrew.renderer}
|
||||
showEditButtons={false}
|
||||
themeBundle={themeBundle}
|
||||
onCursorPageChange={setCurrentEditorCursorPageNum}
|
||||
onViewPageChange={setCurrentEditorViewPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
themeBundle={themeBundle}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
50
client/homebrew/pages/homePage/homePage.less
Normal file
50
client/homebrew/pages/homePage/homePage.less
Normal file
@@ -0,0 +1,50 @@
|
||||
@import './shared/naturalcrit/styles/core.less';
|
||||
|
||||
.homePage {
|
||||
position : relative;
|
||||
a.floatingNewButton {
|
||||
.animate(background-color);
|
||||
position : absolute;
|
||||
right : 70px;
|
||||
bottom : 50px;
|
||||
z-index : 5001;
|
||||
display : block;
|
||||
padding : 1em;
|
||||
font-size : 1.5em;
|
||||
color : white;
|
||||
text-decoration : none;
|
||||
background-color : @orange;
|
||||
box-shadow : 3px 3px 15px black;
|
||||
&:hover { background-color : darken(@orange, 20%); }
|
||||
}
|
||||
.floatingSaveButton {
|
||||
.animateAll();
|
||||
position : absolute;
|
||||
right : 200px;
|
||||
bottom : 70px;
|
||||
z-index : 5000;
|
||||
display : block;
|
||||
padding : 0.8em;
|
||||
font-size : 0.8em;
|
||||
color : white;
|
||||
text-decoration : none;
|
||||
cursor : pointer;
|
||||
background-color : @blue;
|
||||
box-shadow : 3px 3px 15px black;
|
||||
&:hover { background-color : darken(@blue, 20%); }
|
||||
&.show { right : 350px; }
|
||||
}
|
||||
|
||||
.navItem.save {
|
||||
background-color : @orange;
|
||||
transition:all 0.2s;
|
||||
&:hover { background-color : @green; }
|
||||
|
||||
&.neverSaved {
|
||||
translate:-100%;
|
||||
opacity: 0;
|
||||
background-color :#333;
|
||||
cursor:auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
202
client/homebrew/pages/homePage/migrate.md
Normal file
202
client/homebrew/pages/homePage/migrate.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# How to Convert a Legacy Document to v3
|
||||
Here you will find a number of steps to guide you through converting a Legacy document into a Homebrewery v3 document.
|
||||
|
||||
**The first thing you'll want to do is switch the editor's rendering engine from `Legacy` to `v3`.** This will be the renderer we design features for moving forward.
|
||||
|
||||
There are some examples of Legacy code in the code pane if you need more context behind some of the changes.
|
||||
|
||||
**This document will evolve as users like yourself inform us of issues with it, or areas of conversion that it does not cover. _Please_ reach out if you have any suggestions for this document.**
|
||||
|
||||
## Simple Replacements
|
||||
To make your life a little easier with this section, a text editor like [VSCode](https://code.visualstudio.com/) or Notepad will help a lot.
|
||||
|
||||
The following table describes Legacy and other document elements and their Homebrewery counterparts. A simple find/replace should get these in working order.
|
||||
|
||||
| Legacy / Other | Homebrewery |
|
||||
|:----------------|:-----------------------------|
|
||||
| `\pagebreak` | `\page` |
|
||||
| `======` | `\page` |
|
||||
| `\pagebreaknum` | `{{pageNumber,auto}}\n\page` |
|
||||
| `@=====` | `{{pageNumber,auto}}\n\page` |
|
||||
| `\columnbreak` | `\column` |
|
||||
| `.phb` | `.page` |
|
||||
|
||||
## Classed or Styled Divs
|
||||
Anything that relies on the following syntax can be changed to the new Homebrewery v3 curly brace syntax:
|
||||
|
||||
```
|
||||
<div class="classTable wide">
|
||||
...
|
||||
</div>
|
||||
```
|
||||
:
|
||||
The above example is equivalent to the following in v3 syntax.
|
||||
|
||||
```
|
||||
{{classTable,wide
|
||||
...
|
||||
}}
|
||||
```
|
||||
:
|
||||
Some examples of this include class tables (as shown above), descriptive blocks, notes, and spell lists.
|
||||
|
||||
\column
|
||||
|
||||
## Margins and Padding
|
||||
Any manual margins and padding to push text down the page will likely need to be updated. Colons can be used on lines by themselves to push things down the page vertically if you'd rather not set pixel-perfect margins or padding.
|
||||
|
||||
## Notes
|
||||
|
||||
In Legacy, notes are denoted using markdown blockquote syntax. In Homebrewery v3, this is replaced by the curly brace syntax.
|
||||
|
||||
<!--
|
||||
> ##### Catchy Title
|
||||
> Useful Information
|
||||
-->
|
||||
|
||||
{{note
|
||||
##### Title
|
||||
Information
|
||||
}}
|
||||
|
||||
## Split Tables
|
||||
Split tables also use the curly brace syntax, as the new renderer can handle style values separately from class names.
|
||||
|
||||
<!--
|
||||
<div style='column-count:2'>
|
||||
|
||||
| d8 | Loot |
|
||||
|:---:|:-----------:|
|
||||
| 1 | 100gp |
|
||||
| 2 | 200gp |
|
||||
| 3 | 300gp |
|
||||
| 4 | 400gp |
|
||||
|
||||
| d8 | Loot |
|
||||
|:---:|:-----------:|
|
||||
| 5 | 500gp |
|
||||
| 6 | 600gp |
|
||||
| 7 | 700gp |
|
||||
| 8 | 1000gp |
|
||||
|
||||
</div>
|
||||
-->
|
||||
|
||||
##### Typical Difficulty Classes
|
||||
{{column-count:2
|
||||
| Task Difficulty | DC |
|
||||
|:----------------|:--:|
|
||||
| Very easy | 5 |
|
||||
| Easy | 10 |
|
||||
| Medium | 15 |
|
||||
|
||||
| Task Difficulty | DC |
|
||||
|:------------------|:--:|
|
||||
| Hard | 20 |
|
||||
| Very hard | 25 |
|
||||
| Nearly impossible | 30 |
|
||||
}}
|
||||
|
||||
## Blockquotes
|
||||
Blockquotes are denoted by the `>` character at the beginning of the line. In Homebrewery's v3 renderer, they hold virtually no meaning and have no CSS styling. You are free to use blockquotes when styling your document or creating themes without needing to worry about your CSS affecting other parts of the document.
|
||||
|
||||
{{pageNumber,auto}}
|
||||
|
||||
\page
|
||||
|
||||
## Stat Blocks
|
||||
|
||||
There are pretty significant differences between stat blocks on the Legacy renderer and Homebrewery v3. This section contains a list of changes that will need to be made to update the stat block.
|
||||
|
||||
### Initial Changes
|
||||
You will want to **remove all leading** `___` that started the stat block in Legacy, and replace that with `{{monster` before the stat block, and `}}` after it.
|
||||
|
||||
**If you want a frame** around the stat block, you can add `,frame` to the curly brace definition.
|
||||
|
||||
**If the stat block was wide**, make sure to add `,wide` to the curly brace definition.
|
||||
|
||||
### Blockquotes
|
||||
The key difference is the lack of blockquotes. Legacy documents use the `>` symbol at the start of the line for each line in the stat block, and the v3 renderer does not. **You will want to remove all `>` characters at the beginning of all lines, and delete any leading spaces.**
|
||||
|
||||
### Lists
|
||||
The basic characteristics and advanced characteristics sections are not list elements in Homebrewery. You will want to **remove all `-` or `*` characters from the beginning of lines.**
|
||||
|
||||
### Spacing
|
||||
In order to have the correct spacing after removing the list elements, you will want to **add two colons between the name of each basic/advanced characteristic and its value.** _(see example in the code pane)_
|
||||
|
||||
Additionally, in the special traits and actions sections, you will want to add a colon at the beginning of each line that separates a trait/action from another, as seen below. **Any empty lines between special traits and actions should contain only a colon.** _(see example in the code pane)_
|
||||
|
||||
\column
|
||||
|
||||
{{margin-top:102px}}
|
||||
|
||||
<!--
|
||||
### Legacy/Other Document Example:
|
||||
___
|
||||
> ## Centaur
|
||||
> *Large Monstrosity, neutral good*
|
||||
>___
|
||||
> - **Armor Class** 12
|
||||
> - **Hit Points** 45(6d10 + 12)
|
||||
> - **Speed** 50ft.
|
||||
>___
|
||||
>|STR|DEX|CON|INT|WIS|CHA|
|
||||
>|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
>|18 (+4)|14 (+2)|14 (+2)|9 (-1)|13 (+1)|11 (+0)|
|
||||
>___
|
||||
> - **Skills** Athletics +6, Perception +3, Survival +3
|
||||
> - **Senses** passive Perception 13
|
||||
> - **Languages** Elvish, Sylvan
|
||||
> - **Challenge** 2 (450 XP)
|
||||
> ___
|
||||
> ***Charge.*** If the centaur moves at least 30 feet straight toward a target and then hits it with a pike attack on the same turn, the target takes an extra 10 (3d6) piercing damage.
|
||||
>
|
||||
> ***Second Thing*** More details.
|
||||
>
|
||||
> ### Actions
|
||||
> ***Multiattack.*** The centaur makes two attacks: one with its pike and one with its hooves or two with its longbow.
|
||||
>
|
||||
> ***Pike.*** *Melee Weapon Attack:* +6 to hit, reach 10 ft., one target. *Hit:* 9 (1d10 + 4) piercing damage.
|
||||
>
|
||||
> ***Hooves.*** *Melee Weapon Attack:* +6 to hit, reach 5 ft., one target. *Hit:* 11 (2d6 + 4) bludgeoning damage.
|
||||
>
|
||||
> ***Longbow.*** *Ranged Weapon Attack:* +4 to hit, range 150/600 ft., one target. *Hit:* 6 (1d8 + 2) piercing damage.
|
||||
-->
|
||||
|
||||
### Homebrewery v3 Example:
|
||||
|
||||
{{monster
|
||||
## Centaur
|
||||
*Large monstrosity, neutral good*
|
||||
___
|
||||
**Armor Class** :: 12
|
||||
**Hit Points** :: 45(6d10 + 12)
|
||||
**Speed** :: 50ft.
|
||||
___
|
||||
| STR | DEX | CON | INT | WIS | CHA |
|
||||
|:-----:|:-----:|:-----:|:-----:|:-----:|:-----:|
|
||||
|18 (+4)|14 (+2)|14 (+2)|9 (-1) |13 (+1)|11 (+0)|
|
||||
___
|
||||
**Skills** :: Athletics +6, Perception +3, Survival +3
|
||||
**Senses** :: passive Perception 13
|
||||
**Languages** :: Elvish, Sylvan
|
||||
**Challenge** :: 2 (450 XP)
|
||||
___
|
||||
***Charge.*** If the centaur moves at least 30 feet straight toward a target and then hits it with a pike attack on the same turn, the target takes an extra 10 (3d6) piercing damage.
|
||||
:
|
||||
***Second Thing*** More details.
|
||||
|
||||
### Actions
|
||||
***Multiattack.*** The centaur makes two attacks: one with its pike and one with its hooves or two with its longbow.
|
||||
:
|
||||
***Pike.*** *Melee Weapon Attack:* +6 to hit, reach 10 ft., one target. *Hit:* 9 (1d10 + 4) piercing damage.
|
||||
:
|
||||
***Hooves.*** *Melee Weapon Attack:* +6 to hit, reach 5 ft., one target. *Hit:* 11 (2d6 + 4) bludgeoning damage.
|
||||
:
|
||||
***Longbow.*** *Ranged Weapon Attack:* +4 to hit, range 150/600 ft., one target. *Hit:* 6 (1d8 + 2) piercing damage.
|
||||
}}
|
||||
|
||||
{{pageNumber,auto}}
|
||||
|
||||
|
||||
|
||||
176
client/homebrew/pages/homePage/welcome_msg.md
Normal file
176
client/homebrew/pages/homePage/welcome_msg.md
Normal file
@@ -0,0 +1,176 @@
|
||||
```css
|
||||
.page #example + table td {
|
||||
border:1px dashed #00000030;
|
||||
}
|
||||
.page {
|
||||
padding-bottom : 1.1cm;
|
||||
}
|
||||
```
|
||||
|
||||
# The Homebrewery *V3*
|
||||
Welcome traveler from an antique land. Please sit and tell us of what you have seen. The unheard of monsters, who slither and bite. Tell us of the wondrous items and and artifacts you have found, their mysteries yet to be unlocked. Of the vexing vocations and surprising skills you have seen.
|
||||
|
||||
### Homebrew D&D made easy
|
||||
The Homebrewery makes the creation and sharing of authentic looking Fifth-Edition homebrews easy. It uses [Markdown](https://help.github.com/articles/markdown-basics/) with a little CSS magic to make your brews come to life.
|
||||
|
||||
**Try it!** Simply edit the text on the left and watch it *update live* on the right. Note that not every button is visible on this demo page. Click New {{fas,fa-plus-square}} in the navbar above to start brewing with all the features!
|
||||
|
||||
### Editing and Sharing
|
||||
When you create a new homebrew document ("brew"), your document will be given a *edit link* and a *share link*.
|
||||
|
||||
The *edit link* is where you write your brew. If you edit a brew while logged in, you are added as one of the brew's authors, and no one else can edit that brew until you add them as a new author via the {{fa,fa-info-circle}} **Properties** tab. Brews without any author can still be edited by anyone with the *edit link*, so be careful about who you share it with if you prefer to work without an account.
|
||||
|
||||
Anyone with the *share url* will be able to access a read-only version of your homebrew.
|
||||
|
||||
{{note
|
||||
##### PDF Creation
|
||||
PDF Printing works best in Google Chrome. If you are having quality/consistency issues, try using Chrome to print instead.
|
||||
|
||||
After clicking the "Print" item in the navbar a new page will open and a print dialog will pop-up.
|
||||
* Set the **Destination** to "Save as PDF"
|
||||
* Set **Paper Size** to "Letter"
|
||||
* If you are printing on A4 paper, make sure to have the **PRINT → {{far,fa-file}} A4 Pagesize** snippet in your brew
|
||||
* In **Options** make sure "Background Images" is selected.
|
||||
* Hit print and enjoy! You're done!
|
||||
|
||||
If you want to save ink or have a monochrome printer, add the **PRINT → {{fas,fa-tint}} Ink Friendly** snippet to your brew!
|
||||
}}
|
||||
|
||||
 {position:absolute,bottom:20px,left:130px,width:220px}
|
||||
|
||||
{{artist,bottom:160px,left:100px
|
||||
##### Homebrew Mug
|
||||
[naturalcrit](https://homebrew.naturalcrit.com)
|
||||
}}
|
||||
|
||||
{{pageNumber 1}}
|
||||
{{footnote PART 1 | FANCINESS}}
|
||||
|
||||
\column
|
||||
|
||||
## V3 vs Legacy
|
||||
The Homebrewery has two renderers: Legacy and V3. The V3 renderer is recommended for all users because it is more powerful, more customizable, and continues to receive new feature updates while Legacy does not. However Legacy mode will remain available for older brews and veteran users.
|
||||
|
||||
At any time, any individual brew can be changed to your renderer of choice via the {{fa,fa-info-circle}} **Properties** tab on your brew. However, converting between Legacy and V3 may require heavily tweaking the document; while both renderers can use raw HTML, V3 prefers a streamlined curly bracket syntax that avoids the complex HTML structures required by Legacy.
|
||||
|
||||
|
||||
Scroll down to the next page for a brief summary of the changes and features available in V3!
|
||||
#### New Things All The Time!
|
||||
Check out the latest updates in the full changelog [here](/changelog).
|
||||
|
||||
### Helping out
|
||||
Like this tool? Head over to our [Patreon](https://www.patreon.com/Naturalcrit) to help us keep the servers running.
|
||||
|
||||
|
||||
This tool will **always** be free, never have ads, and we will never offer any "premium" features or whatever.
|
||||
|
||||
### Bugs, Issues, Suggestions?
|
||||
- Check the [Frequently Asked Questions](/faq) page first for quick answers.
|
||||
- Get help or the right look for your brew by posting on [r/Homebrewery](https://www.reddit.com/r/homebrewery/submit?selftext=true&title=%5BIssue%5D%20Describe%20Your%20Issue%20Here) or joining the [Discord Of Many Things](https://discord.gg/by3deKx).
|
||||
- Report technical issues or provide feedback on the [GitHub Repo](https://github.com/naturalcrit/homebrewery/).
|
||||
|
||||
### Legal Junk
|
||||
The Homebrewery is licensed using the [MIT License](https://github.com/naturalcrit/homebrewery/blob/master/license). Which means you are free to use The Homebrewery codebase any way that you want, except for claiming that you made it yourself.
|
||||
|
||||
If you wish to sell or in some way gain profit for what's created on this site, it's your responsibility to ensure you have the proper licenses/rights for any images or resources used.
|
||||
#### Crediting Us
|
||||
If you'd like to credit us in your brew, we'd be flattered! Just reference that you made it with The Homebrewery.
|
||||
|
||||
### More Homebrew Resources
|
||||
[{width:50px,float:right,padding-left:10px}](https://discord.gg/by3deKx)
|
||||
|
||||
If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](https://www.reddit.com/r/UnearthedArcana/) and their list of useful resources [here](https://www.reddit.com/r/UnearthedArcana/wiki/resources). The [Discord Of Many Things](https://discord.gg/by3deKx) is another great resource to connect with fellow homebrewers for help and feedback.
|
||||
|
||||
|
||||
{{position:absolute;top:20px;right:20px;width:auto
|
||||
[{height:30px}](https://discord.gg/by3deKx)
|
||||
[{height:30px}](https://github.com/naturalcrit/homebrewery)
|
||||
[{height:30px}](https://patreon.com/NaturalCrit)
|
||||
[{height:30px}](https://www.reddit.com/r/homebrewery/)
|
||||
}}
|
||||
|
||||
\page
|
||||
|
||||
## Markdown+
|
||||
The Homebrewery aims to make homebrewing as simple as possible, providing a live editor with Markdown syntax that is more human-readable and faster to write with than raw HTML.
|
||||
|
||||
From version 3.0.0, with a goal of adding maximum flexibility without users resorting to complex HTML to accomplish simple tasks, Homebrewery provides an extended verision of Markdown with additional syntax.
|
||||
|
||||
### Curly Brackets
|
||||
Standard Markdown lacks several equivalences to HTML. Hence, we have introduced `{{ }}` as a replacement for `<span></span>` and `<div></div>` for a cleaner custom formatting. Inline spans and block elements can be created and given ID's and Classes, as well as CSS properties, each of which are comma separated with no spaces. Use double quotes if a value requires spaces. Spans and Blocks start the same:
|
||||
|
||||
#### Span
|
||||
My favorite author is {{pen,#author,color:orange,font-family:"trebuchet ms" Brandon Sanderson}}. The orange text has a class of `pen`, an id of `author`, is colored orange, and given a new font. The first space outside of quotes marks the beginning of the content.
|
||||
|
||||
|
||||
#### Block
|
||||
{{purple,#book,text-align:center,background:#aa88aa55
|
||||
My favorite book is Wheel of Time. This block has a class of `purple`, an id of `book`, and centered text with a colored background. The opening and closing brackets are on lines separate from the block contents.
|
||||
}}
|
||||
|
||||
#### Injection
|
||||
For any element not inside a span or block, you can *inject* attributes using the same syntax but with single brackets in a single line immediately after the element.
|
||||
|
||||
Inline elements like *italics* {color:#D35400} or images require the injection on the same line.
|
||||
|
||||
Block elements like headers require the injection to start on the line immediately following.
|
||||
|
||||
##### A Purple Header
|
||||
{color:purple,text-align:center}
|
||||
|
||||
\* *this does not currently work for tables yet*
|
||||
|
||||
### Vertical Spacing
|
||||
A blank line can be achieved with a run of one or more `:` alone on a line. More `:`'s will create more space.
|
||||
|
||||
::
|
||||
|
||||
|
||||
Much nicer than `<br><br><br><br><br>`
|
||||
|
||||
### Definition Lists
|
||||
**Example** :: V3 uses HTML *definition lists* to create "lists" with hanging indents.
|
||||
|
||||
|
||||
|
||||
### Column Breaks
|
||||
Column and page breaks with `\column` and `\page`.
|
||||
|
||||
\column
|
||||
### Tables
|
||||
Tables now allow column & row spanning between cells. This is included in some updated snippets, but a simplified example is given below.
|
||||
|
||||
A cell can be spanned across columns by grouping multiple pipe `|` characters at the end of a cell.
|
||||
|
||||
Row spanning is achieved by adding a `^` at the end of a cell just before the `|`.
|
||||
|
||||
These can be combined to span a cell across both columns and rows. Cells must have the same colspan if they are to be rowspan'd.
|
||||
|
||||
##### Example
|
||||
| Head A | Spanned Header ||
|
||||
| Head B | Head C | Head D |
|
||||
|:-------|:------:|:------:|
|
||||
| 1A | 1B | 1C |
|
||||
| 2A ^| 2B | 2C |
|
||||
| 3A ^| 3B 3C ||
|
||||
| 4A | 4B 4C^||
|
||||
| 5A ^| 5B | 5C |
|
||||
| 6A | 6B ^| 6C |
|
||||
|
||||
## Images
|
||||
Images must be hosted online somewhere, like [Imgur](https://www.imgur.com). You use the address to that image to reference it in your brew\*.
|
||||
|
||||
Using *Curly Injection* you can assign an id, classes, or inline CSS properties to the Markdown image syntax.
|
||||
|
||||
 {width:100px,border:"2px solid",border-radius:10px}
|
||||
|
||||
\* *When using Imgur-hosted images, use the "direct link", which can be found when you click into your image in the Imgur interface.*
|
||||
|
||||
## Snippets
|
||||
Homebrewery comes with a series of *code snippets* found at the top of the editor pane that make it easy to create brews as quickly as possible. Just set your cursor where you want the code to appear in the editor pane, choose a snippet, and make the adjustments you need.
|
||||
|
||||
## Style Editor Panel
|
||||
{{fa,fa-paint-brush}} Usually overlooked or unused by some users, the **Style Editor** tab is located on the right side of the Snippet bar. This editor accepts CSS for styling without requiring `<style>` tags-- anything that would have gone inside style tags before can now be placed here, and snippets that insert CSS styles are now located on that tab.
|
||||
|
||||
{{pageNumber 2}}
|
||||
{{footnote PART 2 | BORING STUFF}}
|
||||
108
client/homebrew/pages/homePage/welcome_msg_legacy.md
Normal file
108
client/homebrew/pages/homePage/welcome_msg_legacy.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# The Homebrewery
|
||||
|
||||
Welcome traveler from an antique land. Please sit and tell us of what you have seen. The unheard of monsters, who slither and bite. Tell us of the wondrous items and and artifacts you have found, their mysteries yet to be unlocked. Of the vexing vocations and surprising skills you have seen.
|
||||
|
||||
### Homebrew D&D made easy
|
||||
The Homebrewery makes the creation and sharing of authentic looking Fifth-Edition homebrews easy. It uses [Markdown](https://help.github.com/articles/markdown-basics/) with a little CSS magic to make your brews come to life.
|
||||
|
||||
**Try it!** Simply edit the text on the left and watch it *update live* on the right.
|
||||
|
||||
|
||||
|
||||
### Editing and Sharing
|
||||
When you create your own homebrew you will be given a *edit url* and a *share url*. Any changes you make will be automatically saved to the database within a few seconds. Anyone with the edit url will be able to make edits to your homebrew. So be careful about who you share it with.
|
||||
|
||||
Anyone with the *share url* will be able to access a read-only version of your homebrew.
|
||||
|
||||
## Helping out
|
||||
Like this tool? Want to buy me a beer? [Head here](https://www.patreon.com/Naturalcrit) to help me keep the servers running.
|
||||
|
||||
This tool will **always** be free, never have ads, and I will never offer any "premium" features or whatever.
|
||||
|
||||
|
||||
|
||||
>##### PDF Exporting
|
||||
> PDF Printing works best in Chrome. If you are having quality/consistency issues, try using Chrome to print instead.
|
||||
>
|
||||
> After clicking the "Print" item in the navbar a new page will open and a print dialog will pop-up.
|
||||
> * Set the **Destination** to "Save as PDF"
|
||||
> * Set **Paper Size** to "Letter"
|
||||
> * If you are printing on A4 paper, make sure to have the "A4 page size snippet" in your brew
|
||||
> * In **Options** make sure "Background Images" is selected.
|
||||
> * Hit print and enjoy! You're done!
|
||||
>
|
||||
> If you want to save ink or have a monochrome printer, add the **Ink Friendly** snippet to your brew before you print
|
||||
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
## V3.0.0 Released!
|
||||
With the latest major update to *The Homebrewery* we've implemented an extended Markdown-like syntax for block and span elements, plus a few other changes, eliminating the need for HTML tags like **div** and **span** in most cases. No raw HTML tags should be needed in a brew, and going forward, raw HTML will no longer receive debugging support (*but can still be used if you insist*).
|
||||
|
||||
**You can enable V3 via the <span class="fa fa-info-circle" style="text-indent:0"></span> Properties button!**
|
||||
|
||||
## New Things All The Time!
|
||||
What's new in the latest update? Check out the full changelog [here](/changelog)
|
||||
|
||||
### Bugs, Issues, Suggestions?
|
||||
Take a quick look at our [Frequently Asked Questions page](/faq) to see if your question has a handy answer.
|
||||
|
||||
Need help getting started or just the right look for your brew? Head to [r/Homebrewery](https://www.reddit.com/r/homebrewery/submit?selftext=true&title=%5BIssue%5D%20Describe%20Your%20Issue%20Here) and let us know!
|
||||
|
||||
Have an idea to make The Homebrewery better? Or did you find something that wasn't quite right? Check out the [GitHub Repo](https://github.com/naturalcrit/homebrewery/) to report technical issues.
|
||||
|
||||
### Legal Junk
|
||||
The Homebrewery is licensed using the [MIT License](https://github.com/naturalcrit/homebrewery/blob/master/license). This means you are free to use The Homebrewery codebase any way that you want, except for claiming that you made it yourself.
|
||||
|
||||
If you wish to sell or in some way gain profit for what you make on this site, it's your responsibility to ensure you have the proper licenses/rights for any images or resources used.
|
||||
|
||||
### More Resources
|
||||
<a href='https://discord.gg/by3deKx' target='_blank'><img src='/assets/discordOfManyThings.svg' alt='Discord of Many Things Logo' title='Discord of Many Things Logo' style='width:50px; float: right; padding-left: 10px;'/></a>
|
||||
If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](https://www.reddit.com/r/UnearthedArcana/) and their list of useful resources [here](https://www.reddit.com/r/UnearthedArcana/wiki/resources). The <a href='https://discord.gg/by3deKx' target='_blank' title='Discord of Many Things'>Discord of Many Things</a> is another great resource to connect with fellow homebrewers for help and feedback.
|
||||
|
||||
<img src='https://i.imgur.com/hMna6G0.png' style='position:absolute;bottom:40px;right:30px;width:280px' />
|
||||
|
||||
<div class='pageNumber'>1</div>
|
||||
<div class='footnote'>PART 1 | FANCINESS</div>
|
||||
|
||||
<div style='position: absolute; top: 20px; right: 20px;'>
|
||||
<a href='https://discord.gg/by3deKx' target='_blank' title='Discord of Many Things'><img src='/assets/discord.png' style='height:30px'/></a>
|
||||
<a href='https://github.com/naturalcrit/homebrewery' target='_blank' title='Github' style='color: black; padding-left: 5px;'><img src='/assets/github.png' style='height:30px'/></a>
|
||||
<a href='https://patreon.com/NaturalCrit' target='_blank' title='Patreon' style='color: black; padding-left: 5px;'><img src='/assets/patreon.png' style='height:30px'/></a>
|
||||
<a href='https://www.reddit.com/r/homebrewery/' target='_blank' title='Reddit' style='color: black; padding-left: 5px;'><img src='/assets/reddit.png' style='height:30px'/></a>
|
||||
</div>
|
||||
|
||||
\page
|
||||
|
||||
# Appendix
|
||||
|
||||
### Not quite Markdown
|
||||
Although the Homebrewery uses Markdown, to get all the styling features from the PHB, we had to get a little creative. Some base HTML elements are not used as expected and I've had to include a few new keywords.
|
||||
|
||||
___
|
||||
* **Horizontal Rules** are generally used to *modify* existing elements into a different style. For example, a horizontal rule before a blockquote will give it the style of a Monster Stat Block instead of a note.
|
||||
* **New Pages** are controlled by the author. It's impossible for the site to detect when the end of a page is reached, so indicate you'd like to start a new page, use the new page snippet to get the syntax.
|
||||
* **Code Blocks** are used only to indicate column breaks. Since they don't allow for styling within them, they weren't that useful to use.
|
||||
* **HTML** can be used to get *just* the right look for your homebrew. I've included some examples in the snippet icons above the editor.
|
||||
|
||||
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
|
||||
### Images
|
||||
Images must be hosted online somewhere, like imgur. You use the address to that image to reference it in your brew. Images can be included 'inline' with the text using Markdown-style images. However for background images more control is needed.
|
||||
|
||||
Background images should be included as HTML-style img tags. Using inline CSS you can precisely position your image where you'd like it to be. I have added both a inflow image snippet and a background image snippet to give you exmaples of how to do it.
|
||||
|
||||
|
||||
|
||||
### Crediting Me
|
||||
If you'd like to credit The Homebrewery in your brew, I'd be flattered! Just reference that you made it with The Homebrewery.
|
||||
|
||||
|
||||
|
||||
<div class='pageNumber'>2</div>
|
||||
<div class='footnote'>PART 2 | BORING STUFF</div>
|
||||
280
client/homebrew/pages/newPage/newPage.jsx
Normal file
280
client/homebrew/pages/newPage/newPage.jsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/* eslint-disable max-lines */
|
||||
import './newPage.less';
|
||||
|
||||
// Common imports
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
import Markdown from '../../../../shared/markdown.js';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js';
|
||||
|
||||
import SplitPane from '../../../components/splitPane/splitPane.jsx';
|
||||
import Editor from '../../editor/editor.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import Nav from '../../navbar/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 RecentNavItems from '../../navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
|
||||
// Page specific imports
|
||||
import { Meta } from '../../../../vitreum/headtags.js';
|
||||
|
||||
const BREWKEY = 'HB_newPage_content';
|
||||
const STYLEKEY = 'HB_newPage_style';
|
||||
const METAKEY = 'HB_newPage_metadata';
|
||||
const SNIPKEY = 'HB_newPage_snippets';
|
||||
const SAVEKEYPREFIX = 'HB_editor_defaultSave_';
|
||||
|
||||
const useLocalStorage = true;
|
||||
const neverSaved = true;
|
||||
|
||||
const NewPage = (props)=>{
|
||||
props = {
|
||||
brew : DEFAULT_BREW,
|
||||
...props
|
||||
};
|
||||
|
||||
const [currentBrew , setCurrentBrew ] = useState(props.brew);
|
||||
const [isSaving , setIsSaving ] = useState(false);
|
||||
const [saveGoogle , setSaveGoogle ] = useState(global.account?.googleId ? true : false);
|
||||
const [error , setError ] = useState(null);
|
||||
const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text));
|
||||
const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1);
|
||||
const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1);
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
const [themeBundle , setThemeBundle ] = useState({});
|
||||
const [unsavedChanges , setUnsavedChanges ] = useState(false);
|
||||
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(false);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
const lastSavedBrew = useRef(_.cloneDeep(props.brew));
|
||||
// const saveTimeout = useRef(null);
|
||||
// const warnUnsavedTimeout = useRef(null);
|
||||
const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew
|
||||
const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges
|
||||
|
||||
useEffect(()=>{
|
||||
loadBrew();
|
||||
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
if(e.keyCode === 83) trySaveRef.current(true);
|
||||
if(e.keyCode === 80) printCurrentBrew();
|
||||
if([83, 80].includes(e.keyCode)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const SAVEKEY = `${SAVEKEYPREFIX}${global.account?.username}`;
|
||||
const saveStorage = localStorage.getItem(SAVEKEY) || 'HOMEBREWERY';
|
||||
|
||||
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')
|
||||
window.history.replaceState({}, window.location.title, '/new/');
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
|
||||
setUnsavedChanges(hasChange);
|
||||
|
||||
if(autoSaveEnabled) trySave(false, hasChange);
|
||||
}, [currentBrew]);
|
||||
|
||||
useEffect(()=>{
|
||||
trySaveRef.current = trySave;
|
||||
unsavedChangesRef.current = unsavedChanges;
|
||||
});
|
||||
|
||||
const handleSplitMove = ()=>{
|
||||
editorRef.current.update();
|
||||
};
|
||||
|
||||
const handleBrewChange = (field)=>(value, subfield)=>{ //'text', 'style', 'snippets', 'metadata'
|
||||
if(subfield == 'renderer' || subfield == 'theme')
|
||||
fetchThemeBundle(setError, setThemeBundle, value.renderer, value.theme);
|
||||
|
||||
//If there are HTML errors, run the validator on every change to give quick feedback
|
||||
if(HTMLErrors.length && (field == 'text' || field == 'snippets'))
|
||||
setHTMLErrors(Markdown.validate(value));
|
||||
|
||||
if(field == 'metadata') setCurrentBrew((prev)=>({ ...prev, ...value }));
|
||||
else setCurrentBrew((prev)=>({ ...prev, [field]: value }));
|
||||
|
||||
if(useLocalStorage) {
|
||||
if(field == 'text') localStorage.setItem(BREWKEY, value);
|
||||
if(field == 'style') localStorage.setItem(STYLEKEY, value);
|
||||
if(field == 'snippets') localStorage.setItem(SNIPKEY, value);
|
||||
if(field == 'metadata') localStorage.setItem(METAKEY, JSON.stringify({
|
||||
renderer : value.renderer,
|
||||
theme : value.theme,
|
||||
lang : value.lang
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const trySave = async ()=>{
|
||||
setIsSaving(true);
|
||||
|
||||
const updatedBrew = { ...currentBrew };
|
||||
splitTextStyleAndMetadata(updatedBrew);
|
||||
|
||||
const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^\\page$/gm;
|
||||
updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1;
|
||||
|
||||
const res = await request
|
||||
.post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`)
|
||||
.send(updatedBrew)
|
||||
.catch((err)=>{
|
||||
setIsSaving(false);
|
||||
setError(err);
|
||||
});
|
||||
|
||||
setIsSaving(false);
|
||||
if(!res) return;
|
||||
|
||||
const savedBrew = res.body;
|
||||
|
||||
localStorage.removeItem(BREWKEY);
|
||||
localStorage.removeItem(STYLEKEY);
|
||||
localStorage.removeItem(METAKEY);
|
||||
window.location = `/edit/${savedBrew.editId}`;
|
||||
};
|
||||
|
||||
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={trySave} color='blue' icon='fas fa-save'>save now</Nav.item>;
|
||||
|
||||
// #4 - No unsaved changes, autosave is ON, show AUTO-SAVED
|
||||
if(autoSaveEnabled)
|
||||
return <Nav.item className='save saved'>auto-saved</Nav.item>;
|
||||
|
||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||
if(neverSaved)
|
||||
return <Nav.item className='save neverSaved'>save now</Nav.item>;
|
||||
|
||||
// DEFAULT - No unsaved changes, show SAVED
|
||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||
};
|
||||
|
||||
const clearError = ()=>{
|
||||
setError(null);
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
const renderNavbar = ()=>(
|
||||
<Navbar>
|
||||
<Nav.section>
|
||||
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
|
||||
</Nav.section>
|
||||
|
||||
<Nav.section>
|
||||
{error
|
||||
? <ErrorNavItem error={error} clearError={clearError} />
|
||||
: renderSaveButton()}
|
||||
<NewBrewItem />
|
||||
<PrintNavItem />
|
||||
<HelpNavItem />
|
||||
<VaultNavItem />
|
||||
<RecentNavItem />
|
||||
<AccountNavItem />
|
||||
</Nav.section>
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='newPage sitePage'>
|
||||
{renderNavbar()}
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={handleSplitMove}>
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
brew={currentBrew}
|
||||
onBrewChange={handleBrewChange}
|
||||
renderer={currentBrew.renderer}
|
||||
userThemes={props.userThemes}
|
||||
themeBundle={themeBundle}
|
||||
onCursorPageChange={setCurrentEditorCursorPageNum}
|
||||
onViewPageChange={setCurrentEditorViewPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={currentBrew.text}
|
||||
style={currentBrew.style}
|
||||
renderer={currentBrew.renderer}
|
||||
theme={currentBrew.theme}
|
||||
themeBundle={themeBundle}
|
||||
errors={HTMLErrors}
|
||||
lang={currentBrew.lang}
|
||||
onPageChange={setCurrentBrewRendererPageNum}
|
||||
currentEditorViewPageNum={currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewPage;
|
||||
17
client/homebrew/pages/newPage/newPage.less
Normal file
17
client/homebrew/pages/newPage/newPage.less
Normal file
@@ -0,0 +1,17 @@
|
||||
@import './shared/naturalcrit/styles/colors.less';
|
||||
|
||||
.newPage {
|
||||
.navItem.save {
|
||||
background-color : @orange;
|
||||
transition:all 0.2s;
|
||||
&:hover { background-color : @green; }
|
||||
|
||||
&.neverSaved {
|
||||
translate:-100%;
|
||||
opacity: 0;
|
||||
background-color :#333;
|
||||
cursor:auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
120
client/homebrew/pages/sharePage/sharePage.jsx
Normal file
120
client/homebrew/pages/sharePage/sharePage.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import './sharePage.less';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Headtags from '../../../../vitreum/headtags.js';
|
||||
const Meta = Headtags.Meta;
|
||||
|
||||
import Nav from '../../navbar/nav.jsx';
|
||||
import Navbar from '../../navbar/navbar.jsx';
|
||||
import MetadataNav from '../../navbar/metadata.navitem.jsx';
|
||||
import PrintNavItem from '../../navbar/print.navitem.jsx';
|
||||
import RecentNavItems from '../../navbar/recent.navitem.jsx';
|
||||
const { both: RecentNavItem } = RecentNavItems;
|
||||
import Account from '../../navbar/account.navitem.jsx';
|
||||
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
|
||||
|
||||
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
|
||||
import { printCurrentBrew, fetchThemeBundle } from '../../../../shared/helpers.js';
|
||||
|
||||
const SharePage = (props)=>{
|
||||
const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props;
|
||||
|
||||
const [themeBundle, setThemeBundle] = useState({});
|
||||
const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1);
|
||||
|
||||
const handleBrewRendererPageChange = useCallback((pageNumber)=>{
|
||||
setCurrentBrewRendererPageNum(pageNumber);
|
||||
}, []);
|
||||
|
||||
const handleControlKeys = (e)=>{
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
const P_KEY = 80;
|
||||
if(e.keyCode === P_KEY) {
|
||||
printCurrentBrew();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(()=>{
|
||||
document.addEventListener('keydown', handleControlKeys);
|
||||
fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme);
|
||||
|
||||
return ()=>{
|
||||
document.removeEventListener('keydown', handleControlKeys);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const processShareId = ()=>{
|
||||
return brew.googleId && !brew.stubbed ? brew.googleId + brew.shareId : brew.shareId;
|
||||
};
|
||||
|
||||
const renderEditLink = ()=>{
|
||||
if(!brew.editId) return null;
|
||||
|
||||
const editLink = brew.googleId && ! brew.stubbed ? brew.googleId + brew.editId : brew.editId;
|
||||
|
||||
return (
|
||||
<Nav.item color='orange' icon='fas fa-pencil-alt' href={`/edit/${editLink}`}>
|
||||
edit
|
||||
</Nav.item>
|
||||
);
|
||||
};
|
||||
|
||||
const titleEl = (
|
||||
<Nav.item className='brewTitle' style={disableMeta ? { cursor: 'default' } : {}}>
|
||||
{brew.title}
|
||||
</Nav.item>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='sharePage sitePage'>
|
||||
<Meta name='robots' content='noindex, nofollow' />
|
||||
<Navbar>
|
||||
<Nav.section className='titleSection'>
|
||||
{disableMeta ? titleEl : <MetadataNav brew={brew}>{titleEl}</MetadataNav>}
|
||||
</Nav.section>
|
||||
|
||||
<Nav.section>
|
||||
{brew.shareId && (
|
||||
<>
|
||||
<PrintNavItem />
|
||||
<Nav.dropdown>
|
||||
<Nav.item color='red' icon='fas fa-code'>
|
||||
source
|
||||
</Nav.item>
|
||||
<Nav.item color='blue' icon='fas fa-eye' href={`/source/${processShareId()}`}>
|
||||
view
|
||||
</Nav.item>
|
||||
{renderEditLink()}
|
||||
<Nav.item color='blue' icon='fas fa-download' href={`/download/${processShareId()}`}>
|
||||
download
|
||||
</Nav.item>
|
||||
<Nav.item color='blue' icon='fas fa-clone' href={`/new/${processShareId()}`}>
|
||||
clone to new
|
||||
</Nav.item>
|
||||
</Nav.dropdown>
|
||||
</>
|
||||
)}
|
||||
<RecentNavItem brew={brew} storageKey='view' />
|
||||
<Account />
|
||||
</Nav.section>
|
||||
</Navbar>
|
||||
|
||||
<div className='content'>
|
||||
<BrewRenderer
|
||||
text={brew.text}
|
||||
style={brew.style}
|
||||
lang={brew.lang}
|
||||
renderer={brew.renderer}
|
||||
theme={brew.theme}
|
||||
themeBundle={themeBundle}
|
||||
onPageChange={handleBrewRendererPageChange}
|
||||
currentBrewRendererPageNum={currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SharePage;
|
||||
7
client/homebrew/pages/sharePage/sharePage.less
Normal file
7
client/homebrew/pages/sharePage/sharePage.less
Normal file
@@ -0,0 +1,7 @@
|
||||
.sharePage {
|
||||
nav .navSection.titleSection {
|
||||
flex-grow : 1;
|
||||
justify-content : center;
|
||||
}
|
||||
.content { overflow-y : hidden; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user