diff --git a/.circleci/config.yml b/.circleci/config.yml
index 274ec25ac..d405486b5 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -10,7 +10,7 @@ orbs:
jobs:
build:
docker:
- - image: cimg/node:20.8.0
+ - image: cimg/node:20.17.0
- image: mongo:4.4
working_directory: ~/homebrewery
@@ -27,7 +27,7 @@ jobs:
# fallback to using the latest cache if no exact match is found
- v1-dependencies-
- - run: sudo npm install -g npm@10.2.0
+ - run: sudo npm install -g npm@10.8.2
- node/install-packages:
app-dir: ~/homebrewery
cache-path: node_modules
@@ -45,7 +45,7 @@ jobs:
test:
docker:
- - image: cimg/node:20.8.0
+ - image: cimg/node:20.17.0
working_directory: ~/homebrewery
parallelism: 1
@@ -76,6 +76,9 @@ jobs:
- run:
name: Test - Routes
command: npm run test:route
+ - run:
+ name: Test - HTML sanitization
+ command: npm run test:safehtml
- run:
name: Test - Coverage
command: npm run test:coverage
diff --git a/changelog.md b/changelog.md
index 9d1ddf32d..1f7815d8d 100644
--- a/changelog.md
+++ b/changelog.md
@@ -81,9 +81,85 @@ pre {
}
```
+
## changelog
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
+### Saturday 10/12/2024 - v3.16.0
+
+{{taskList
+##### 5e-Cleric
+
+* [x] Added a new API endpoint `/metadata/:shareId` to fetch metadata about individual brews
+
+Fixes issue [#2638](https://github.com/naturalcrit/homebrewery/issues/2638)
+
+* [x] Added A3, A5, and Card page size snippets under {{openSans **:fas_paintbrush: STYLE TAB :fas_arrow_right: :fas_print: PRINT**}}
+
+* [x] Adjust navbar styling for very long titles
+
+Fixes issue [#2071](https://github.com/naturalcrit/homebrewery/issues/2071)
+
+* [x] Added some sorting options to the {{openSans **VAULT** {{fas,fa-dungeon}}}} page
+
+* [x] Fix `language` property not working in share page
+
+Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776)
+
+##### abquintic
+
+* [x] New {{openSans **:fas_pencil: TEXT EDITOR :fas_arrow_right: :fas_bookmark: PAGE NUMBER :fas_arrow_right:**}}
+{{openSans **:fas_xmark: SKIP PAGE NUMBER**}} and {{openSans **:fas_arrow_rotate_left: RESTART PAGE NUMBER**}} snippets for more control over automatic page numbering.
+
+Fixes issue [#513](https://github.com/naturalcrit/homebrewery/issues/513)
+
+* [x] New Table of Contents control options via {{openSans **:fas_pencil: TEXT EDITOR :fas_arrow_right: :fas_book: TABLE OF CONTENTS**}} submenus. By default, H1-H3 is included in the ToC generation, but the new options allow marking `{{blocks}}` to include or exclude specific or ranges of contained headers. Also, a global option to increase the default range of H1-H3 to H1-H4/5/6. After applying these markers, you must regenerate the Table of Contents to see the changes.
+
+* [x] Added a ":fas_lock: SYNC VIEWS" button onto the divider bar. When locked, scrolling on either panel will sync the other panel to the same page.
+
+Fixes issue [#241](https://github.com/naturalcrit/homebrewery/issues/241)
+
+##### Gazook89
+
+* [x] Added a :fas_glasses: HIDE button to the page navigation bar
+
+##### G-Ambatte
+
+* [x] Automatic local backups of your files, in case of accidental data loss. Stores up to 5 snapshots of each brew edited in your browser, incrementing from a few minutes old to a maximum of several days. Restore a backup by clicking an entry in the new {{openSans **:fas_clock_rotate_left: HISTORY**}} button in the snippet bar.
+
+Fixes issue [#3070](https://github.com/naturalcrit/homebrewery/issues/3070)
+
+* [x] Fix issue with legacy brews breaking on Share page
+
+Fixes issue [#3764](https://github.com/naturalcrit/homebrewery/issues/3764)
+
+* [x] Fix print size when printing a zoomed document
+
+Fixes issue [#3744](https://github.com/naturalcrit/homebrewery/issues/3744)
+
+##### All
+
+* [x] Background code cleanup, security fixes, dev tool improvements, dependency updates, prep for upcoming features, etc.
+}}
+
+### Wednesday 9/25/2024 - v3.15.1
+
+{{taskList
+##### calculuschild
+
+* [x] Background fixes to handle Google Drive issues
+
+* [x] Remove duplicate error logging
+
+##### calculuschild, 5e-Cleric
+
+* [x] Fix links in {{openSans **RECENT BREWS :fas_clock_rotate_left:**}} and user {{openSans **BREWS :fas_beer_mug_empty:**}} pointing to trashed Google Drive files after transferring from Google to Homebrewery storage
+
+Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776)
+}}
+
+\page
+
### Wednesday 9/04/2024 - v3.15.0
{{taskList
diff --git a/client/admin/admin.jsx b/client/admin/admin.jsx
index 92e0b2aee..f2f2667a4 100644
--- a/client/admin/admin.jsx
+++ b/client/admin/admin.jsx
@@ -2,35 +2,44 @@ require('./admin.less');
const React = require('react');
const createClass = require('create-react-class');
+const BrewUtils = require('./brewUtils/brewUtils.jsx');
+const NotificationUtils = require('./notificationUtils/notificationUtils.jsx');
-const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
-const BrewLookup = require('./brewLookup/brewLookup.jsx');
-const BrewCompress = require ('./brewCompress/brewCompress.jsx');
-const Stats = require('./stats/stats.jsx');
+const tabGroups = ['brew', 'notifications'];
const Admin = createClass({
getDefaultProps : function() {
return {};
},
+ getInitialState : function(){
+ return ({
+ currentTab : 'brew'
+ });
+ },
+
+ handleClick : function(newTab){
+ if(this.state.currentTab === newTab) return;
+ this.setState({
+ currentTab : newTab
+ });
+ },
+
render : function(){
return
-
-
-
-
-
-
-
-
-
-
+
+
+ {tabGroups.map((tab, idx)=>{ return { return this.handleClick(tab); }}>{tab.toUpperCase()} ; })}
+
+ {this.state.currentTab==='brew' && }
+ {this.state.currentTab==='notifications' && }
+
;
}
});
diff --git a/client/admin/admin.less b/client/admin/admin.less
index a61335835..c6c9b4662 100644
--- a/client/admin/admin.less
+++ b/client/admin/admin.less
@@ -6,39 +6,95 @@
@import 'font-awesome/css/font-awesome.css';
-html,body, #reactContainer, .naturalCrit{
- min-height : 100%;
-}
+html,body, #reactContainer, .naturalCrit { min-height : 100%; }
@sidebarWidth : 250px;
-body{
- background-color : #eee;
- font-family : 'Open Sans', sans-serif;
- color : #4b5055;
- font-weight : 100;
- text-rendering : optimizeLegibility;
- margin : 0;
+body {
+ height : 100%;
padding : 0;
- height : 100%;
+ margin : 0;
+ font-family : 'Open Sans', sans-serif;
+ font-weight : 100;
+ color : #4B5055;
+ background-color : #EEEEEE;
+ text-rendering : optimizeLegibility;
}
-.admin{
+:where(.admin) {
- header{
+ header {
+ padding : 20px 0px;
+ margin-bottom : 30px;
+ font-size : 2em;
+ color : white;
background-color : @red;
- font-size: 2em;
- padding : 20px 0px;
- color : white;
- margin-bottom: 30px;
- i{
- margin-right: 30px;
+ i { margin-right : 30px; }
+ }
+
+ hr { margin : 30px 0px; }
+
+ :where(.container) {
+ input {
+ height : 33px;
+ padding : 0px 10px;
+ margin-bottom : 20px;
+ font-family : monospace;
+ }
+
+ button {
+ height : 37px;
+ vertical-align : middle;
+ }
+
+ dl {
+ @maxItemWidth : 132px;
+ dt {
+ float : left;
+ width : @maxItemWidth;
+ clear : left;
+ text-align : right;
+ &::after { content : ' : '; }
+ }
+ dd {
+ height : 1em;
+ padding : 0 0 0.5em 0;
+ margin-left : @maxItemWidth + 6px;
+ }
+ }
+
+ .tabs button {
+ margin-right : 3px;
+ margin-left : 3px;
+ color : black;
+ background-color : #EEEEEE;
+ border : 1px solid #444444;
+ border-radius : 5px;
+ &:hover {
+ color : #EEEEEE;
+ background-color : #444444;
+ }
+ &.active {
+ margin-right : 2px;
+ margin-left : 2px;
+ text-decoration : underline;
+ background-color : #CCCCCC;
+ border : 2px solid #444444;
+ }
+ }
+
+ .notificationUtils {
+ display : flex;
+ gap : 50px;
+ justify-content : space-between;
}
}
- hr{
- margin : 30px 0px;
+ .error {
+ background: rgb(178, 54, 54);
+ color:white;
+ font-weight: 900;
+ margin-block:10px;
+ padding:10px;
}
-
-
}
diff --git a/client/admin/brewCleanup/brewCleanup.less b/client/admin/brewCleanup/brewCleanup.less
deleted file mode 100644
index ec7582855..000000000
--- a/client/admin/brewCleanup/brewCleanup.less
+++ /dev/null
@@ -1,10 +0,0 @@
-.BrewCleanup{
- .removeBox{
- margin-top: 20px;
- button{
- background-color: @red;
- margin-right: 10px;
- }
- }
-
-}
\ No newline at end of file
diff --git a/client/admin/brewCompress/brewCompress.less b/client/admin/brewCompress/brewCompress.less
deleted file mode 100644
index 2a2bf42ea..000000000
--- a/client/admin/brewCompress/brewCompress.less
+++ /dev/null
@@ -1,10 +0,0 @@
-.BrewCompress{
- .removeBox{
- margin-top: 20px;
- button{
- background-color: @red;
- margin-right: 10px;
- }
- }
-
-}
\ No newline at end of file
diff --git a/client/admin/brewLookup/brewLookup.less b/client/admin/brewLookup/brewLookup.less
deleted file mode 100644
index 61eeec424..000000000
--- a/client/admin/brewLookup/brewLookup.less
+++ /dev/null
@@ -1,30 +0,0 @@
-
-.brewLookup{
- input{
- height : 33px;
- margin-bottom : 20px;
- padding : 0px 10px;
- font-family : monospace;
- }
- button{
- vertical-align : middle;
- height : 37px;
- }
- dl{
- @maxItemWidth : 132px;
- dt{
- float : left;
- clear : left;
- width : @maxItemWidth;
- text-align : right;
- &::after {
- content: " : ";
- }
- }
- dd{
- height : 1em;
- margin-left : @maxItemWidth + 6px;
- padding : 0 0 0.5em 0;
- }
- }
-}
\ No newline at end of file
diff --git a/client/admin/brewCleanup/brewCleanup.jsx b/client/admin/brewUtils/brewCleanup/brewCleanup.jsx
similarity index 100%
rename from client/admin/brewCleanup/brewCleanup.jsx
rename to client/admin/brewUtils/brewCleanup/brewCleanup.jsx
diff --git a/client/admin/brewUtils/brewCleanup/brewCleanup.less b/client/admin/brewUtils/brewCleanup/brewCleanup.less
new file mode 100644
index 000000000..16fc98957
--- /dev/null
+++ b/client/admin/brewUtils/brewCleanup/brewCleanup.less
@@ -0,0 +1,9 @@
+.BrewCleanup {
+ .removeBox {
+ margin-top : 20px;
+ button {
+ margin-right : 10px;
+ background-color : @red;
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/admin/brewCompress/brewCompress.jsx b/client/admin/brewUtils/brewCompress/brewCompress.jsx
similarity index 100%
rename from client/admin/brewCompress/brewCompress.jsx
rename to client/admin/brewUtils/brewCompress/brewCompress.jsx
diff --git a/client/admin/brewUtils/brewCompress/brewCompress.less b/client/admin/brewUtils/brewCompress/brewCompress.less
new file mode 100644
index 000000000..8668e9280
--- /dev/null
+++ b/client/admin/brewUtils/brewCompress/brewCompress.less
@@ -0,0 +1,9 @@
+.BrewCompress {
+ .removeBox {
+ margin-top : 20px;
+ button {
+ margin-right : 10px;
+ background-color : @red;
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/admin/brewLookup/brewLookup.jsx b/client/admin/brewUtils/brewLookup/brewLookup.jsx
similarity index 63%
rename from client/admin/brewLookup/brewLookup.jsx
rename to client/admin/brewUtils/brewLookup/brewLookup.jsx
index c9212d990..e5b585ced 100644
--- a/client/admin/brewLookup/brewLookup.jsx
+++ b/client/admin/brewUtils/brewLookup/brewLookup.jsx
@@ -1,4 +1,5 @@
require('./brewLookup.less');
+
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
@@ -13,22 +14,43 @@ const BrewLookup = createClass({
},
getInitialState() {
return {
- query : '',
- foundBrew : null,
- searching : false,
- error : null
+ query : '',
+ foundBrew : null,
+ searching : false,
+ error : null,
+ scriptCount : 0
};
},
handleChange(e){
this.setState({ query: e.target.value });
},
lookup(){
- this.setState({ searching: true, error: null });
+ this.setState({ searching: true, error: null, scriptCount: 0 });
request.get(`/admin/lookup/${this.state.query}`)
- .then((res)=>this.setState({ foundBrew: res.body }))
+ .then((res)=>{
+ const foundBrew = res.body;
+ const scriptCheck = foundBrew?.text.match(/(<\/?s)cript/g);
+ this.setState({
+ foundBrew : foundBrew,
+ scriptCount : scriptCheck?.length || 0,
+ });
+ })
.catch((err)=>this.setState({ error: err }))
- .finally(()=>this.setState({ searching: false }));
+ .finally(()=>{
+ this.setState({
+ searching : false
+ });
+ });
+ },
+
+ async cleanScript(){
+ if(!this.state.foundBrew?.shareId) return;
+
+ await request.put(`/admin/clean/script/${this.state.foundBrew.shareId}`)
+ .catch((err)=>{ this.setState({ error: err }); return; });
+
+ this.lookup();
},
renderFoundBrew(){
@@ -47,12 +69,23 @@ const BrewLookup = createClass({
Share Link
/share/{brew.shareId}
+ Created Time
+ {brew.createdAt ? Moment(brew.createdAt).toLocaleString() : 'No creation date'}
+
Last Updated
{Moment(brew.updatedAt).fromNow()}
Num of Views
{brew.views}
+
+ SCRIPT tags detected
+ {this.state.scriptCount}
+ {this.state.scriptCount > 0 &&
+
+ CLEAN BREW
+
+ }
;
},
diff --git a/client/admin/brewUtils/brewLookup/brewLookup.less b/client/admin/brewUtils/brewLookup/brewLookup.less
new file mode 100644
index 000000000..da15e3a64
--- /dev/null
+++ b/client/admin/brewUtils/brewLookup/brewLookup.less
@@ -0,0 +1,6 @@
+.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
new file mode 100644
index 000000000..de8c29895
--- /dev/null
+++ b/client/admin/brewUtils/brewUtils.jsx
@@ -0,0 +1,24 @@
+const React = require('react');
+const createClass = require('create-react-class');
+
+
+const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
+const BrewLookup = require('./brewLookup/brewLookup.jsx');
+const BrewCompress = require ('./brewCompress/brewCompress.jsx');
+const Stats = require('./stats/stats.jsx');
+
+const BrewUtils = createClass({
+ render : function(){
+ return <>
+
+
+
+
+
+
+
+ >;
+ }
+});
+
+module.exports = BrewUtils;
diff --git a/client/admin/stats/stats.jsx b/client/admin/brewUtils/stats/stats.jsx
similarity index 100%
rename from client/admin/stats/stats.jsx
rename to client/admin/brewUtils/stats/stats.jsx
diff --git a/client/admin/brewUtils/stats/stats.less b/client/admin/brewUtils/stats/stats.less
new file mode 100644
index 000000000..b5a4612e1
--- /dev/null
+++ b/client/admin/brewUtils/stats/stats.less
@@ -0,0 +1,13 @@
+
+.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.jsx b/client/admin/notificationUtils/notificationAdd/notificationAdd.jsx
new file mode 100644
index 000000000..0cca1047e
--- /dev/null
+++ b/client/admin/notificationUtils/notificationAdd/notificationAdd.jsx
@@ -0,0 +1,109 @@
+require('./notificationAdd.less');
+const React = require('react');
+const { useState, useRef } = require('react');
+const request = require('superagent');
+
+const NotificationAdd = ()=>{
+ const [notificationResult, setNotificationResult] = useState(null);
+ const [searching, setSearching] = useState(false);
+ const [error, setError] = useState(null);
+
+ const dismissKeyRef = useRef(null);
+ const titleRef = useRef(null);
+ const textRef = useRef(null);
+ const startAtRef = useRef(null);
+ const stopAtRef = useRef(null);
+
+ const saveNotification = async ()=>{
+ const dismissKey = dismissKeyRef.current.value;
+ const title = titleRef.current.value;
+ const text = textRef.current.value;
+ const startAt = new Date(startAtRef.current.value);
+ const stopAt = new Date(stopAtRef.current.value);
+
+ // Basic validation
+ if(!dismissKey || !title || !text || isNaN(startAt.getTime()) || isNaN(stopAt.getTime())) {
+ setError('All fields are required');
+ return;
+ }
+ if(startAt >= stopAt) {
+ setError('End date must be after the start date!');
+ return;
+ }
+
+ const data = {
+ dismissKey,
+ title,
+ text,
+ startAt : startAt?.toISOString() ?? '',
+ stopAt : stopAt?.toISOString() ?? '',
+ };
+
+ try {
+ setSearching(true);
+ setError(null);
+ const response = await request.post('/admin/notification/add').send(data);
+ console.log(response.body);
+
+ // Reset form fields
+ dismissKeyRef.current.value = '';
+ titleRef.current.value = '';
+ textRef.current.value = '';
+
+ setNotificationResult('Notification successfully created.');
+ setSearching(false);
+ } catch (err) {
+ console.log(err.response.body.message);
+ setError(`Error saving notification: ${err.response.body.message}`);
+ setSearching(false);
+ }
+ };
+
+ return (
+
+
Add Notification
+
+
+ Dismiss Key:
+
+
+
+
+ Title:
+
+
+
+
+ Text:
+
+
+
+
+ Start Date:
+
+
+
+
+ End Date:
+
+
+
+
{notificationResult}
+
+
+
+ Save Notification
+
+ {error &&
{error}
}
+
+ );
+};
+
+module.exports = NotificationAdd;
diff --git a/client/admin/notificationUtils/notificationAdd/notificationAdd.less b/client/admin/notificationUtils/notificationAdd/notificationAdd.less
new file mode 100644
index 000000000..878da24c2
--- /dev/null
+++ b/client/admin/notificationUtils/notificationAdd/notificationAdd.less
@@ -0,0 +1,37 @@
+.notificationAdd {
+ position : relative;
+ display : flex;
+ flex-direction : column;
+ width : 500px;
+
+ .field {
+ display : grid;
+ grid-template-columns : 120px 150px;
+ align-items : center;
+ justify-items : stretch;
+ width : 100%;
+ margin-bottom : 20px;
+
+
+ input {
+ height : 33px;
+ padding : 0px 10px;
+ margin-bottom : unset;
+ font-family : monospace;
+ }
+
+ textarea {
+ width : 50ch;
+ min-height : 7em;
+ max-height : 20em;
+ resize : vertical;
+ padding : 10px;
+ }
+ }
+
+ button {
+ width: 200px;
+
+ i { margin-right : 10px; }
+ }
+}
\ No newline at end of file
diff --git a/client/admin/notificationUtils/notificationLookup/notificationLookup.jsx b/client/admin/notificationUtils/notificationLookup/notificationLookup.jsx
new file mode 100644
index 000000000..05f81b776
--- /dev/null
+++ b/client/admin/notificationUtils/notificationLookup/notificationLookup.jsx
@@ -0,0 +1,105 @@
+require('./notificationLookup.less');
+
+const React = require('react');
+const { useState } = require('react');
+const request = require('superagent');
+const Moment = require('moment');
+
+const NotificationDetail = ({ notification, onDelete })=>(
+ <>
+
+ Key
+ {notification.dismissKey}
+
+ Title
+ {notification.title || 'No Title'}
+
+ Created
+ {Moment(notification.createdAt).format('LLLL')}
+
+ Start
+ {Moment(notification.startAt).format('LLLL') || 'No Start Time'}
+
+ Stop
+ {Moment(notification.stopAt).format('LLLL') || 'No End Time'}
+
+ Text
+ {notification.text || 'No Text'}
+
+ onDelete(notification.dismissKey)}>DELETE
+ >
+);
+
+const NotificationLookup = ()=>{
+ const [searching, setSearching] = useState(false);
+ const [error, setError] = useState(null);
+ const [notifications, setNotifications] = useState([]);
+
+ const lookupAll = async ()=>{
+ setSearching(true);
+ setError(null);
+
+ try {
+ const res = await request.get('/admin/notification/all');
+ setNotifications(res.body || []);
+ } catch (err) {
+ console.log(err);
+ setError(`Error looking up notifications: ${err.response.body.message}`);
+ } finally {
+ setSearching(false);
+ }
+ };
+
+ const deleteNotification = async (dismissKey)=>{
+ if(!dismissKey) return;
+
+ const confirmed = window.confirm(
+ `Really delete notification ${dismissKey}?`
+ );
+ if(!confirmed) {
+ console.log('Delete notification cancelled');
+ return;
+ }
+ console.log('Delete notification confirm');
+ try {
+ await request.delete(`/admin/notification/delete/${dismissKey}`);
+ lookupAll();
+ } catch (err) {
+ console.log(err);
+ setError(`Error deleting notification: ${err.response.body.message}`);
+ };
+ };
+
+ const renderNotificationsList = ()=>{
+ if(error)
+ return {error}
;
+
+ if(notifications.length === 0)
+ return No notifications available.
;
+
+ return (
+
+ {notifications.map((notification)=>(
+
+
+ {notification.title || 'No Title'}
+
+
+
+ ))}
+
+ );
+ };
+
+ return (
+
+
Check all Notifications
+
+
+
+ {renderNotificationsList()}
+
+ );
+};
+
+module.exports = NotificationLookup;
diff --git a/client/admin/notificationUtils/notificationLookup/notificationLookup.less b/client/admin/notificationUtils/notificationLookup/notificationLookup.less
new file mode 100644
index 000000000..3f9b78310
--- /dev/null
+++ b/client/admin/notificationUtils/notificationLookup/notificationLookup.less
@@ -0,0 +1,40 @@
+
+.notificationLookup {
+ width : 450px;
+ height : fit-content;
+
+ .notificationList {
+ display : flex;
+ flex-direction : column;
+ max-height : 500px;
+ margin-block : 20px;
+ overflow : auto;
+ border : 1px solid;
+ border-radius : 5px;
+
+ li {
+ padding : 10px;
+ background : #CCCCCC;
+
+ &:nth-child(even) { background : #DDDDDD; }
+ &:first-child {
+ border-top-left-radius : 5px;
+ border-top-right-radius : 5px;
+ }
+ &:last-child {
+ border-bottom-right-radius : 5px;
+ border-bottom-left-radius : 5px;
+ }
+
+ summary {
+ font-size : 20px;
+ font-weight : 900;
+ }
+
+ dl dt{
+ font-weight: 900;
+ }
+ }
+ }
+ .noNotification { margin-block : 20px; }
+}
\ No newline at end of file
diff --git a/client/admin/notificationUtils/notificationUtils.jsx b/client/admin/notificationUtils/notificationUtils.jsx
new file mode 100644
index 000000000..22ea21328
--- /dev/null
+++ b/client/admin/notificationUtils/notificationUtils.jsx
@@ -0,0 +1,15 @@
+const React = require('react');
+
+const NotificationLookup = require('./notificationLookup/notificationLookup.jsx');
+const NotificationAdd = require('./notificationAdd/notificationAdd.jsx');
+
+const NotificationUtils = ()=>{
+ return (
+
+ );
+};
+
+module.exports = NotificationUtils;
diff --git a/client/admin/stats/stats.less b/client/admin/stats/stats.less
deleted file mode 100644
index 5337bf671..000000000
--- a/client/admin/stats/stats.less
+++ /dev/null
@@ -1,28 +0,0 @@
-
-.Stats{
- position : relative;
- .pending{
- position : absolute;
- top : 0px;
- left : 0px;
- height : 100%;
- width : 100%;
- background-color : rgba(238,238,238, 0.5);
- }
- dl{
- @maxItemWidth : 132px;
- dt{
- float : left;
- clear : left;
- width : @maxItemWidth;
- text-align : right;
- &::after {
- content: " : ";
- }
- }
- dd{
- margin : 0 0 0 @maxItemWidth + 10px;
- padding : 0 0 0.5em 0;
- }
- }
-}
\ No newline at end of file
diff --git a/client/components/Anchored.jsx b/client/components/Anchored.jsx
new file mode 100644
index 000000000..4c7a225e4
--- /dev/null
+++ b/client/components/Anchored.jsx
@@ -0,0 +1,91 @@
+import React, { useState, useRef, forwardRef, useEffect, cloneElement, Children } from 'react';
+import './Anchored.less';
+
+// Anchored is a wrapper component that must have as children an and a component.
+// AnchoredTrigger must have a unique `id` prop, which is passed up to Anchored, saved in state on mount, and
+// then passed down through props into AnchoredBox. The `id` is used for the CSS Anchor Positioning properties.
+// **The Anchor Positioning API is not available in Firefox yet**
+// So in Firefox the positioning isn't perfect but is likely sufficient, and FF team seems to be working on the API quickly.
+
+
+const Anchored = ({ children })=>{
+ const [visible, setVisible] = useState(false);
+ const [anchorId, setAnchorId] = useState(null);
+ const boxRef = useRef(null);
+ const triggerRef = useRef(null);
+
+ // promote trigger id to Anchored id (to pass it back down to the box as "anchorId")
+ useEffect(()=>{
+ if(triggerRef.current){
+ setAnchorId(triggerRef.current.id);
+ }
+ }, []);
+
+ // close box on outside click or Escape key
+ useEffect(()=>{
+ const handleClickOutside = (evt)=>{
+ if(
+ boxRef.current &&
+ !boxRef.current.contains(evt.target) &&
+ triggerRef.current &&
+ !triggerRef.current.contains(evt.target)
+ ) {
+ setVisible(false);
+ }
+ };
+
+ const handleEscapeKey = (evt)=>{
+ if(evt.key === 'Escape') setVisible(false);
+ };
+
+ window.addEventListener('click', handleClickOutside);
+ window.addEventListener('keydown', handleEscapeKey);
+
+ return ()=>{
+ window.removeEventListener('click', handleClickOutside);
+ window.removeEventListener('keydown', handleEscapeKey);
+ };
+ }, []);
+
+ const toggleVisibility = ()=>setVisible((prev)=>!prev);
+
+ // Map children to inject necessary props
+ const mappedChildren = Children.map(children, (child)=>{
+ if(child.type === AnchoredTrigger) {
+ return cloneElement(child, { ref: triggerRef, toggleVisibility, visible });
+ }
+ if(child.type === AnchoredBox) {
+ return cloneElement(child, { ref: boxRef, visible, anchorId });
+ }
+ return child;
+ });
+
+ return <>{mappedChildren}>;
+};
+
+// forward ref for AnchoredTrigger
+const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
+
+ {children}
+
+));
+
+// forward ref for AnchoredBox
+const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
+
+ {children}
+
+));
+
+export { Anchored, AnchoredTrigger, AnchoredBox };
diff --git a/client/components/Anchored.less b/client/components/Anchored.less
new file mode 100644
index 000000000..4f0e2fa8f
--- /dev/null
+++ b/client/components/Anchored.less
@@ -0,0 +1,13 @@
+
+
+.anchored-box {
+ position:absolute;
+ @supports (inset-block-start: anchor(bottom)){
+ inset-block-start: anchor(bottom);
+ }
+ justify-self: anchor-center;
+ visibility: hidden;
+ &.active {
+ visibility: visible;
+ }
+}
\ No newline at end of file
diff --git a/client/components/dialog.jsx b/client/components/dialog.jsx
index 2057ecb87..0cdda2dee 100644
--- a/client/components/dialog.jsx
+++ b/client/components/dialog.jsx
@@ -1,22 +1,26 @@
// Dialog box, for popups and modal blocking messages
-const React = require('react');
+import React from 'react';
const { useRef, useEffect } = React;
-function Dialog({ dismissKey, closeText = 'Close', blocking = false, ...rest }) {
+function Dialog({ dismisskeys = [], closeText = 'Close', blocking = false, ...rest }) {
const dialogRef = useRef(null);
useEffect(()=>{
- if(!dismissKey || !localStorage.getItem(dismissKey)) {
+ if(dismisskeys.length !== 0) {
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
}
- }, []);
+ }, [dialogRef.current, dismisskeys]);
const dismiss = ()=>{
- dismissKey && localStorage.setItem(dismissKey, true);
+ dismisskeys.forEach((key)=>{
+ if(key) {
+ localStorage.setItem(key, 'true');
+ }
+ });
dialogRef.current?.close();
};
- return (
+ return (
{rest.children}
diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx
index 7268e4b34..4685775b9 100644
--- a/client/homebrew/brewRenderer/brewRenderer.jsx
+++ b/client/homebrew/brewRenderer/brewRenderer.jsx
@@ -1,7 +1,7 @@
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
require('./brewRenderer.less');
const React = require('react');
-const { useState, useRef, useEffect, useCallback } = React;
+const { useState, useRef, useCallback, useMemo } = React;
const _ = require('lodash');
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
@@ -16,8 +16,7 @@ const Frame = require('react-frame-component').default;
const dedent = require('dedent-tabs').default;
const { printCurrentBrew } = require('../../../shared/helpers.js');
-const DOMPurify = require('dompurify');
-const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false };
+import { safeHTML } from './safeHTML.js';
const PAGE_HEIGHT = 1056;
@@ -29,6 +28,7 @@ const INITIAL_CONTENT = dedent`