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
-
homebrewery admin
-
- -
- -
- -
- -
+
+ + {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 98% rename from client/admin/brewLookup/brewLookup.jsx rename to client/admin/brewUtils/brewLookup/brewLookup.jsx index c9212d990..50a2f2015 100644 --- a/client/admin/brewLookup/brewLookup.jsx +++ b/client/admin/brewUtils/brewLookup/brewLookup.jsx @@ -1,4 +1,3 @@ -require('./brewLookup.less'); const React = require('react'); const createClass = require('create-react-class'); const cx = require('classnames'); 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..5a8ebf5d0 --- /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

+ + + + + + + + + + + +
{notificationResult}
+ + + {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..71f8da59c --- /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'}
+ +
Text
+
{notification.text || 'No Text'}
+ +
Created
+
{Moment(notification.createdAt).format('LLLL')}
+ +
Start
+
{Moment(notification.startAt).format('LLLL') || 'No Start Time'}
+ +
Stop
+
{Moment(notification.stopAt).format('LLLL') || 'No End Time'}
+
+ + +); + +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/homebrew/navbar/editTitle.navitem.jsx b/client/homebrew/navbar/editTitle.navitem.jsx deleted file mode 100644 index 94ae5d0b0..000000000 --- a/client/homebrew/navbar/editTitle.navitem.jsx +++ /dev/null @@ -1,34 +0,0 @@ -const React = require('react'); -const createClass = require('create-react-class'); -const cx = require('classnames'); -const Nav = require('naturalcrit/nav/nav.jsx'); - -const MAX_TITLE_LENGTH = 50; - - -const EditTitle = createClass({ - displayName : 'EditTitleNavItem', - getDefaultProps : function() { - return { - title : '', - onChange : function(){} - }; - }, - - handleChange : function(e){ - if(e.target.value.length > MAX_TITLE_LENGTH) return; - this.props.onChange(e.target.value); - }, - render : function(){ - return - - -
= MAX_TITLE_LENGTH })}> - {this.props.title.length}/{MAX_TITLE_LENGTH} -
-
; - }, - -}); - -module.exports = EditTitle; diff --git a/client/homebrew/navbar/reddit.navitem.jsx b/client/homebrew/navbar/reddit.navitem.jsx deleted file mode 100644 index 1d9f95604..000000000 --- a/client/homebrew/navbar/reddit.navitem.jsx +++ /dev/null @@ -1,44 +0,0 @@ -const React = require('react'); -const createClass = require('create-react-class'); -const Nav = require('naturalcrit/nav/nav.jsx'); - -const MAIN_URL = 'https://www.reddit.com/r/UnearthedArcana/submit?selftext=true'; - - -const RedditShare = createClass({ - displayName : 'RedditShareNavItem', - getDefaultProps : function() { - return { - brew : { - title : '', - sharedId : '', - text : '' - } - }; - }, - - getText : function(){ - - }, - - - handleClick : function(){ - const url = [ - MAIN_URL, - `title=${encodeURIComponent(this.props.brew.title ? this.props.brew.title : 'Check out my brew!')}`, - `text=${encodeURIComponent(this.props.brew.text)}` - ].join('&'); - - window.open(url, '_blank'); - }, - - - render : function(){ - return - share on reddit - ; - }, - -}); - -module.exports = RedditShare; diff --git a/config/default.json b/config/default.json index 12b35e6cf..bea3b2663 100644 --- a/config/default.json +++ b/config/default.json @@ -6,5 +6,7 @@ "enable_v3" : true, "enable_themes" : true, "local_environments" : ["docker", "local"], - "publicUrl" : "https://homebrewery.naturalcrit.com" + "publicUrl" : "https://homebrewery.naturalcrit.com", + "hb_images" : null, + "hb_fonts" : null } diff --git a/package-lock.json b/package-lock.json index f060d38ce..d9d24565a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,12 @@ "body-parser": "^1.20.2", "classnames": "^2.5.1", "codemirror": "^5.65.6", - "cookie-parser": "^1.4.6", + "cookie-parser": "^1.4.7", "create-react-class": "^15.7.0", "dedent-tabs": "^0.10.3", "dompurify": "^3.1.7", "expr-eval": "^2.0.2", - "express": "^4.21.0", + "express": "^4.21.1", "express-async-handler": "^1.2.0", "express-static-gzip": "2.1.8", "fs-extra": "11.2.0", @@ -4816,21 +4816,19 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", - "license": "MIT", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "engines": { "node": ">= 0.6" } }, "node_modules/cookie-parser": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", - "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", - "license": "MIT", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", "dependencies": { - "cookie": "0.4.1", + "cookie": "0.7.2", "cookie-signature": "1.0.6" }, "engines": { @@ -6254,16 +6252,16 @@ "license": "MIT" }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -6309,10 +6307,9 @@ } }, "node_modules/express/node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "license": "MIT", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "engines": { "node": ">= 0.6" } diff --git a/package.json b/package.json index 03eb5e365..39046d4a5 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:api-unit": "jest \"server/.*.spec.js\" --verbose", "test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose", "test:api-unit:css": "jest \"server/.*.spec.js\" -t \"Get CSS\" --verbose", + "test:api-unit:notifications": "jest \"server/.*.spec.js\" -t \"Notifications\" --verbose", "test:coverage": "jest --coverage --silent --runInBand", "test:dev": "jest --verbose --watch", "test:basic": "jest tests/markdown/basic.test.js --verbose", @@ -93,12 +94,12 @@ "body-parser": "^1.20.2", "classnames": "^2.5.1", "codemirror": "^5.65.6", - "cookie-parser": "^1.4.6", + "cookie-parser": "^1.4.7", "create-react-class": "^15.7.0", "dedent-tabs": "^0.10.3", "dompurify": "^3.1.7", "expr-eval": "^2.0.2", - "express": "^4.21.0", + "express": "^4.21.1", "express-async-handler": "^1.2.0", "express-static-gzip": "2.1.8", "fs-extra": "11.2.0", diff --git a/server/admin.api.js b/server/admin.api.js index fe2def3ce..a112dc6f1 100644 --- a/server/admin.api.js +++ b/server/admin.api.js @@ -1,4 +1,5 @@ const HomebrewModel = require('./homebrew.model.js').model; +const NotificationModel = require('./notifications.model.js').model; const router = require('express').Router(); const Moment = require('moment'); //const render = require('vitreum/steps/render'); @@ -138,12 +139,48 @@ router.get('/admin/stats', mw.adminOnly, async (req, res)=>{ } }); +// ####################### NOTIFICATIONS + +router.get('/admin/notification/all', async (req, res, next)=>{ + try { + const notifications = await NotificationModel.getAll(); + return res.json(notifications); + } catch (error) { + console.log('Error getting all notifications: ', error.message); + return res.status(500).json({ message: error.message }); + } +}); + +router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{ + console.table(req.body); + try { + const notification = await NotificationModel.addNotification(req.body); + return res.status(201).json(notification); + } catch (error) { + console.log('Error adding notification: ', error.message); + return res.status(500).json({ message: error.message }); + } +}); + +router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{ + try { + const notification = await NotificationModel.deleteNotification(req.params.id); + return res.json(notification); + } catch (error) { + console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }'); + return res.status(500).json({ message: error.message }); + } +}); + router.get('/admin', mw.adminOnly, (req, res)=>{ templateFn('admin', { url : req.originalUrl }) .then((page)=>res.send(page)) - .catch((err)=>res.sendStatus(500)); + .catch((err)=>{ + console.log(err); + res.sendStatus(500); + }); }); module.exports = router; diff --git a/server/admin.api.spec.js b/server/admin.api.spec.js new file mode 100644 index 000000000..b0dbd5d84 --- /dev/null +++ b/server/admin.api.spec.js @@ -0,0 +1,116 @@ +const supertest = require('supertest'); + +const app = supertest.agent(require('app.js').app) + .set('X-Forwarded-Proto', 'https'); + +const NotificationModel = require('./notifications.model.js').model; + +describe('Tests for admin api', ()=>{ + afterEach(()=>{ + jest.resetAllMocks(); + }); + + describe('Notifications', ()=>{ + it('should return list of all notifications', async ()=>{ + const testNotifications = ['a', 'b']; + + jest.spyOn(NotificationModel, 'find') + .mockImplementationOnce(() => { + return { exec: jest.fn().mockResolvedValue(testNotifications) }; + }); + + const response = await app + .get('/admin/notification/all') + .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual(testNotifications); + }); + + it('should add a new notification', async ()=>{ + const inputNotification = { + title : 'Test Notification', + text : 'This is a test notification', + startAt : new Date().toISOString(), + stopAt : new Date().toISOString(), + dismissKey : 'testKey' + }; + + const savedNotification = { + ...inputNotification, + _id : expect.any(String), + createdAt : expect.any(String), + startAt : inputNotification.startAt, + stopAt : inputNotification.stopAt, + }; + + jest.spyOn(NotificationModel.prototype, 'save') + .mockImplementationOnce(function() { + return Promise.resolve(this); + }); + + const response = await app + .post('/admin/notification/add') + .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`) + .send(inputNotification); + + expect(response.status).toBe(201); + expect(response.body).toEqual(savedNotification); + }); + + it('should handle error adding a notification without dismissKey', async () => { + const inputNotification = { + title : 'Test Notification', + text : 'This is a test notification', + startAt : new Date().toISOString(), + stopAt : new Date().toISOString() + }; + + //Change 'save' function to just return itself instead of actually interacting with the database + jest.spyOn(NotificationModel.prototype, 'save') + .mockImplementationOnce(function() { + return Promise.resolve(this); + }); + + const response = await app + .post('/admin/notification/add') + .set('Authorization', 'Basic ' + Buffer.from('admin:password3').toString('base64')) + .send(inputNotification); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ message: 'Dismiss key is required!' }); + }); + + it('should delete a notification based on its dismiss key', async ()=>{ + const dismissKey = 'testKey'; + + jest.spyOn(NotificationModel, 'findOneAndDelete') + .mockImplementationOnce((key) => { + return { exec: jest.fn().mockResolvedValue(key) }; + }); + const response = await app + .delete(`/admin/notification/delete/${dismissKey}`) + .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`); + + expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({'dismissKey': 'testKey'}); + expect(response.status).toBe(200); + expect(response.body).toEqual({ dismissKey: 'testKey' }); + }); + + it('should handle error deleting a notification that doesnt exist', async ()=>{ + const dismissKey = 'testKey'; + + jest.spyOn(NotificationModel, 'findOneAndDelete') + .mockImplementationOnce(() => { + return { exec: jest.fn().mockResolvedValue() }; + }); + const response = await app + .delete(`/admin/notification/delete/${dismissKey}`) + .set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`); + + expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({'dismissKey': 'testKey'}); + expect(response.status).toBe(500); + expect(response.body).toEqual({ message: 'Notification not found' }); + }); + }); +}); diff --git a/server/app.js b/server/app.js index 6dcabec12..17ee3d2d9 100644 --- a/server/app.js +++ b/server/app.js @@ -8,6 +8,8 @@ const express = require('express'); const yaml = require('js-yaml'); const app = express(); const config = require('./config.js'); +const fs = require('fs-extra'); + const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js'); const GoogleActions = require('./googleActions.js'); @@ -451,6 +453,10 @@ if(isLocalEnvironment){ }); } +// Add Static Local Paths +app.use('/staticImages', express.static(config.get('hb_images') && fs.existsSync(config.get('hb_images')) ? config.get('hb_images') :'staticImages')); +app.use('/staticFonts', express.static(config.get('hb_fonts') && fs.existsSync(config.get('hb_fonts')) ? config.get('hb_fonts'):'staticFonts')); + //Vault Page app.get('/vault', asyncHandler(async(req, res, next)=>{ req.ogMeta = { ...defaultMetaTags, diff --git a/server/middleware/content-negotiation.js b/server/middleware/content-negotiation.js index 201e64a25..a5bc7dc83 100644 --- a/server/middleware/content-negotiation.js +++ b/server/middleware/content-negotiation.js @@ -1,12 +1,16 @@ +const config = require('../config.js'); +const nodeEnv = config.get('node_env'); +const isLocalEnvironment = config.get('local_environments').includes(nodeEnv); + module.exports = (req, res, next)=>{ const isImageRequest = req.get('Accept')?.split(',') ?.filter((h)=>!h.includes('q=')) ?.every((h)=>/image\/.*/.test(h)); - if(isImageRequest) { + if(isImageRequest && !isLocalEnvironment && !req.url?.startsWith('/staticImages')) { return res.status(406).send({ message : 'Request for image at this URL is not supported' }); } next(); -}; \ No newline at end of file +}; diff --git a/server/notifications.model.js b/server/notifications.model.js new file mode 100644 index 000000000..0a32bde8a --- /dev/null +++ b/server/notifications.model.js @@ -0,0 +1,62 @@ +const mongoose = require('mongoose'); +const _ = require('lodash'); + +const NotificationSchema = new mongoose.Schema({ + dismissKey : { type: String, unique: true, required: true }, + title : { type: String, default: '' }, + text : { type: String, default: '' }, + createdAt : { type: Date, default: Date.now }, + startAt : { type: Date, default: Date.now }, + stopAt : { type: Date, default: Date.now }, +}, { versionKey: false }); + +NotificationSchema.statics.addNotification = async function(data) { + if(!data.dismissKey) throw { message: 'Dismiss key is required!' }; + + const defaults = { + title : '', + text : '', + startAt : new Date(), + stopAt : new Date(), + }; + + const notificationData = _.defaults(data, defaults); + + try { + const newNotification = new this(notificationData); + const savedNotification = await newNotification.save(); + return savedNotification; + } catch (err) { + throw { message: err.message || 'Error saving notification' }; + } +}; + +NotificationSchema.statics.deleteNotification = async function(dismissKey) { + if(!dismissKey) throw { message: 'Dismiss key is required!' }; + + try { + const deletedNotification = await this.findOneAndDelete({ dismissKey }).exec(); + if(!deletedNotification) { + throw { message: 'Notification not found' }; + } + return deletedNotification; + } catch (err) { + throw { message: err.message || 'Error deleting notification' }; + } +}; + +NotificationSchema.statics.getAll = async function() { + try { + const notifications = await this.find().exec(); + return notifications; + } catch (err) { + throw { message: err.message || 'Error retrieving notifications' }; + } +}; + +const Notification = mongoose.model('Notification', NotificationSchema); + +module.exports = { + schema : NotificationSchema, + model : Notification, +};