diff --git a/.circleci/config.yml b/.circleci/config.yml index c195df81c..2025e8fe7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -73,9 +73,6 @@ jobs: - run: name: Test - Non-Breaking Spaces command: npm run test:non-breaking-spaces - - run: - name: Test - Paragraph Justification - command: npm run test:paragraph-justification - run: name: Test - Variables command: npm run test:variables diff --git a/client/admin/admin.jsx b/client/admin/admin.jsx index f2f2667a4..95c296259 100644 --- a/client/admin/admin.jsx +++ b/client/admin/admin.jsx @@ -1,47 +1,48 @@ -require('./admin.less'); -const React = require('react'); -const createClass = require('create-react-class'); - +import './admin.less'; +import React, { useEffect, useState } from 'react'; const BrewUtils = require('./brewUtils/brewUtils.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({ - getDefaultProps : function() { - return {}; - }, +const Admin = ()=>{ + const [currentTab, setCurrentTab] = useState('brew'); - getInitialState : function(){ - return ({ - currentTab : 'brew' - }); - }, + useEffect(()=>{ + setCurrentTab(localStorage.getItem('hbAdminTab')); + }, []); - handleClick : function(newTab){ - if(this.state.currentTab === newTab) return; - this.setState({ - currentTab : newTab - }); - }, + useEffect(()=>{ + localStorage.setItem('hbAdminTab', currentTab); + }, [currentTab]); - render : function(){ - return
+ return ( +
- homebrewery admin + The Homebrewery Admin Page + back to homepage
- {this.state.currentTab==='brew' && } - {this.state.currentTab==='notifications' && } + {currentTab === 'brew' && } + {currentTab === 'notifications' && } + {currentTab === 'authors' && }
-
; - } -}); +
+ ); +}; module.exports = Admin; diff --git a/client/admin/admin.less b/client/admin/admin.less index c6c9b4662..1955f8a93 100644 --- a/client/admin/admin.less +++ b/client/admin/admin.less @@ -22,7 +22,7 @@ body { } :where(.admin) { - + padding-bottom : 50px; header { padding : 20px 0px; margin-bottom : 30px; @@ -30,6 +30,7 @@ body { color : white; background-color : @red; i { margin-right : 30px; } + a { float : right; } } hr { margin : 30px 0px; } @@ -48,21 +49,23 @@ body { } dl { - @maxItemWidth : 132px; + display : grid; + grid-template-columns : 120px 1fr; + row-gap : 10px; + align-items : center; + justify-items : start; + padding-top : 0.5em; dt { - float : left; - width : @maxItemWidth; - clear : left; - text-align : right; + float : left; + clear : left; + height : fit-content; + font-weight : 900; + text-align : right; &::after { content : ' : '; } } - dd { - height : 1em; - padding : 0 0 0.5em 0; - margin-left : @maxItemWidth + 6px; - } + dd { height : fit-content; } } - + .tabs button { margin-right : 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 { - background: rgb(178, 54, 54); - color:white; - font-weight: 900; - margin-block:10px; - padding:10px; + float : right; + padding : 10px; + margin-block : 10px; + font-weight : 900; + color : white; + background : rgb(178, 54, 54); } } diff --git a/client/admin/authorUtils/authorLookup/authorLookup.jsx b/client/admin/authorUtils/authorLookup/authorLookup.jsx new file mode 100644 index 000000000..abdece6f7 --- /dev/null +++ b/client/admin/authorUtils/authorLookup/authorLookup.jsx @@ -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 <> +

Results

+

None found.

+ ; + + return <> +

{`Results - ${results.length} brews` }

+ + + + + + + + + + + + {results + .sort((a, b)=>{ // Sort brews from most recently updated + if(a.updatedAt > b.updatedAt) return -1; + return 1; + }) + .map((brew, idx)=>{ + return + + + + + + ; + })} + +
TitleShareEditLast UpdateStorage
{brew.title}{brew.shareId}{brew.editId}{brew.updatedAt}{brew.googleId ? 'Google' : 'Homebrewery'}
+ ; + }; + + const handleKeyPress = (evt)=>{ + if(evt.key === 'Enter') return lookup(); + }; + + const handleChange = (evt)=>{ + setAuthor(evt.target.value); + }; + + return ( +
+
+

Author Lookup

+ +
+
+ {renderResults()} +
+
+ ); +}; + +module.exports = authorLookup; diff --git a/client/admin/authorUtils/authorLookup/authorLookup.less b/client/admin/authorUtils/authorLookup/authorLookup.less new file mode 100644 index 000000000..8fdd56d04 --- /dev/null +++ b/client/admin/authorUtils/authorLookup/authorLookup.less @@ -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; } + } + } + +} \ No newline at end of file diff --git a/client/admin/authorUtils/authorUtils.jsx b/client/admin/authorUtils/authorUtils.jsx new file mode 100644 index 000000000..a96eea528 --- /dev/null +++ b/client/admin/authorUtils/authorUtils.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import AuthorLookup from './authorLookup/authorLookup.jsx'; + +const authorUtils = ()=>{ + return ( +
+ +
+ ); +}; + +module.exports = authorUtils; \ No newline at end of file diff --git a/client/admin/brewUtils/brewCleanup/brewCleanup.jsx b/client/admin/brewUtils/brewCleanup/brewCleanup.jsx index a166ae112..d4b17c570 100644 --- a/client/admin/brewUtils/brewCleanup/brewCleanup.jsx +++ b/client/admin/brewUtils/brewCleanup/brewCleanup.jsx @@ -1,10 +1,8 @@ -require('./brewCleanup.less'); const React = require('react'); const createClass = require('create-react-class'); const request = require('superagent'); - const BrewCleanup = createClass({ displayName : 'BrewCleanup', getDefaultProps(){ @@ -39,9 +37,9 @@ const BrewCleanup = createClass({ if(!this.state.primed) return; if(!this.state.count){ - return
No Matching Brews found.
; + return
No Matching Brews found.
; } - return
+ return
; }, render(){ - return
+ return

Brew Cleanup

Removes very short brews to tidy up the database

@@ -65,7 +63,7 @@ const BrewCleanup = createClass({ {this.renderPrimed()} {this.state.error - &&
{this.state.error.toString()}
+ &&
{this.state.error.toString()}
}
; } diff --git a/client/admin/brewUtils/brewCleanup/brewCleanup.less b/client/admin/brewUtils/brewCleanup/brewCleanup.less deleted file mode 100644 index 16fc98957..000000000 --- a/client/admin/brewUtils/brewCleanup/brewCleanup.less +++ /dev/null @@ -1,9 +0,0 @@ -.BrewCleanup { - .removeBox { - margin-top : 20px; - button { - margin-right : 10px; - background-color : @red; - } - } -} \ No newline at end of file diff --git a/client/admin/brewUtils/brewCompress/brewCompress.jsx b/client/admin/brewUtils/brewCompress/brewCompress.jsx index 2c8e5b023..ccb59e027 100644 --- a/client/admin/brewUtils/brewCompress/brewCompress.jsx +++ b/client/admin/brewUtils/brewCompress/brewCompress.jsx @@ -1,10 +1,7 @@ -require('./brewCompress.less'); const React = require('react'); const createClass = require('create-react-class'); - const request = require('superagent'); - const BrewCompress = createClass({ displayName : 'BrewCompress', getDefaultProps(){ @@ -53,9 +50,9 @@ const BrewCompress = createClass({ if(!this.state.primed) return; if(!this.state.count){ - return
No Matching Brews found.
; + return
No Matching Brews found.
; } - return
+ return
; }, render(){ - return
+ return

Brew Compression

Compresses the text in brews to binary

diff --git a/client/admin/brewUtils/brewCompress/brewCompress.less b/client/admin/brewUtils/brewCompress/brewCompress.less deleted file mode 100644 index 8668e9280..000000000 --- a/client/admin/brewUtils/brewCompress/brewCompress.less +++ /dev/null @@ -1,9 +0,0 @@ -.BrewCompress { - .removeBox { - margin-top : 20px; - button { - margin-right : 10px; - background-color : @red; - } - } -} \ No newline at end of file diff --git a/client/admin/brewUtils/brewLookup/brewLookup.jsx b/client/admin/brewUtils/brewLookup/brewLookup.jsx index e5b585ced..fb780f29e 100644 --- a/client/admin/brewUtils/brewLookup/brewLookup.jsx +++ b/client/admin/brewUtils/brewLookup/brewLookup.jsx @@ -1,5 +1,3 @@ -require('./brewLookup.less'); - const React = require('react'); const createClass = require('create-react-class'); const cx = require('classnames'); @@ -55,7 +53,7 @@ const BrewLookup = createClass({ renderFoundBrew(){ const brew = this.state.foundBrew; - return
+ return
Title
{brew.title}
@@ -90,7 +88,7 @@ const BrewLookup = createClass({ }, render(){ - return
+ return

Brew Lookup

; } diff --git a/client/admin/brewUtils/brewLookup/brewLookup.less b/client/admin/brewUtils/brewLookup/brewLookup.less deleted file mode 100644 index da15e3a64..000000000 --- a/client/admin/brewUtils/brewLookup/brewLookup.less +++ /dev/null @@ -1,6 +0,0 @@ -.brewLookup { - .cleanButton { - display : inline-block; - width : 100%; - } -} \ No newline at end of file diff --git a/client/admin/brewUtils/brewUtils.jsx b/client/admin/brewUtils/brewUtils.jsx index de8c29895..bab2cb82f 100644 --- a/client/admin/brewUtils/brewUtils.jsx +++ b/client/admin/brewUtils/brewUtils.jsx @@ -1,6 +1,6 @@ const React = require('react'); const createClass = require('create-react-class'); - +require('./brewUtils.less'); const BrewCleanup = require('./brewCleanup/brewCleanup.jsx'); const BrewLookup = require('./brewLookup/brewLookup.jsx'); diff --git a/client/admin/brewUtils/brewUtils.less b/client/admin/brewUtils/brewUtils.less new file mode 100644 index 000000000..5bbbc3f69 --- /dev/null +++ b/client/admin/brewUtils/brewUtils.less @@ -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; } +} \ No newline at end of file diff --git a/client/admin/brewUtils/stats/stats.jsx b/client/admin/brewUtils/stats/stats.jsx index 85ce10610..7f96618f9 100644 --- a/client/admin/brewUtils/stats/stats.jsx +++ b/client/admin/brewUtils/stats/stats.jsx @@ -1,11 +1,8 @@ -require('./stats.less'); const React = require('react'); const createClass = require('create-react-class'); -const cx = require('classnames'); const request = require('superagent'); - const Stats = createClass({ displayName : 'Stats', getDefaultProps(){ @@ -14,7 +11,8 @@ const Stats = createClass({ getInitialState(){ return { stats : { - totalBrews : 0 + totalBrews : 0, + totalPublishedBrews : 0 }, fetching : false }; @@ -29,11 +27,13 @@ const Stats = createClass({ .finally(()=>this.setState({ fetching: false })); }, render(){ - return
+ return

Stats

Total Brew Count
{this.state.stats.totalBrews}
+
Total Brews Published
+
{this.state.stats.totalPublishedBrews}
{this.state.fetching diff --git a/client/admin/brewUtils/stats/stats.less b/client/admin/brewUtils/stats/stats.less deleted file mode 100644 index b5a4612e1..000000000 --- a/client/admin/brewUtils/stats/stats.less +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/client/admin/notificationUtils/notificationAdd/notificationAdd.less b/client/admin/notificationUtils/notificationAdd/notificationAdd.less index 878da24c2..9256b43cb 100644 --- a/client/admin/notificationUtils/notificationAdd/notificationAdd.less +++ b/client/admin/notificationUtils/notificationAdd/notificationAdd.less @@ -6,18 +6,21 @@ .field { display : grid; - grid-template-columns : 120px 150px; + 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 { diff --git a/client/admin/notificationUtils/notificationLookup/notificationLookup.less b/client/admin/notificationUtils/notificationLookup/notificationLookup.less index 3f9b78310..830467368 100644 --- a/client/admin/notificationUtils/notificationLookup/notificationLookup.less +++ b/client/admin/notificationUtils/notificationLookup/notificationLookup.less @@ -1,8 +1,8 @@ - .notificationLookup { width : 450px; height : fit-content; + .noNotification { margin-block : 20px; } .notificationList { display : flex; flex-direction : column; @@ -30,11 +30,6 @@ font-size : 20px; font-weight : 900; } - - dl dt{ - font-weight: 900; - } } } - .noNotification { margin-block : 20px; } } \ No newline at end of file diff --git a/client/components/combobox.jsx b/client/components/combobox.jsx index 56633bbdd..ae9f1d7f8 100644 --- a/client/components/combobox.jsx +++ b/client/components/combobox.jsx @@ -45,6 +45,7 @@ const Combobox = createClass({ }, handleDropdown : function(show){ this.setState({ + value : show ? '' : this.props.default, showDropdown : show, inputFocused : this.props.autoSuggest.clearAutoSuggestOnClick ? show : false }); @@ -78,7 +79,7 @@ const Combobox = createClass({ if(!e.target.checkValidity()){ this.setState({ value : this.props.default - }, ()=>this.props.onEntry(e)); + }); } }} /> diff --git a/client/homebrew/editor/editor.jsx b/client/homebrew/editor/editor.jsx index 2d0a26268..9e6178f3e 100644 --- a/client/homebrew/editor/editor.jsx +++ b/client/homebrew/editor/editor.jsx @@ -456,6 +456,7 @@ const Editor = createClass({ rerenderParent={this.rerenderParent} /> diff --git a/client/homebrew/editor/metadataEditor/metadataEditor.jsx b/client/homebrew/editor/metadataEditor/metadataEditor.jsx index 9d2ec7db6..2a65d9384 100644 --- a/client/homebrew/editor/metadataEditor/metadataEditor.jsx +++ b/client/homebrew/editor/metadataEditor/metadataEditor.jsx @@ -40,6 +40,7 @@ const MetadataEditor = createClass({ theme : '5ePHB', lang : 'en' }, + onChange : ()=>{}, reportError : ()=>{} }; @@ -47,7 +48,7 @@ const MetadataEditor = createClass({ getInitialState : function(){ return { - showThumbnail : true + showThumbnail : true }; }, @@ -67,6 +68,11 @@ const MetadataEditor = createClass({ 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', ''); @@ -74,14 +80,16 @@ const MetadataEditor = createClass({ ...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'); - 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){ 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'); }, @@ -200,7 +216,7 @@ const MetadataEditor = createClass({ 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
this.handleTheme(theme)} title={''}> + return
{theme.author ?? renderer} : {theme.name}
@@ -210,26 +226,40 @@ const MetadataEditor = createClass({
; - }); + }).filter(Boolean); }; const currentRenderer = this.props.metadata.renderer; - const currentTheme = mergedThemes[`${_.upperFirst(this.props.metadata.renderer)}`][this.props.metadata.theme] - ?? { name: `!!! THEME MISSING !!! ID=${this.props.metadata.theme}` }; + const currentThemeDisplay = this.props.themeBundle?.name ? `${this.props.themeBundle.author ?? currentRenderer} : ${this.props.themeBundle.name}` : 'No Theme Selected'; let dropdown; if(currentRenderer == 'legacy') { dropdown = - -
{`Themes are not supported in the Legacy Renderer`}
-
; +
+
Themes are not supported in the Legacy Renderer
+
; } else { dropdown = - -
{currentTheme.author ?? _.upperFirst(currentRenderer)} : {currentTheme.name}
- - {listThemes(currentRenderer)} -
; +
+ 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'] + }} + /> + 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. +
; } return
@@ -251,8 +281,6 @@ const MetadataEditor = createClass({ }); }; - const debouncedHandleFieldChange = _.debounce(this.handleFieldChange, 500); - return
@@ -263,7 +291,7 @@ const MetadataEditor = createClass({ onSelect={(value)=>this.handleLanguage(value)} onEntry={(e)=>{ e.target.setCustomValidity(''); //Clear the validation popup while typing - debouncedHandleFieldChange('lang', e); + this.handleFieldChange('lang', e); }} options={listLanguages()} autoSuggest={{ @@ -271,8 +299,7 @@ const MetadataEditor = createClass({ clearAutoSuggestOnClick : true, filterOn : ['value', 'detail', 'title'] }} - > - + /> Sets the HTML Lang property for your brew. May affect hyphenation or spellcheck.
@@ -345,7 +372,7 @@ const MetadataEditor = createClass({ placeholder='add tag' unique={true} values={this.props.metadata.tags} onChange={(e)=>this.handleFieldChange('tags', e)} - /> + />
@@ -370,7 +397,7 @@ const MetadataEditor = createClass({ 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)} - /> + />

Privacy

diff --git a/client/homebrew/editor/metadataEditor/metadataEditor.less b/client/homebrew/editor/metadataEditor/metadataEditor.less index 2cff01cfe..c5658a796 100644 --- a/client/homebrew/editor/metadataEditor/metadataEditor.less +++ b/client/homebrew/editor/metadataEditor/metadataEditor.less @@ -1,9 +1,12 @@ @import 'naturalcrit/styles/colors.less'; +.userThemeName { + padding-left: 10px; + padding-right: 10px; +} .metadataEditor { position : absolute; - z-index : 5; 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. @@ -71,8 +74,7 @@ border : 1px solid gray; &:focus { outline : 1px solid #444444; } } - &.thumbnail { - height : 1.4em; + &.thumbnail, &.themes{ label { line-height : 2.0em; } .value { overflow : hidden; @@ -88,6 +90,17 @@ } } + &.themes{ + .value { + overflow : visible; + text-overflow : auto; + } + button { + padding-left: 5px; + padding-right: 5px; + } + } + &.description { flex : 1; textarea.value { @@ -156,89 +169,73 @@ } .themes.field { - .navDropdownContainer { + & .dropdown-container { position : relative; z-index : 100; background-color : white; - &.disabled { - font-style : italic; - color : dimgray; - background-color : darkgray; - } - & > div:first-child { - padding : 3px 3px; - background-color : inherit; - border : 1px solid gray; - i { float : right; } - &:hover { - color : white; - background-color : @blue; + } + & .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%); } } - .navDropdown .item > p { - width : 45%; - height : 1.1em; - overflow : hidden; - text-overflow : ellipsis; - white-space : nowrap; - } - .navDropdown { - position : absolute; - width : 100%; - box-shadow : 0px 5px 10px rgba(0, 0, 0, 0.3); - .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%); - } - } - &: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%); - } - } + + .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; } } } diff --git a/client/homebrew/editor/metadataEditor/validations.js b/client/homebrew/editor/metadataEditor/validations.js index 32c8131f6..b475783a4 100644 --- a/client/homebrew/editor/metadataEditor/validations.js +++ b/client/homebrew/editor/metadataEditor/validations.js @@ -27,6 +27,19 @@ module.exports = { (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.'; + } ] }; diff --git a/client/homebrew/navbar/error-navitem.jsx b/client/homebrew/navbar/error-navitem.jsx index f6788e6d5..3de26ca56 100644 --- a/client/homebrew/navbar/error-navitem.jsx +++ b/client/homebrew/navbar/error-navitem.jsx @@ -116,6 +116,19 @@ const ErrorNavItem = createClass({ ; } + if(HBErrorCode === '10') { + return + Oops! +
+ Looks like the brew you have selected + as a theme is not tagged for use as a + theme. Verify that + brew + {response.body.brewId} has the meta:theme tag! +
+
; + } + return Oops!
diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index ffb6a6b40..4db6d6e23 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -379,7 +379,7 @@ const EditPage = createClass({ const title = `${this.props.brew.title} ${systems}`; const text = `Hey guys! I've been working on this homebrew. I'd love your feedback. Check it out. -**[Homebrewery Link](${global.config.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)}`; }, @@ -410,7 +410,7 @@ const EditPage = createClass({ view - {navigator.clipboard.writeText(`${global.config.publicUrl}/share/${shareLink}`);}}> + {navigator.clipboard.writeText(`${global.config.baseUrl}/share/${shareLink}`);}}> copy url @@ -443,6 +443,7 @@ const EditPage = createClass({ reportError={this.errorReported} renderer={this.state.brew.renderer} userThemes={this.props.userThemes} + themeBundle={this.state.themeBundle} snippetBundle={this.state.themeBundle.snippets} updateBrew={this.updateBrew} onCursorPageChange={this.handleEditorCursorPageChange} diff --git a/client/homebrew/pages/errorPage/errors/errorIndex.js b/client/homebrew/pages/errorPage/errors/errorIndex.js index 2c7be5d4b..c2c49f958 100644 --- a/client/homebrew/pages/errorPage/errors/errorIndex.js +++ b/client/homebrew/pages/errorPage/errors/errorIndex.js @@ -167,6 +167,14 @@ const errorIndex = (props)=>{ **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.`, //account page when account is not defined '50' : dedent` diff --git a/client/homebrew/pages/newPage/newPage.jsx b/client/homebrew/pages/newPage/newPage.jsx index ee2c67d5f..1d5887b8a 100644 --- a/client/homebrew/pages/newPage/newPage.jsx +++ b/client/homebrew/pages/newPage/newPage.jsx @@ -233,6 +233,7 @@ const NewPage = createClass({ onMetaChange={this.handleMetaChange} renderer={this.state.brew.renderer} userThemes={this.props.userThemes} + themeBundle={this.state.themeBundle} snippetBundle={this.state.themeBundle.snippets} onCursorPageChange={this.handleEditorCursorPageChange} onViewPageChange={this.handleEditorViewPageChange} diff --git a/package.json b/package.json index 2969423b9..3f125ed89 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "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: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:route": "jest tests/routes/static-pages.test.js --verbose", "test:safehtml": "jest tests/html/safeHTML.test.js --verbose", @@ -85,9 +84,9 @@ ] }, "dependencies": { - "@babel/core": "^7.26.8", - "@babel/plugin-transform-runtime": "^7.26.8", - "@babel/preset-env": "^7.26.8", + "@babel/core": "^7.26.9", + "@babel/plugin-transform-runtime": "^7.26.9", + "@babel/preset-env": "^7.26.9", "@babel/preset-react": "^7.26.3", "@googleapis/drive": "^8.14.0", "body-parser": "^1.20.2", @@ -114,11 +113,11 @@ "marked-extended-tables": "^1.1.0", "marked-gfm-heading-id": "^4.0.1", "marked-smartypants-lite": "^1.0.3", - "marked-subsuper-text": "^1.0.1", + "marked-subsuper-text": "^1.0.3", "markedLegacy": "npm:marked@^0.3.19", "moment": "^2.30.1", - "mongoose": "^8.10.0", - "nanoid": "5.0.9", + "mongoose": "^8.10.1", + "nanoid": "5.1.0", "nconf": "^0.12.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -131,10 +130,10 @@ "devDependencies": { "@stylistic/stylelint-plugin": "^3.1.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-react": "^7.37.4", - "globals": "^15.14.0", + "globals": "^15.15.0", "jest": "^29.7.0", "jest-expect-message": "^1.1.3", "jsdom-global": "^3.0.2", diff --git a/server/admin.api.js b/server/admin.api.js index 1a39f020b..e70268afa 100644 --- a/server/admin.api.js +++ b/server/admin.api.js @@ -93,7 +93,7 @@ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{ /* Cleans `` from the "text" field of a brew */ 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, '');}; @@ -114,6 +114,18 @@ router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', 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 */ router.put('/admin/compress/:id', (req, res)=>{ 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)=>{ try { const totalBrewsCount = await HomebrewModel.countDocuments({}); diff --git a/server/app.js b/server/app.js index 4dec6b4c4..76caf6fed 100644 --- a/server/app.js +++ b/server/app.js @@ -552,6 +552,7 @@ const renderPage = async (req, res)=>{ const configuration = { local : isLocalEnvironment, publicUrl : config.get('publicUrl') ?? '', + baseUrl : `${req.protocol}://${req.get('host')}`, environment : nodeEnv, deployment : config.get('heroku_app_name') ?? '' }; diff --git a/server/homebrew.api.js b/server/homebrew.api.js index 9a479732e..7bd88cbdb 100644 --- a/server/homebrew.api.js +++ b/server/homebrew.api.js @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import _ from 'lodash'; -import {model as HomebrewModel} from './homebrew.model.js'; +import { model as HomebrewModel } from './homebrew.model.js'; import express from 'express'; import zlib from 'zlib'; import GoogleActions from './googleActions.js'; @@ -279,6 +279,8 @@ const api = { let currentTheme; const completeStyles = []; const completeSnippets = []; + let themeName; + let themeAuthor; while (req.params.id) { //=== User Themes ===// @@ -292,6 +294,10 @@ const api = { currentTheme = req.brew; 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(currentTheme?.snippets) completeSnippets.push(JSON.parse(currentTheme.snippets)); @@ -301,6 +307,7 @@ const api = { req.params.renderer = currentTheme.renderer; } else { //=== Static Themes ===// + themeName ??= req.params.id; 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\");`; completeSnippets.push(localSnippets); @@ -313,7 +320,9 @@ const api = { const returnObj = { // Reverse the order of the arrays so they are listed oldest parent to youngest child. styles : completeStyles.reverse(), - snippets : completeSnippets.reverse() + snippets : completeSnippets.reverse(), + name : themeName, + author : themeAuthor }; res.setHeader('Content-Type', 'application/json'); diff --git a/server/homebrew.api.spec.js b/server/homebrew.api.spec.js index 8270b1568..1c42cb4be 100644 --- a/server/homebrew.api.spec.js +++ b/server/homebrew.api.spec.js @@ -576,7 +576,7 @@ brew`); describe('Theme bundle', ()=>{ it('should return Theme Bundle for a User Theme', async ()=>{ 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 })); @@ -587,6 +587,8 @@ brew`); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ + name : 'User Theme A', + author : 'authorName', styles : ['/* From Brew: https://localhost/share/userThemeAID */\n\nUser Theme A Style'], snippets : [] }); @@ -594,9 +596,9 @@ brew`); it('should return Theme Bundle for nested User Themes', async ()=>{ const brews = { - userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' }, - userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' }, - userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: null, shareId: 'userThemeCID', style: 'User Theme C 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', tags: ['meta:theme'], authors: ['authorName'] }, + 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 })); @@ -607,6 +609,8 @@ brew`); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ + name : 'User Theme A', + author : 'authorName', styles : [ '/* From Brew: https://localhost/share/userThemeCID */\n\nUser Theme C Style', '/* From Brew: https://localhost/share/userThemeBID */\n\nUser Theme B Style', @@ -623,6 +627,8 @@ brew`); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ + name : '5ePHB', + author : undefined, styles : [ `/* From Theme Blank */\n\n@import url("/themes/V3/Blank/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 ()=>{ const brews = { - userThemeAID : { title: 'User Theme A', renderer: 'V3', theme: 'userThemeBID', shareId: 'userThemeAID', style: 'User Theme A Style' }, - userThemeBID : { title: 'User Theme B', renderer: 'V3', theme: 'userThemeCID', shareId: 'userThemeBID', style: 'User Theme B Style' }, - userThemeCID : { title: 'User Theme C', renderer: 'V3', theme: '5eDMG', shareId: 'userThemeCID', style: 'User Theme C 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', tags: ['meta:theme'], authors: ['authorName'] }, + 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 })); @@ -649,6 +655,8 @@ brew`); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ + name : 'User Theme A', + author : 'authorName', styles : [ `/* From Theme Blank */\n\n@import url("/themes/V3/Blank/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 = { - 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 })); @@ -686,6 +694,27 @@ brew`); name : 'ThemeLoad Error', 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', ()=>{ diff --git a/shared/helpers.js b/shared/helpers.js index b2190cdcd..2ad9218fa 100644 --- a/shared/helpers.js +++ b/shared/helpers.js @@ -44,13 +44,19 @@ const fetchThemeBundle = async (obj, renderer, theme)=>{ .catch((err)=>{ obj.setState({ error: err }); }); - if(!res) return; - + if(!res) { + obj.setState((prevState)=>({ + ...prevState, + themeBundle : {} + })); + return; + } const themeBundle = res.body; themeBundle.joinedStyles = themeBundle.styles.map((style)=>``).join('\n\n'); obj.setState((prevState)=>({ ...prevState, - themeBundle : themeBundle + themeBundle : themeBundle, + error : null })); };