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],