diff --git a/client/components/combobox.jsx b/client/components/combobox.jsx
new file mode 100644
index 000000000..a6e699dcf
--- /dev/null
+++ b/client/components/combobox.jsx
@@ -0,0 +1,129 @@
+const React = require('react');
+const createClass = require('create-react-class');
+const _ = require('lodash');
+const cx = require('classnames');
+require('./combobox.less');
+
+const Combobox = createClass({
+ 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({
+ 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(e){
+ this.setState({
+ value : e.currentTarget.getAttribute('data-value')
+ }, ()=>{this.props.onSelect(this.state.value);});
+ ;
+ },
+ renderTextInput : function(){
+ return (
+
{this.handleDropdown(true);} : undefined}
+ onClick= {this.props.trigger == 'click' ? ()=>{this.handleDropdown(true);} : undefined}>
+ this.handleInput(e)}
+ value={this.state.value || ''}
+ placeholder={this.props.placeholder}
+ onBlur={(e)=>{
+ if(!e.target.checkValidity()){
+ this.setState({
+ value : this.props.default
+ }, ()=>this.props.onEntry(e));
+ }
+ }}
+ />
+
+ );
+ },
+ 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());
+ } else if(suggestMethod === 'startsWith'){
+ return item.props[attr]?.toLowerCase().startsWith(this.state.value.toLowerCase());
+ }
+ });
+ return children;
+ });
+ dropdownChildren = _.uniq(filteredArrays.flat(1));
+ }
+
+ return (
+
+ {dropdownChildren}
+
+ );
+ },
+ render : function () {
+ const dropdownChildren = this.state.options.map((child, i)=>{
+ const clone = React.cloneElement(child, { onClick: (e)=>this.handleSelect(e) });
+ return clone;
+ });
+ return (
+ {this.handleDropdown(false);} : undefined}>
+ {this.renderTextInput()}
+ {this.renderDropdown(dropdownChildren)}
+
+ );
+ }
+});
+
+module.exports = Combobox;
diff --git a/client/components/combobox.less b/client/components/combobox.less
new file mode 100644
index 000000000..3810a874e
--- /dev/null
+++ b/client/components/combobox.less
@@ -0,0 +1,50 @@
+.dropdown-container {
+ position:relative;
+ input {
+ width: 100%;
+ }
+ .dropdown-options {
+ position:absolute;
+ background-color: white;
+ z-index: 100;
+ width: 100%;
+ border: 1px solid gray;
+ overflow-y: auto;
+ max-height: 200px;
+
+ &::-webkit-scrollbar {
+ width: 14px;
+ }
+ &::-webkit-scrollbar-track {
+ background: #ffffff;
+ }
+ &::-webkit-scrollbar-thumb {
+ background-color: #949494;
+ border-radius: 10px;
+ border: 3px solid #ffffff;
+ }
+
+ .item {
+ position:relative;
+ font-size: 11px;
+ font-family: Open Sans;
+ padding: 5px;
+ cursor: default;
+ margin: 0 3px;
+ //border-bottom: 1px solid darkgray;
+ &:hover {
+ filter: brightness(120%);
+ background-color: rgb(163, 163, 163);
+ }
+ .detail {
+ width:100%;
+ text-align: left;
+ color: rgb(124, 124, 124);
+ font-style:italic;
+ font-size: 9px;
+ }
+ }
+
+ }
+
+}
diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx
index a41e01228..27fef7e16 100644
--- a/client/homebrew/brewRenderer/brewRenderer.jsx
+++ b/client/homebrew/brewRenderer/brewRenderer.jsx
@@ -27,6 +27,7 @@ const BrewRenderer = createClass({
style : '',
renderer : 'legacy',
theme : '5ePHB',
+ lang : '',
errors : []
};
},
@@ -190,7 +191,6 @@ const BrewRenderer = createClass({
const rendererPath = this.props.renderer == 'V3' ? 'V3' : 'Legacy';
const themePath = this.props.theme ?? '5ePHB';
const baseThemePath = Themes[rendererPath][themePath].baseTheme;
-
return (
{!this.state.isMounted
@@ -223,7 +223,7 @@ const BrewRenderer = createClass({
&&
<>
{this.renderStyle()}
-
+
{this.renderPages()}
>
diff --git a/client/homebrew/editor/metadataEditor/metadataEditor.jsx b/client/homebrew/editor/metadataEditor/metadataEditor.jsx
index 3d77f557f..bf09e40e4 100644
--- a/client/homebrew/editor/metadataEditor/metadataEditor.jsx
+++ b/client/homebrew/editor/metadataEditor/metadataEditor.jsx
@@ -6,6 +6,7 @@ const _ = require('lodash');
const cx = require('classnames');
const request = require('../../utils/request-middleware.js');
const Nav = require('naturalcrit/nav/nav.jsx');
+const Combobox = require('client/components/combobox.jsx');
const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx');
const Themes = require('themes/themes.json');
@@ -35,7 +36,8 @@ const MetadataEditor = createClass({
authors : [],
systems : [],
renderer : 'legacy',
- theme : '5ePHB'
+ theme : '5ePHB',
+ lang : 'en'
},
onChange : ()=>{},
reportError : ()=>{}
@@ -76,6 +78,7 @@ const MetadataEditor = createClass({
const errMessage = validationErr.map((err)=>{
return `- ${err}`;
}).join('\n');
+
callIfExists(e.target, 'setCustomValidity', errMessage);
callIfExists(e.target, 'reportValidity');
}
@@ -111,6 +114,11 @@ const MetadataEditor = createClass({
this.props.onChange(this.props.metadata);
},
+ 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;
@@ -224,6 +232,46 @@ const MetadataEditor = createClass({
;
},
+ 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
+ {`${code}`}
+
{`${localName.of(code)}`}
+
;
+ });
+ };
+
+ const debouncedHandleFieldChange = _.debounce(this.handleFieldChange, 500);
+
+ return
+
+
+ this.handleLanguage(value)}
+ onEntry={(e)=> { e.target.setCustomValidity(''); //Clear the validation popup while typing
+ debouncedHandleFieldChange('lang', e);
+ }}
+ options={listLanguages()}
+ autoSuggest={{
+ suggestMethod : 'startsWith',
+ clearAutoSuggestOnClick : true,
+ filterOn : ['data-value', 'data-detail', 'title']
+ }}
+ >
+
+ Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.
+
+
+
;
+ },
+
renderRenderOptions : function(){
if(!global.enable_v3) return;
@@ -301,6 +349,8 @@ const MetadataEditor = createClass({
+ {this.renderLanguageDropdown()}
+
{this.renderThemeDropdown()}
{this.renderRenderOptions()}
@@ -315,7 +365,7 @@ const MetadataEditor = createClass({
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
placeholder='invite author' unique={true}
values={this.props.metadata.invitedAuthors}
- notes={['Invited authors 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)}/>
diff --git a/client/homebrew/editor/metadataEditor/metadataEditor.less b/client/homebrew/editor/metadataEditor/metadataEditor.less
index 001285d11..5678c2554 100644
--- a/client/homebrew/editor/metadataEditor/metadataEditor.less
+++ b/client/homebrew/editor/metadataEditor/metadataEditor.less
@@ -36,11 +36,15 @@
flex: 5 0 200px;
gap: 10px;
}
+
+
+
.field{
display : flex;
flex-wrap : wrap;
width : 100%;
min-width : 200px;
+ position : relative;
&>label{
width : 80px;
font-size : 11px;
@@ -57,6 +61,9 @@
}
input[type='text'], textarea {
border : 1px solid gray;
+ &:focus {
+ outline: 1px solid #444;
+ }
}
&.thumbnail{
height : 1.4em;
@@ -88,9 +95,15 @@
}
}
+ &.language .language-dropdown {
+ max-width : 150px;
+ z-index : 200;
+ }
small {
- font-size : 0.6em;
- font-style : italic;
+ font-size : 0.6em;
+ font-style : italic;
+ line-height : 1.4em;
+ display : inline-block;
}
}
@@ -159,7 +172,7 @@
.navDropdownContainer {
background-color : white;
position : relative;
- z-index : 500;
+ z-index : 100;
&.disabled {
font-style :italic;
font-style : italic;
diff --git a/client/homebrew/editor/metadataEditor/validations.js b/client/homebrew/editor/metadataEditor/validations.js
index 22f9e918b..32c8131f6 100644
--- a/client/homebrew/editor/metadataEditor/validations.js
+++ b/client/homebrew/editor/metadataEditor/validations.js
@@ -23,9 +23,9 @@ module.exports = {
}
}
],
- language : [
+ lang : [
(value)=>{
- return new RegExp(/[a-z]{2,3}(-.*)?/).test(value || '') === false ? '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;
}
]
};
diff --git a/client/homebrew/homebrew.jsx b/client/homebrew/homebrew.jsx
index 5fa225945..f6dccbdb7 100644
--- a/client/homebrew/homebrew.jsx
+++ b/client/homebrew/homebrew.jsx
@@ -47,6 +47,7 @@ const Homebrew = createClass({
editId : null,
createdAt : null,
updatedAt : null,
+ lang : ''
}
};
},
diff --git a/client/homebrew/navbar/account.navitem.jsx b/client/homebrew/navbar/account.navitem.jsx
index 7347e084b..6b412c368 100644
--- a/client/homebrew/navbar/account.navitem.jsx
+++ b/client/homebrew/navbar/account.navitem.jsx
@@ -63,7 +63,7 @@ const Account = createClass({
if(global.account){
return
diff --git a/client/homebrew/navbar/navbar.less b/client/homebrew/navbar/navbar.less
index 3bd6a66e1..b1db6ae30 100644
--- a/client/homebrew/navbar/navbar.less
+++ b/client/homebrew/navbar/navbar.less
@@ -187,4 +187,7 @@
.account.navItem{
min-width: 100px;
}
+ .account.username.navItem{
+ text-transform: none;
+ }
}
diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx
index 0ce4db729..94d5aef3b 100644
--- a/client/homebrew/pages/editPage/editPage.jsx
+++ b/client/homebrew/pages/editPage/editPage.jsx
@@ -398,7 +398,14 @@ const EditPage = createClass({
reportError={this.errorReported}
renderer={this.state.brew.renderer}
/>
-
+
;
diff --git a/client/homebrew/pages/newPage/newPage.jsx b/client/homebrew/pages/newPage/newPage.jsx
index 3e96ff5c0..0f18d42be 100644
--- a/client/homebrew/pages/newPage/newPage.jsx
+++ b/client/homebrew/pages/newPage/newPage.jsx
@@ -61,6 +61,7 @@ const NewPage = createClass({
// brew.description = metaStorage?.description || this.state.brew.description;
brew.renderer = metaStorage?.renderer ?? brew.renderer;
brew.theme = metaStorage?.theme ?? brew.theme;
+ brew.lang = metaStorage?.lang ?? brew.lang;
this.setState({
brew : brew
@@ -70,7 +71,7 @@ const NewPage = createClass({
localStorage.setItem(BREWKEY, brew.text);
if(brew.style)
localStorage.setItem(STYLEKEY, brew.style);
- localStorage.setItem(METAKEY, JSON.stringify({ 'renderer': brew.renderer, 'theme': brew.theme }));
+ localStorage.setItem(METAKEY, JSON.stringify({ 'renderer': brew.renderer, 'theme': brew.theme, 'lang': brew.lang }));
},
componentWillUnmount : function() {
document.removeEventListener('keydown', this.handleControlKeys);
@@ -114,13 +115,16 @@ const NewPage = createClass({
handleMetaChange : function(metadata){
this.setState((prevState)=>({
brew : { ...prevState.brew, ...metadata },
- }));
- localStorage.setItem(METAKEY, JSON.stringify({
- // 'title' : this.state.brew.title,
- // 'description' : this.state.brew.description,
- 'renderer' : this.state.brew.renderer,
- 'theme' : this.state.brew.theme
- }));
+ }), ()=>{
+ localStorage.setItem(METAKEY, JSON.stringify({
+ // 'title' : this.state.brew.title,
+ // 'description' : this.state.brew.description,
+ 'renderer' : this.state.brew.renderer,
+ 'theme' : this.state.brew.theme,
+ 'lang' : this.state.brew.lang
+ }));
+ });
+ ;
},
save : async function(){
@@ -211,7 +215,7 @@ const NewPage = createClass({
onMetaChange={this.handleMetaChange}
renderer={this.state.brew.renderer}
/>
-
+
;
diff --git a/package.json b/package.json
index 4c02e6cfe..2a659af70 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,9 @@
"shared",
"server"
],
+ "coveragePathIgnorePatterns": [
+ "build/*"
+ ],
"coverageThreshold" : {
"global" : {
"statements" : 25,
@@ -68,11 +71,11 @@
]
},
"dependencies": {
- "@babel/core": "^7.21.0",
+ "@babel/core": "^7.21.3",
"@babel/plugin-transform-runtime": "^7.21.0",
"@babel/preset-env": "^7.19.4",
"@babel/preset-react": "^7.18.6",
- "@googleapis/drive": "^4.0.2",
+ "@googleapis/drive": "^5.0.1",
"body-parser": "^1.20.2",
"classnames": "^2.3.2",
"codemirror": "^5.65.6",
@@ -82,7 +85,7 @@
"express": "^4.18.2",
"express-async-handler": "^1.2.0",
"express-static-gzip": "2.1.7",
- "fs-extra": "11.1.0",
+ "fs-extra": "11.1.1",
"js-yaml": "^4.1.0",
"jwt-simple": "^0.5.6",
"less": "^3.13.1",
@@ -94,17 +97,17 @@
"mongoose": "^7.0.2",
"nanoid": "3.3.4",
"nconf": "^0.12.0",
- "npm": "^8.10.0",
+ "npm": "^9.6.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-frame-component": "4.1.3",
- "react-router-dom": "6.8.2",
+ "react-router-dom": "6.9.0",
"sanitize-filename": "1.6.3",
"superagent": "^6.1.0",
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
},
"devDependencies": {
- "eslint": "^8.35.0",
+ "eslint": "^8.36.0",
"eslint-plugin-react": "^7.32.2",
"jest": "^29.5.0",
"supertest": "^6.3.3"
diff --git a/server/app.js b/server/app.js
index 59aac2d9b..5b153d115 100644
--- a/server/app.js
+++ b/server/app.js
@@ -23,7 +23,7 @@ const splitTextStyleAndMetadata = (brew)=>{
const index = brew.text.indexOf('```\n\n');
const metadataSection = brew.text.slice(12, index - 1);
const metadata = yaml.load(metadataSection);
- Object.assign(brew, _.pick(metadata, ['title', 'description', 'tags', 'systems', 'renderer', 'theme']));
+ Object.assign(brew, _.pick(metadata, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang']));
brew.text = brew.text.slice(index + 5);
}
if(brew.text.startsWith('```css')) {
@@ -225,6 +225,7 @@ app.get('/user/:username', async (req, res, next)=>{
'pageCount',
'description',
'authors',
+ 'lang',
'published',
'views',
'shareId',
diff --git a/server/brewDefaults.js b/server/brewDefaults.js
index 30798cea7..62fc6e671 100644
--- a/server/brewDefaults.js
+++ b/server/brewDefaults.js
@@ -15,6 +15,7 @@ const DEFAULT_BREW = {
authors : [],
tags : [],
systems : [],
+ lang : 'en',
thumbnail : '',
views : 0,
published : false,
diff --git a/server/googleActions.js b/server/googleActions.js
index dcdfbfd0b..095004cfa 100644
--- a/server/googleActions.js
+++ b/server/googleActions.js
@@ -129,7 +129,9 @@ const GoogleActions = {
description : file.description,
views : parseInt(file.properties.views),
published : file.properties.published ? file.properties.published == 'true' : false,
- systems : []
+ systems : [],
+ lang : file.properties.lang,
+ thumbnail : file.properties.thumbnail
};
});
return brews;
@@ -149,7 +151,8 @@ const GoogleActions = {
editId : brew.editId || nanoid(12),
pageCount : brew.pageCount,
renderer : brew.renderer || 'legacy',
- isStubbed : true
+ isStubbed : true,
+ lang : brew.lang || 'en'
}
},
media : {
@@ -187,7 +190,8 @@ const GoogleActions = {
pageCount : brew.pageCount,
renderer : brew.renderer || 'legacy',
isStubbed : true,
- version : 1
+ version : 1,
+ lang : brew.lang || 'en'
}
};
@@ -255,6 +259,7 @@ const GoogleActions = {
description : obj.data.description,
systems : obj.data.properties.systems ? obj.data.properties.systems.split(',') : [],
authors : [],
+ lang : obj.data.properties.lang,
published : obj.data.properties.published ? obj.data.properties.published == 'true' : false,
trashed : obj.data.trashed,
diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js
index 3f3eb9794..5ab6ac4fc 100644
--- a/server/homebrew.api.spec.js
+++ b/server/homebrew.api.spec.js
@@ -62,6 +62,7 @@ describe('Tests for api', ()=>{
description : 'this is a description',
tags : ['something', 'fun'],
systems : ['D&D 5e'],
+ lang : 'en',
renderer : 'v3',
theme : 'phb',
published : true,
@@ -255,6 +256,7 @@ If you believe you should have access to this brew, ask the file owner to invite
pageCount : 1,
published : false,
renderer : 'legacy',
+ lang : 'en',
shareId : undefined,
systems : [],
tags : [],
@@ -448,6 +450,7 @@ brew`);
pageCount : 1,
published : false,
renderer : 'V3',
+ lang : 'en',
shareId : expect.any(String),
style : undefined,
systems : [],
@@ -506,6 +509,7 @@ brew`);
pageCount : undefined,
published : false,
renderer : undefined,
+ lang : 'en',
shareId : expect.any(String),
googleId : expect.any(String),
style : undefined,
diff --git a/server/homebrew.model.js b/server/homebrew.model.js
index 41f3b8716..104309a28 100644
--- a/server/homebrew.model.js
+++ b/server/homebrew.model.js
@@ -15,6 +15,7 @@ const HomebrewSchema = mongoose.Schema({
description : { type: String, default: '' },
tags : [String],
systems : [String],
+ lang : { type: String, default: 'en' },
renderer : { type: String, default: '' },
authors : [String],
invitedAuthors : [String],