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

Merge branch 'master' into marked-subsuper

This commit is contained in:
David Bolack
2025-02-19 11:37:38 -06:00
33 changed files with 511 additions and 247 deletions

View File

@@ -73,9 +73,6 @@ jobs:
- run: - run:
name: Test - Non-Breaking Spaces name: Test - Non-Breaking Spaces
command: npm run test:non-breaking-spaces command: npm run test:non-breaking-spaces
- run:
name: Test - Paragraph Justification
command: npm run test:paragraph-justification
- run: - run:
name: Test - Variables name: Test - Variables
command: npm run test:variables command: npm run test:variables

View File

@@ -1,47 +1,48 @@
require('./admin.less'); import './admin.less';
const React = require('react'); import React, { useEffect, useState } from 'react';
const createClass = require('create-react-class');
const BrewUtils = require('./brewUtils/brewUtils.jsx'); const BrewUtils = require('./brewUtils/brewUtils.jsx');
const NotificationUtils = require('./notificationUtils/notificationUtils.jsx'); const NotificationUtils = require('./notificationUtils/notificationUtils.jsx');
import AuthorUtils from './authorUtils/authorUtils.jsx';
const tabGroups = ['brew', 'notifications']; const tabGroups = ['brew', 'notifications', 'authors'];
const Admin = createClass({ const Admin = ()=>{
getDefaultProps : function() { const [currentTab, setCurrentTab] = useState('brew');
return {};
},
getInitialState : function(){ useEffect(()=>{
return ({ setCurrentTab(localStorage.getItem('hbAdminTab'));
currentTab : 'brew' }, []);
});
},
handleClick : function(newTab){ useEffect(()=>{
if(this.state.currentTab === newTab) return; localStorage.setItem('hbAdminTab', currentTab);
this.setState({ }, [currentTab]);
currentTab : newTab
});
},
render : function(){ return (
return <div className='admin'> <div className='admin'>
<header> <header>
<div className='container'> <div className='container'>
<i className='fas fa-rocket' /> <i className='fas fa-rocket' />
homebrewery admin The Homebrewery Admin Page
<a href='/'>back to homepage</a>
</div> </div>
</header> </header>
<main className='container'> <main className='container'>
<nav className='tabs'> <nav className='tabs'>
{tabGroups.map((tab, idx)=>{ return <button className={tab===this.state.currentTab ? 'active' : ''} key={idx} onClick={()=>{ return this.handleClick(tab); }}>{tab.toUpperCase()}</button>; })} {tabGroups.map((tab, idx)=>(
<button
className={tab === currentTab ? 'active' : ''}
key={idx}
onClick={()=>setCurrentTab(tab)}>
{tab.toUpperCase()}
</button>
))}
</nav> </nav>
{this.state.currentTab==='brew' && <BrewUtils />} {currentTab === 'brew' && <BrewUtils />}
{this.state.currentTab==='notifications' && <NotificationUtils />} {currentTab === 'notifications' && <NotificationUtils />}
{currentTab === 'authors' && <AuthorUtils />}
</main> </main>
</div>; </div>
} );
}); };
module.exports = Admin; module.exports = Admin;

View File

@@ -22,7 +22,7 @@ body {
} }
:where(.admin) { :where(.admin) {
padding-bottom : 50px;
header { header {
padding : 20px 0px; padding : 20px 0px;
margin-bottom : 30px; margin-bottom : 30px;
@@ -30,6 +30,7 @@ body {
color : white; color : white;
background-color : @red; background-color : @red;
i { margin-right : 30px; } i { margin-right : 30px; }
a { float : right; }
} }
hr { margin : 30px 0px; } hr { margin : 30px 0px; }
@@ -48,21 +49,23 @@ body {
} }
dl { dl {
@maxItemWidth : 132px; display : grid;
grid-template-columns : 120px 1fr;
row-gap : 10px;
align-items : center;
justify-items : start;
padding-top : 0.5em;
dt { dt {
float : left; float : left;
width : @maxItemWidth; clear : left;
clear : left; height : fit-content;
text-align : right; font-weight : 900;
text-align : right;
&::after { content : ' : '; } &::after { content : ' : '; }
} }
dd { dd { height : fit-content; }
height : 1em;
padding : 0 0 0.5em 0;
margin-left : @maxItemWidth + 6px;
}
} }
.tabs button { .tabs button {
margin-right : 3px; margin-right : 3px;
margin-left : 3px; margin-left : 3px;
@@ -90,11 +93,45 @@ body {
} }
} }
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 { .error {
background: rgb(178, 54, 54); float : right;
color:white; padding : 10px;
font-weight: 900; margin-block : 10px;
margin-block:10px; font-weight : 900;
padding:10px; color : white;
background : rgb(178, 54, 54);
} }
} }

View 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>
);
};
module.exports = authorLookup;

View 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; }
}
}
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import AuthorLookup from './authorLookup/authorLookup.jsx';
const authorUtils = ()=>{
return (
<section className='authorUtils'>
<AuthorLookup />
</section>
);
};
module.exports = authorUtils;

View File

@@ -1,10 +1,8 @@
require('./brewCleanup.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const request = require('superagent'); const request = require('superagent');
const BrewCleanup = createClass({ const BrewCleanup = createClass({
displayName : 'BrewCleanup', displayName : 'BrewCleanup',
getDefaultProps(){ getDefaultProps(){
@@ -39,9 +37,9 @@ const BrewCleanup = createClass({
if(!this.state.primed) return; if(!this.state.primed) return;
if(!this.state.count){ if(!this.state.count){
return <div className='removeBox'>No Matching Brews found.</div>; return <div className='result noBrews'>No Matching Brews found.</div>;
} }
return <div className='removeBox'> return <div className='result'>
<button onClick={this.cleanup} className='remove'> <button onClick={this.cleanup} className='remove'>
{this.state.pending {this.state.pending
? <i className='fas fa-spin fa-spinner' /> ? <i className='fas fa-spin fa-spinner' />
@@ -52,7 +50,7 @@ const BrewCleanup = createClass({
</div>; </div>;
}, },
render(){ render(){
return <div className='BrewCleanup'> return <div className='brewUtil brewCleanup'>
<h2> Brew Cleanup </h2> <h2> Brew Cleanup </h2>
<p>Removes very short brews to tidy up the database</p> <p>Removes very short brews to tidy up the database</p>
@@ -65,7 +63,7 @@ const BrewCleanup = createClass({
{this.renderPrimed()} {this.renderPrimed()}
{this.state.error {this.state.error
&& <div className='error'>{this.state.error.toString()}</div> && <div className='error noBrews'>{this.state.error.toString()}</div>
} }
</div>; </div>;
} }

View File

@@ -1,9 +0,0 @@
.BrewCleanup {
.removeBox {
margin-top : 20px;
button {
margin-right : 10px;
background-color : @red;
}
}
}

View File

@@ -1,10 +1,7 @@
require('./brewCompress.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const request = require('superagent'); const request = require('superagent');
const BrewCompress = createClass({ const BrewCompress = createClass({
displayName : 'BrewCompress', displayName : 'BrewCompress',
getDefaultProps(){ getDefaultProps(){
@@ -53,9 +50,9 @@ const BrewCompress = createClass({
if(!this.state.primed) return; if(!this.state.primed) return;
if(!this.state.count){ if(!this.state.count){
return <div className='removeBox'>No Matching Brews found.</div>; return <div className='result noBrews'>No Matching Brews found.</div>;
} }
return <div className='removeBox'> return <div className='result'>
<button onClick={this.cleanup} className='remove'> <button onClick={this.cleanup} className='remove'>
{this.state.pending {this.state.pending
? <i className='fas fa-spin fa-spinner' /> ? <i className='fas fa-spin fa-spinner' />
@@ -69,7 +66,7 @@ const BrewCompress = createClass({
</div>; </div>;
}, },
render(){ render(){
return <div className='BrewCompress'> return <div className='brewUtil brewCompress'>
<h2> Brew Compression </h2> <h2> Brew Compression </h2>
<p>Compresses the text in brews to binary</p> <p>Compresses the text in brews to binary</p>

View File

@@ -1,9 +0,0 @@
.BrewCompress {
.removeBox {
margin-top : 20px;
button {
margin-right : 10px;
background-color : @red;
}
}
}

View File

@@ -1,5 +1,3 @@
require('./brewLookup.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const cx = require('classnames'); const cx = require('classnames');
@@ -55,7 +53,7 @@ const BrewLookup = createClass({
renderFoundBrew(){ renderFoundBrew(){
const brew = this.state.foundBrew; const brew = this.state.foundBrew;
return <div className='foundBrew'> return <div className='result'>
<dl> <dl>
<dt>Title</dt> <dt>Title</dt>
<dd>{brew.title}</dd> <dd>{brew.title}</dd>
@@ -90,7 +88,7 @@ const BrewLookup = createClass({
}, },
render(){ render(){
return <div className='brewLookup'> return <div className='brewUtil brewLookup'>
<h2>Brew Lookup</h2> <h2>Brew Lookup</h2>
<input type='text' value={this.state.query} onChange={this.handleChange} placeholder='edit or share id' /> <input type='text' value={this.state.query} onChange={this.handleChange} placeholder='edit or share id' />
<button onClick={this.lookup}> <button onClick={this.lookup}>
@@ -106,7 +104,7 @@ const BrewLookup = createClass({
{this.state.foundBrew {this.state.foundBrew
? this.renderFoundBrew() ? this.renderFoundBrew()
: <div className='noBrew'>No brew found.</div> : <div className='result noBrew'>No brew found.</div>
} }
</div>; </div>;
} }

View File

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

View File

@@ -1,6 +1,6 @@
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
require('./brewUtils.less');
const BrewCleanup = require('./brewCleanup/brewCleanup.jsx'); const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
const BrewLookup = require('./brewLookup/brewLookup.jsx'); const BrewLookup = require('./brewLookup/brewLookup.jsx');

View File

@@ -0,0 +1,29 @@
.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; }
}

View File

@@ -1,11 +1,8 @@
require('./stats.less');
const React = require('react'); const React = require('react');
const createClass = require('create-react-class'); const createClass = require('create-react-class');
const cx = require('classnames');
const request = require('superagent'); const request = require('superagent');
const Stats = createClass({ const Stats = createClass({
displayName : 'Stats', displayName : 'Stats',
getDefaultProps(){ getDefaultProps(){
@@ -14,7 +11,8 @@ const Stats = createClass({
getInitialState(){ getInitialState(){
return { return {
stats : { stats : {
totalBrews : 0 totalBrews : 0,
totalPublishedBrews : 0
}, },
fetching : false fetching : false
}; };
@@ -29,11 +27,13 @@ const Stats = createClass({
.finally(()=>this.setState({ fetching: false })); .finally(()=>this.setState({ fetching: false }));
}, },
render(){ render(){
return <div className='Stats'> return <div className='brewUtil stats'>
<h2> Stats </h2> <h2> Stats </h2>
<dl> <dl>
<dt>Total Brew Count</dt> <dt>Total Brew Count</dt>
<dd>{this.state.stats.totalBrews}</dd> <dd>{this.state.stats.totalBrews}</dd>
<dt>Total Brews Published</dt>
<dd>{this.state.stats.totalPublishedBrews}</dd>
</dl> </dl>
{this.state.fetching {this.state.fetching

View File

@@ -1,13 +0,0 @@
.Stats {
position : relative;
.pending {
position : absolute;
top : 0px;
left : 0px;
width : 100%;
height : 100%;
background-color : rgba(238,238,238, 0.5);
}
}

View File

@@ -6,18 +6,21 @@
.field { .field {
display : grid; display : grid;
grid-template-columns : 120px 150px; grid-template-columns : 120px 200px;
align-items : center; align-items : center;
justify-items : stretch; justify-items : stretch;
width : 100%; width : 100%;
margin-bottom : 20px; margin-bottom : 20px;
input { input {
height : 33px; height : 33px;
padding : 0px 10px; padding : 0px 10px;
margin-bottom : unset; margin-bottom : unset;
font-family : monospace; font-family : monospace;
&[type="date"] {
width:14ch;
}
} }
textarea { textarea {

View File

@@ -1,8 +1,8 @@
.notificationLookup { .notificationLookup {
width : 450px; width : 450px;
height : fit-content; height : fit-content;
.noNotification { margin-block : 20px; }
.notificationList { .notificationList {
display : flex; display : flex;
flex-direction : column; flex-direction : column;
@@ -30,11 +30,6 @@
font-size : 20px; font-size : 20px;
font-weight : 900; font-weight : 900;
} }
dl dt{
font-weight: 900;
}
} }
} }
.noNotification { margin-block : 20px; }
} }

View File

@@ -45,6 +45,7 @@ const Combobox = createClass({
}, },
handleDropdown : function(show){ handleDropdown : function(show){
this.setState({ this.setState({
value : show ? '' : this.props.default,
showDropdown : show, showDropdown : show,
inputFocused : this.props.autoSuggest.clearAutoSuggestOnClick ? show : false inputFocused : this.props.autoSuggest.clearAutoSuggestOnClick ? show : false
}); });
@@ -78,7 +79,7 @@ const Combobox = createClass({
if(!e.target.checkValidity()){ if(!e.target.checkValidity()){
this.setState({ this.setState({
value : this.props.default value : this.props.default
}, ()=>this.props.onEntry(e)); });
} }
}} }}
/> />

View File

@@ -456,6 +456,7 @@ const Editor = createClass({
rerenderParent={this.rerenderParent} /> rerenderParent={this.rerenderParent} />
<MetadataEditor <MetadataEditor
metadata={this.props.brew} metadata={this.props.brew}
themeBundle={this.props.themeBundle}
onChange={this.props.onMetaChange} onChange={this.props.onMetaChange}
reportError={this.props.reportError} reportError={this.props.reportError}
userThemes={this.props.userThemes}/> userThemes={this.props.userThemes}/>

View File

@@ -40,6 +40,7 @@ const MetadataEditor = createClass({
theme : '5ePHB', theme : '5ePHB',
lang : 'en' lang : 'en'
}, },
onChange : ()=>{}, onChange : ()=>{},
reportError : ()=>{} reportError : ()=>{}
}; };
@@ -47,7 +48,7 @@ const MetadataEditor = createClass({
getInitialState : function(){ getInitialState : function(){
return { return {
showThumbnail : true showThumbnail : true
}; };
}, },
@@ -67,6 +68,11 @@ const MetadataEditor = createClass({
const inputRules = validations[name] ?? []; const inputRules = validations[name] ?? [];
const validationErr = inputRules.map((rule)=>rule(e.target.value)).filter(Boolean); const validationErr = inputRules.map((rule)=>rule(e.target.value)).filter(Boolean);
const debouncedReportValidity = _.debounce((target, errMessage) => {
callIfExists(target, 'setCustomValidity', errMessage);
callIfExists(target, 'reportValidity');
}, 300); // 300ms debounce delay, adjust as needed
// if no validation rules, save to props // if no validation rules, save to props
if(validationErr.length === 0){ if(validationErr.length === 0){
callIfExists(e.target, 'setCustomValidity', ''); callIfExists(e.target, 'setCustomValidity', '');
@@ -74,14 +80,16 @@ const MetadataEditor = createClass({
...this.props.metadata, ...this.props.metadata,
[name] : e.target.value [name] : e.target.value
}); });
return true;
} else { } else {
// if validation issues, display built-in browser error popup with each error. // if validation issues, display built-in browser error popup with each error.
const errMessage = validationErr.map((err)=>{ const errMessage = validationErr.map((err)=>{
return `- ${err}`; return `- ${err}`;
}).join('\n'); }).join('\n');
callIfExists(e.target, 'setCustomValidity', errMessage);
callIfExists(e.target, 'reportValidity'); debouncedReportValidity(e.target, errMessage);
return false;
} }
}, },
@@ -112,6 +120,14 @@ const MetadataEditor = createClass({
handleTheme : function(theme){ handleTheme : function(theme){
this.props.metadata.renderer = theme.renderer; this.props.metadata.renderer = theme.renderer;
this.props.metadata.theme = theme.path; 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'); this.props.onChange(this.props.metadata, 'theme');
}, },
@@ -200,7 +216,7 @@ const MetadataEditor = createClass({
if(theme.path == this.props.metadata.shareId) return; if(theme.path == this.props.metadata.shareId) return;
const preview = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownPreview.png`; const preview = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownPreview.png`;
const texture = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownTexture.png`; const texture = theme.thumbnail || `/themes/${theme.renderer}/${theme.path}/dropdownTexture.png`;
return <div className='item' key={`${renderer}_${theme.name}`} onClick={()=>this.handleTheme(theme)} title={''}> return <div className='item' key={`${renderer}_${theme.name}`} value={`${theme.author ?? renderer} : ${theme.name}`} data={theme} title={''}>
{theme.author ?? renderer} : {theme.name} {theme.author ?? renderer} : {theme.name}
<div className='texture-container'> <div className='texture-container'>
<img src={texture}/> <img src={texture}/>
@@ -210,26 +226,40 @@ const MetadataEditor = createClass({
<img src={preview}/> <img src={preview}/>
</div> </div>
</div>; </div>;
}); }).filter(Boolean);
}; };
const currentRenderer = this.props.metadata.renderer; const currentRenderer = this.props.metadata.renderer;
const currentTheme = mergedThemes[`${_.upperFirst(this.props.metadata.renderer)}`][this.props.metadata.theme] const currentThemeDisplay = this.props.themeBundle?.name ? `${this.props.themeBundle.author ?? currentRenderer} : ${this.props.themeBundle.name}` : 'No Theme Selected';
?? { name: `!!! THEME MISSING !!! ID=${this.props.metadata.theme}` };
let dropdown; let dropdown;
if(currentRenderer == 'legacy') { if(currentRenderer == 'legacy') {
dropdown = dropdown =
<Nav.dropdown className='disabled value' trigger='disabled'> <div className='disabled value' trigger='disabled'>
<div> {`Themes are not supported in the Legacy Renderer`} <i className='fas fa-caret-down'></i> </div> <div> Themes are not supported in the Legacy Renderer </div>
</Nav.dropdown>; </div>;
} else { } else {
dropdown = dropdown =
<Nav.dropdown className='value' trigger='click'> <div className='value'>
<div> {currentTheme.author ?? _.upperFirst(currentRenderer)} : {currentTheme.name} <i className='fas fa-caret-down'></i> </div> <Combobox trigger='click'
className='themes-dropdown'
{listThemes(currentRenderer)} default={currentThemeDisplay}
</Nav.dropdown>; 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'> return <div className='field themes'>
@@ -251,8 +281,6 @@ const MetadataEditor = createClass({
}); });
}; };
const debouncedHandleFieldChange = _.debounce(this.handleFieldChange, 500);
return <div className='field language'> return <div className='field language'>
<label>language</label> <label>language</label>
<div className='value'> <div className='value'>
@@ -263,7 +291,7 @@ const MetadataEditor = createClass({
onSelect={(value)=>this.handleLanguage(value)} onSelect={(value)=>this.handleLanguage(value)}
onEntry={(e)=>{ onEntry={(e)=>{
e.target.setCustomValidity(''); //Clear the validation popup while typing e.target.setCustomValidity(''); //Clear the validation popup while typing
debouncedHandleFieldChange('lang', e); this.handleFieldChange('lang', e);
}} }}
options={listLanguages()} options={listLanguages()}
autoSuggest={{ autoSuggest={{
@@ -271,8 +299,7 @@ const MetadataEditor = createClass({
clearAutoSuggestOnClick : true, clearAutoSuggestOnClick : true,
filterOn : ['value', 'detail', 'title'] filterOn : ['value', 'detail', 'title']
}} }}
> />
</Combobox>
<small>Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.</small> <small>Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.</small>
</div> </div>
@@ -345,7 +372,7 @@ const MetadataEditor = createClass({
placeholder='add tag' unique={true} placeholder='add tag' unique={true}
values={this.props.metadata.tags} values={this.props.metadata.tags}
onChange={(e)=>this.handleFieldChange('tags', e)} onChange={(e)=>this.handleFieldChange('tags', e)}
/> />
<div className='field systems'> <div className='field systems'>
<label>systems</label> <label>systems</label>
@@ -370,7 +397,7 @@ const MetadataEditor = createClass({
values={this.props.metadata.invitedAuthors} 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.']} 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)} onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}
/> />
<h2>Privacy</h2> <h2>Privacy</h2>

View File

@@ -1,9 +1,12 @@
@import 'naturalcrit/styles/colors.less'; @import 'naturalcrit/styles/colors.less';
.userThemeName {
padding-left: 10px;
padding-right: 10px;
}
.metadataEditor { .metadataEditor {
position : absolute; position : absolute;
z-index : 5;
box-sizing : border-box; box-sizing : border-box;
width : 100%; width : 100%;
height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this. height : calc(100vh - 54px); // 54px is the height of the navbar + snippet bar. probably a better way to dynamic get this.
@@ -71,8 +74,7 @@
border : 1px solid gray; border : 1px solid gray;
&:focus { outline : 1px solid #444444; } &:focus { outline : 1px solid #444444; }
} }
&.thumbnail { &.thumbnail, &.themes{
height : 1.4em;
label { line-height : 2.0em; } label { line-height : 2.0em; }
.value { .value {
overflow : hidden; overflow : hidden;
@@ -88,6 +90,17 @@
} }
} }
&.themes{
.value {
overflow : visible;
text-overflow : auto;
}
button {
padding-left: 5px;
padding-right: 5px;
}
}
&.description { &.description {
flex : 1; flex : 1;
textarea.value { textarea.value {
@@ -156,89 +169,73 @@
} }
.themes.field { .themes.field {
.navDropdownContainer { & .dropdown-container {
position : relative; position : relative;
z-index : 100; z-index : 100;
background-color : white; background-color : white;
&.disabled { }
font-style : italic; & .dropdown-options {
color : dimgray; overflow-y : visible;
background-color : darkgray; }
} .disabled {
& > div:first-child { font-style : italic;
padding : 3px 3px; color : dimgray;
background-color : inherit; background-color : darkgray;
border : 1px solid gray; }
i { float : right; } .item {
&:hover { position : relative;
color : white; padding : 3px 3px;
background-color : @blue; 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%);
} }
} }
.navDropdown .item > p {
width : 45%; .texture-container {
height : 1.1em; position : absolute;
overflow : hidden; top : 0;
text-overflow : ellipsis; left : 0;
white-space : nowrap; width : 100%;
} height : 100%;
.navDropdown { min-height : 100%;
position : absolute; overflow : hidden;
width : 100%; > img {
box-shadow : 0px 5px 10px rgba(0, 0, 0, 0.3); position : absolute;
.item { top : 0;
position : relative; right : 0;
padding : 3px 3px; width : 50%;
overflow : visible; min-height : 100%;
background-color : white; -webkit-mask-image : linear-gradient(90deg, transparent, black 20%);
border-top : 1px solid rgb(118, 118, 118); mask-image : linear-gradient(90deg, transparent, black 20%);
.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%);
}
}
&:hover {
color : white;
background-color : @blue;
}
&:hover > .preview { opacity : 1; }
.texture-container {
position : absolute;
top : 0;
left : 0;
width : 100%;
height : 100%;
min-height : 100%;
overflow : hidden;
> img {
position : absolute;
top : 0px;
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; }
} }
} }

View File

@@ -27,6 +27,19 @@ module.exports = {
(value)=>{ (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; 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.';
}
] ]
}; };

View File

@@ -116,6 +116,19 @@ const ErrorNavItem = createClass({
</Nav.item>; </Nav.item>;
} }
if(HBErrorCode === '10') {
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops!
<div className='errorContainer' onClick={clearError}>
Looks like the brew you have selected
as a theme is not tagged for use as a
theme. Verify that
brew <a className='lowercase' target='_blank' rel='noopener noreferrer' href={`/share/${response.body.brewId}`}>
{response.body.brewId}</a> has the <span className='lowercase'>meta:theme</span> tag!
</div>
</Nav.item>;
}
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'> return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
Oops! Oops!
<div className='errorContainer'> <div className='errorContainer'>

View File

@@ -379,7 +379,7 @@ const EditPage = createClass({
const title = `${this.props.brew.title} ${systems}`; const title = `${this.props.brew.title} ${systems}`;
const text = `Hey guys! I've been working on this homebrew. I'd love your feedback. Check it out. const text = `Hey guys! I've been working on this homebrew. I'd love your feedback. Check it out.
**[Homebrewery Link](${global.config.publicUrl}/share/${shareLink})**`; **[Homebrewery Link](${global.config.baseUrl}/share/${shareLink})**`;
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title.toWellFormed())}&text=${encodeURIComponent(text)}`; return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title.toWellFormed())}&text=${encodeURIComponent(text)}`;
}, },
@@ -410,7 +410,7 @@ const EditPage = createClass({
<Nav.item color='blue' href={`/share/${shareLink}`}> <Nav.item color='blue' href={`/share/${shareLink}`}>
view view
</Nav.item> </Nav.item>
<Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.publicUrl}/share/${shareLink}`);}}> <Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${shareLink}`);}}>
copy url copy url
</Nav.item> </Nav.item>
<Nav.item color='blue' href={this.getRedditLink()} newTab={true} rel='noopener noreferrer'> <Nav.item color='blue' href={this.getRedditLink()} newTab={true} rel='noopener noreferrer'>
@@ -443,6 +443,7 @@ const EditPage = createClass({
reportError={this.errorReported} reportError={this.errorReported}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
userThemes={this.props.userThemes} userThemes={this.props.userThemes}
themeBundle={this.state.themeBundle}
snippetBundle={this.state.themeBundle.snippets} snippetBundle={this.state.themeBundle.snippets}
updateBrew={this.updateBrew} updateBrew={this.updateBrew}
onCursorPageChange={this.handleEditorCursorPageChange} onCursorPageChange={this.handleEditorCursorPageChange}

View File

@@ -167,6 +167,14 @@ const errorIndex = (props)=>{
**Requested access:** ${props.brew.accessType} **Requested access:** ${props.brew.accessType}
**Brew ID:** ${props.brew.brewId}`, **Brew ID:** ${props.brew.brewId}`,
// Theme Not Valid
'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.`,
//account page when account is not defined //account page when account is not defined
'50' : dedent` '50' : dedent`

View File

@@ -233,6 +233,7 @@ const NewPage = createClass({
onMetaChange={this.handleMetaChange} onMetaChange={this.handleMetaChange}
renderer={this.state.brew.renderer} renderer={this.state.brew.renderer}
userThemes={this.props.userThemes} userThemes={this.props.userThemes}
themeBundle={this.state.themeBundle}
snippetBundle={this.state.themeBundle.snippets} snippetBundle={this.state.themeBundle.snippets}
onCursorPageChange={this.handleEditorCursorPageChange} onCursorPageChange={this.handleEditorCursorPageChange}
onViewPageChange={this.handleEditorViewPageChange} onViewPageChange={this.handleEditorViewPageChange}

View File

@@ -39,7 +39,6 @@
"test:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace", "test:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace",
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace", "test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
"test:non-breaking-spaces": "jest tests/markdown/non-breaking-spaces.test.js --verbose --noStackTrace", "test:non-breaking-spaces": "jest tests/markdown/non-breaking-spaces.test.js --verbose --noStackTrace",
"test:paragraph-justification": "jest tests/markdown/paragraph-justification.test.js --verbose --noStackTrace",
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace", "test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
"test:route": "jest tests/routes/static-pages.test.js --verbose", "test:route": "jest tests/routes/static-pages.test.js --verbose",
"test:safehtml": "jest tests/html/safeHTML.test.js --verbose", "test:safehtml": "jest tests/html/safeHTML.test.js --verbose",
@@ -85,9 +84,9 @@
] ]
}, },
"dependencies": { "dependencies": {
"@babel/core": "^7.26.8", "@babel/core": "^7.26.9",
"@babel/plugin-transform-runtime": "^7.26.8", "@babel/plugin-transform-runtime": "^7.26.9",
"@babel/preset-env": "^7.26.8", "@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3", "@babel/preset-react": "^7.26.3",
"@googleapis/drive": "^8.14.0", "@googleapis/drive": "^8.14.0",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
@@ -114,11 +113,11 @@
"marked-extended-tables": "^1.1.0", "marked-extended-tables": "^1.1.0",
"marked-gfm-heading-id": "^4.0.1", "marked-gfm-heading-id": "^4.0.1",
"marked-smartypants-lite": "^1.0.3", "marked-smartypants-lite": "^1.0.3",
"marked-subsuper-text": "^1.0.1", "marked-subsuper-text": "^1.0.3",
"markedLegacy": "npm:marked@^0.3.19", "markedLegacy": "npm:marked@^0.3.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongoose": "^8.10.0", "mongoose": "^8.10.1",
"nanoid": "5.0.9", "nanoid": "5.1.0",
"nconf": "^0.12.1", "nconf": "^0.12.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -131,10 +130,10 @@
"devDependencies": { "devDependencies": {
"@stylistic/stylelint-plugin": "^3.1.2", "@stylistic/stylelint-plugin": "^3.1.2",
"babel-plugin-transform-import-meta": "^2.3.2", "babel-plugin-transform-import-meta": "^2.3.2",
"eslint": "^9.20.0", "eslint": "^9.20.1",
"eslint-plugin-jest": "^28.11.0", "eslint-plugin-jest": "^28.11.0",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",
"globals": "^15.14.0", "globals": "^15.15.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expect-message": "^1.1.3", "jest-expect-message": "^1.1.3",
"jsdom-global": "^3.0.2", "jsdom-global": "^3.0.2",

View File

@@ -93,7 +93,7 @@ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
/* Cleans `<script` and `</script>` from the "text" field of a brew */ /* Cleans `<script` and `</script>` from the "text" field of a brew */
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{ router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
console.log(`[ADMIN] Cleaning script tags from ShareID ${req.params.id}`); console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Cleaning script tags from ShareID ${req.params.id}`);
function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');}; function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');};
@@ -114,6 +114,18 @@ router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin',
return await HomebrewAPI.updateBrew(req, res); return await HomebrewAPI.updateBrew(req, res);
}); });
/* Get list of a user's documents */
router.get('/admin/user/list/:user', mw.adminOnly, async (req, res)=>{
const username = req.params.user;
const fields = { _id: 0, text: 0, textBin: 0 }; // Remove unnecessary fields from document lists
console.log(`[ADMIN: ${req.account?.username || 'Not Logged In'}] Get brew list for ${username}`);
const brews = await HomebrewModel.getByUser(username, true, fields);
return res.json(brews);
});
/* Compresses the "text" field of a brew to binary */ /* Compresses the "text" field of a brew to binary */
router.put('/admin/compress/:id', (req, res)=>{ router.put('/admin/compress/:id', (req, res)=>{
HomebrewModel.findOne({ _id: req.params.id }) HomebrewModel.findOne({ _id: req.params.id })
@@ -135,7 +147,6 @@ router.put('/admin/compress/:id', (req, res)=>{
}); });
}); });
router.get('/admin/stats', mw.adminOnly, async (req, res)=>{ router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
try { try {
const totalBrewsCount = await HomebrewModel.countDocuments({}); const totalBrewsCount = await HomebrewModel.countDocuments({});

View File

@@ -552,6 +552,7 @@ const renderPage = async (req, res)=>{
const configuration = { const configuration = {
local : isLocalEnvironment, local : isLocalEnvironment,
publicUrl : config.get('publicUrl') ?? '', publicUrl : config.get('publicUrl') ?? '',
baseUrl : `${req.protocol}://${req.get('host')}`,
environment : nodeEnv, environment : nodeEnv,
deployment : config.get('heroku_app_name') ?? '' deployment : config.get('heroku_app_name') ?? ''
}; };

View File

@@ -1,6 +1,6 @@
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import _ from 'lodash'; import _ from 'lodash';
import {model as HomebrewModel} from './homebrew.model.js'; import { model as HomebrewModel } from './homebrew.model.js';
import express from 'express'; import express from 'express';
import zlib from 'zlib'; import zlib from 'zlib';
import GoogleActions from './googleActions.js'; import GoogleActions from './googleActions.js';
@@ -279,6 +279,8 @@ const api = {
let currentTheme; let currentTheme;
const completeStyles = []; const completeStyles = [];
const completeSnippets = []; const completeSnippets = [];
let themeName;
let themeAuthor;
while (req.params.id) { while (req.params.id) {
//=== User Themes ===// //=== User Themes ===//
@@ -292,6 +294,10 @@ const api = {
currentTheme = req.brew; currentTheme = req.brew;
splitTextStyleAndMetadata(currentTheme); splitTextStyleAndMetadata(currentTheme);
if(!currentTheme.tags.some(tag => tag === "meta:theme" || tag === "meta:Theme"))
throw { brewId: req.params.id, name: 'Invalid Theme Selected', message: 'Selected theme does not have the meta:theme tag', status: 422, HBErrorCode: '10' };
themeName ??= currentTheme.title;
themeAuthor ??= currentTheme.authors?.[0];
// If there is anything in the snippets or style members, append them to the appropriate array // If there is anything in the snippets or style members, append them to the appropriate array
if(currentTheme?.snippets) completeSnippets.push(JSON.parse(currentTheme.snippets)); if(currentTheme?.snippets) completeSnippets.push(JSON.parse(currentTheme.snippets));
@@ -301,6 +307,7 @@ const api = {
req.params.renderer = currentTheme.renderer; req.params.renderer = currentTheme.renderer;
} else { } else {
//=== Static Themes ===// //=== Static Themes ===//
themeName ??= req.params.id;
const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client
const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`; const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`;
completeSnippets.push(localSnippets); completeSnippets.push(localSnippets);
@@ -313,7 +320,9 @@ const api = {
const returnObj = { const returnObj = {
// Reverse the order of the arrays so they are listed oldest parent to youngest child. // Reverse the order of the arrays so they are listed oldest parent to youngest child.
styles : completeStyles.reverse(), styles : completeStyles.reverse(),
snippets : completeSnippets.reverse() snippets : completeSnippets.reverse(),
name : themeName,
author : themeAuthor
}; };
res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Type', 'application/json');

View File

@@ -576,7 +576,7 @@ brew`);
describe('Theme bundle', ()=>{ describe('Theme bundle', ()=>{
it('should return Theme Bundle for a User Theme', async ()=>{ it('should return Theme Bundle for a User Theme', async ()=>{
const brews = { const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: null, shareId: 'userThemeAID', style: 'User Theme A Style' } userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: null, shareId: 'userThemeAID', style: 'User Theme A Style', tags: ['meta:theme'], authors: ['authorName'] }
}; };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew })); const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
@@ -587,6 +587,8 @@ brew`);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ expect(res.send).toHaveBeenCalledWith({
name : 'User Theme A',
author : 'authorName',
styles : ['/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'], styles : ['/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'],
snippets : [] snippets : []
}); });
@@ -594,9 +596,9 @@ brew`);
it('should return Theme Bundle for nested User Themes', async ()=>{ it('should return Theme Bundle for nested User Themes', async ()=>{
const brews = { const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' }, userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style', tags: ['meta:theme'], authors: ['authorName'] },
userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' }, userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style', tags: ['meta:theme'], authors: ['authorName'] },
userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: null, shareId: 'userThemeCID', style: 'User Theme C Style' } userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: null, shareId: 'userThemeCID', style: 'User Theme C Style', tags: ['meta:theme'], authors: ['authorName'] }
}; };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew })); const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
@@ -607,6 +609,8 @@ brew`);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ expect(res.send).toHaveBeenCalledWith({
name : 'User Theme A',
author : 'authorName',
styles : [ styles : [
'/* From Brew: https://localhost/share/userThemeCID */\n\nUser Theme C Style', '/* From Brew: https://localhost/share/userThemeCID */\n\nUser Theme C Style',
'/* From Brew: https://localhost/share/userThemeBID */\n\nUser Theme B Style', '/* From Brew: https://localhost/share/userThemeBID */\n\nUser Theme B Style',
@@ -623,6 +627,8 @@ brew`);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ expect(res.send).toHaveBeenCalledWith({
name : '5ePHB',
author : undefined,
styles : [ styles : [
`/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`, `/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`,
`/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");` `/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`
@@ -636,9 +642,9 @@ brew`);
it('should return Theme Bundle for nested User and Static Themes together', async ()=>{ it('should return Theme Bundle for nested User and Static Themes together', async ()=>{
const brews = { const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' }, userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style', tags: ['meta:theme'], authors: ['authorName'] },
userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' }, userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style', tags: ['meta:theme'], authors: ['authorName'] },
userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: '5eDMG', shareId: 'userThemeCID', style: 'User Theme C Style' } userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: '5eDMG', shareId: 'userThemeCID', style: 'User Theme C Style', tags: ['meta:theme'], authors: ['authorName'] }
}; };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew })); const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
@@ -649,6 +655,8 @@ brew`);
expect(res.status).toHaveBeenCalledWith(200); expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ expect(res.send).toHaveBeenCalledWith({
name : 'User Theme A',
author : 'authorName',
styles : [ styles : [
`/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`, `/* From Theme Blank */\n\n@import url("/themes/V3/Blank/style.css");`,
`/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`, `/* From Theme 5ePHB */\n\n@import url("/themes/V3/5ePHB/style.css");`,
@@ -665,9 +673,9 @@ brew`);
}); });
}); });
it('should fail for an invalid Theme in the chain', async()=>{ it('should fail for a missing Theme in the chain', async()=>{
const brews = { const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'missingTheme', shareId: 'userThemeAID', style: 'User Theme A Style' }, userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'missingTheme', shareId: 'userThemeAID', style: 'User Theme A Style', tags: ['meta:theme'], authors: ['authorName'] },
}; };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew })); const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
@@ -686,6 +694,27 @@ brew`);
name : 'ThemeLoad Error', name : 'ThemeLoad Error',
status : 404 }); status : 404 });
}); });
it('should fail for a User Theme not tagged with meta:theme', async ()=>{
const brews = {
userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: null, shareId: 'userThemeAID', style: 'User Theme A Style' }
};
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
model.get = jest.fn((getParams)=>toBrewPromise(brews[getParams.shareId]));
const req = { params: { renderer: 'V3', id: 'userThemeAID' }, get: ()=>{ return 'localhost'; }, protocol: 'https' };
let err;
await api.getThemeBundle(req, res)
.catch((e)=>err = e);
expect(err).toEqual({
HBErrorCode : '10',
brewId : 'userThemeAID',
message : 'Selected theme does not have the meta:theme tag',
name : 'Invalid Theme Selected',
status : 422 });
});
}); });
describe('deleteBrew', ()=>{ describe('deleteBrew', ()=>{

View File

@@ -44,13 +44,19 @@ const fetchThemeBundle = async (obj, renderer, theme)=>{
.catch((err)=>{ .catch((err)=>{
obj.setState({ error: err }); obj.setState({ error: err });
}); });
if(!res) return; if(!res) {
obj.setState((prevState)=>({
...prevState,
themeBundle : {}
}));
return;
}
const themeBundle = res.body; const themeBundle = res.body;
themeBundle.joinedStyles = themeBundle.styles.map((style)=>`<style>${style}</style>`).join('\n\n'); themeBundle.joinedStyles = themeBundle.styles.map((style)=>`<style>${style}</style>`).join('\n\n');
obj.setState((prevState)=>({ obj.setState((prevState)=>({
...prevState, ...prevState,
themeBundle : themeBundle themeBundle : themeBundle,
error : null
})); }));
}; };