mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2025-12-24 14:12:40 +00:00
Merge branch 'master' of https://github.com/naturalcrit/homebrewery into fix-vulnerability-admin-pages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <div className='admin'>
|
||||
|
||||
<header>
|
||||
<div className='container'>
|
||||
<i className='fas fa-rocket' />
|
||||
homebrewery admin
|
||||
</div>
|
||||
</header>
|
||||
<div className='container'>
|
||||
<Stats />
|
||||
<hr />
|
||||
<BrewLookup />
|
||||
<hr />
|
||||
<BrewCleanup />
|
||||
<hr />
|
||||
<BrewCompress />
|
||||
</div>
|
||||
<main className='container'>
|
||||
<nav className='tabs'>
|
||||
{tabGroups.map((tab, idx)=>{ return <button className={tab===this.state.currentTab ? 'active' : ''} key={idx} onClick={()=>{ return this.handleClick(tab); }}>{tab.toUpperCase()}</button>; })}
|
||||
</nav>
|
||||
{this.state.currentTab==='brew' && <BrewUtils />}
|
||||
{this.state.currentTab==='notifications' && <NotificationUtils />}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.BrewCleanup{
|
||||
.removeBox{
|
||||
margin-top: 20px;
|
||||
button{
|
||||
background-color: @red;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.BrewCompress{
|
||||
.removeBox{
|
||||
margin-top: 20px;
|
||||
button{
|
||||
background-color: @red;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
client/admin/brewUtils/brewCleanup/brewCleanup.less
Normal file
9
client/admin/brewUtils/brewCleanup/brewCleanup.less
Normal file
@@ -0,0 +1,9 @@
|
||||
.BrewCleanup {
|
||||
.removeBox {
|
||||
margin-top : 20px;
|
||||
button {
|
||||
margin-right : 10px;
|
||||
background-color : @red;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
client/admin/brewUtils/brewCompress/brewCompress.less
Normal file
9
client/admin/brewUtils/brewCompress/brewCompress.less
Normal file
@@ -0,0 +1,9 @@
|
||||
.BrewCompress {
|
||||
.removeBox {
|
||||
margin-top : 20px;
|
||||
button {
|
||||
margin-right : 10px;
|
||||
background-color : @red;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
require('./brewLookup.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const cx = require('classnames');
|
||||
24
client/admin/brewUtils/brewUtils.jsx
Normal file
24
client/admin/brewUtils/brewUtils.jsx
Normal file
@@ -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 <>
|
||||
<Stats />
|
||||
<hr />
|
||||
<BrewLookup />
|
||||
<hr />
|
||||
<BrewCleanup />
|
||||
<hr />
|
||||
<BrewCompress />
|
||||
</>;
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = BrewUtils;
|
||||
13
client/admin/brewUtils/stats/stats.less
Normal file
13
client/admin/brewUtils/stats/stats.less
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className='notificationAdd'>
|
||||
<h2>Add Notification</h2>
|
||||
|
||||
<label className='field'>
|
||||
Dismiss Key:
|
||||
<input className='fieldInput' type='text' ref={dismissKeyRef} required
|
||||
placeholder='GOOGLEDRIVENOTIF'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className='field'>
|
||||
Title:
|
||||
<input className='fieldInput' type='text' ref={titleRef} required
|
||||
placeholder='Stop using Google Drive as image host'
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className='field'>
|
||||
Text:
|
||||
<textarea className='fieldInput' type='text' ref={textRef} required
|
||||
placeholder='Google Drive is not an image hosting site, you should not use it as such.'
|
||||
>
|
||||
</textarea>
|
||||
</label>
|
||||
|
||||
<label className='field'>
|
||||
Start Date:
|
||||
<input type='date' className='fieldInput' ref={startAtRef} required/>
|
||||
</label>
|
||||
|
||||
<label className='field'>
|
||||
End Date:
|
||||
<input type='date' className='fieldInput' ref={stopAtRef} required/>
|
||||
</label>
|
||||
|
||||
<div className='notificationResult'>{notificationResult}</div>
|
||||
|
||||
<button className='notificationSave' onClick={saveNotification} disabled={searching}>
|
||||
<i className={`fas ${searching ? 'fa-spin fa-spinner' : 'fa-save'}`}/>
|
||||
Save Notification
|
||||
</button>
|
||||
{error && <div className='error'>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = NotificationAdd;
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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 })=>(
|
||||
<>
|
||||
<dl>
|
||||
<dt>Key</dt>
|
||||
<dd>{notification.dismissKey}</dd>
|
||||
|
||||
<dt>Title</dt>
|
||||
<dd>{notification.title || 'No Title'}</dd>
|
||||
|
||||
<dt>Text</dt>
|
||||
<dd>{notification.text || 'No Text'}</dd>
|
||||
|
||||
<dt>Created</dt>
|
||||
<dd>{Moment(notification.createdAt).format('LLLL')}</dd>
|
||||
|
||||
<dt>Start</dt>
|
||||
<dd>{Moment(notification.startAt).format('LLLL') || 'No Start Time'}</dd>
|
||||
|
||||
<dt>Stop</dt>
|
||||
<dd>{Moment(notification.stopAt).format('LLLL') || 'No End Time'}</dd>
|
||||
</dl>
|
||||
<button onClick={()=>onDelete(notification.dismissKey)}>DELETE</button>
|
||||
</>
|
||||
);
|
||||
|
||||
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 <div className='error'>{error}</div>;
|
||||
|
||||
if(notifications.length === 0)
|
||||
return <div className='noNotification'>No notifications available.</div>;
|
||||
|
||||
return (
|
||||
<ul className='notificationList'>
|
||||
{notifications.map((notification)=>(
|
||||
<li key={notification.dismissKey} >
|
||||
<details>
|
||||
<summary>{notification.title || 'No Title'}</summary>
|
||||
<NotificationDetail notification={notification} onDelete={deleteNotification} />
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='notificationLookup'>
|
||||
<h2>Check all Notifications</h2>
|
||||
<button onClick={lookupAll}>
|
||||
<i className={`fas ${searching ? 'fa-spin fa-spinner' : 'fa-search'}`} />
|
||||
</button>
|
||||
{renderNotificationsList()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = NotificationLookup;
|
||||
@@ -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; }
|
||||
}
|
||||
15
client/admin/notificationUtils/notificationUtils.jsx
Normal file
15
client/admin/notificationUtils/notificationUtils.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
const React = require('react');
|
||||
|
||||
const NotificationLookup = require('./notificationLookup/notificationLookup.jsx');
|
||||
const NotificationAdd = require('./notificationAdd/notificationAdd.jsx');
|
||||
|
||||
const NotificationUtils = ()=>{
|
||||
return (
|
||||
<section className='notificationUtils'>
|
||||
<NotificationAdd />
|
||||
<NotificationLookup />
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = NotificationUtils;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,9 @@ const BrewRenderer = (props)=>{
|
||||
theme : '5ePHB',
|
||||
lang : '',
|
||||
errors : [],
|
||||
currentEditorCursorPageNum : 0,
|
||||
currentEditorViewPageNum : 0,
|
||||
currentBrewRendererPageNum : 0,
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentEditorViewPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
themeBundle : {},
|
||||
onPageChange : ()=>{},
|
||||
...props
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
overflow-y : unset;
|
||||
.pages {
|
||||
margin : 0px;
|
||||
zoom: 100% !important;
|
||||
& > .page { box-shadow : unset; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,17 +304,14 @@ const MetadataEditor = createClass({
|
||||
onChange={(e)=>this.handleRenderer('V3', e)} />
|
||||
V3
|
||||
</label>
|
||||
|
||||
<a href='/legacy' target='_blank' rel='noopener noreferrer'>
|
||||
Click here to see the demo page for the old Legacy renderer!
|
||||
</a>
|
||||
<small><a href='/legacy' target='_blank' rel='noopener noreferrer'>Click here to see the demo page for the old Legacy renderer!</a></small>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
|
||||
render : function(){
|
||||
return <div className='metadataEditor'>
|
||||
<h1 className='sectionHead'>Brew</h1>
|
||||
<h1>Properties Editor</h1>
|
||||
|
||||
<div className='field title'>
|
||||
<label>title</label>
|
||||
@@ -362,9 +359,7 @@ const MetadataEditor = createClass({
|
||||
|
||||
{this.renderRenderOptions()}
|
||||
|
||||
<hr/>
|
||||
|
||||
<h1 className='sectionHead'>Authors</h1>
|
||||
<h2>Authors</h2>
|
||||
|
||||
{this.renderAuthors()}
|
||||
|
||||
@@ -375,15 +370,13 @@ const MetadataEditor = createClass({
|
||||
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)}/>
|
||||
|
||||
<hr/>
|
||||
|
||||
<h1 className='sectionHead'>Privacy</h1>
|
||||
<h2>Privacy</h2>
|
||||
|
||||
<div className='field publish'>
|
||||
<label>publish</label>
|
||||
<div className='value'>
|
||||
{this.renderPublish()}
|
||||
<small>Published homebrews will be publicly viewable and searchable (eventually...)</small>
|
||||
<small>Published brews are searchable in <a href='/vault'>the Vault</a> and visible on your user page. Unpublished brews are not indexed in the Vault or visible on your user page, but can still be shared and indexed by search engines. You can unpublish a brew any time.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import 'naturalcrit/styles/colors.less';
|
||||
|
||||
|
||||
.metadataEditor {
|
||||
position : absolute;
|
||||
z-index : 5;
|
||||
@@ -9,12 +10,19 @@
|
||||
padding : 25px;
|
||||
overflow-y : auto;
|
||||
background-color : #999999;
|
||||
font-size : 13px;
|
||||
|
||||
.sectionHead {
|
||||
h1 {
|
||||
margin: 0 0 40px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin : 20px 0;
|
||||
font-weight : 1000;
|
||||
|
||||
&:first-of-type { margin-top : 0; }
|
||||
font-weight : bold;
|
||||
border-bottom: 2px solid gray;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
& > div { margin-bottom : 10px; }
|
||||
@@ -43,15 +51,21 @@
|
||||
min-width : 200px;
|
||||
& > label {
|
||||
width : 80px;
|
||||
font-size : 11px;
|
||||
font-weight : 800;
|
||||
line-height : 1.8em;
|
||||
text-transform : uppercase;
|
||||
font-size: .9em;
|
||||
}
|
||||
& > .value {
|
||||
flex : 1 1 auto;
|
||||
width : 50px;
|
||||
&:invalid { background : #FFB9B9; }
|
||||
small {
|
||||
display : block;
|
||||
font-size : 0.9em;
|
||||
font-style : italic;
|
||||
line-height : 1.4em;
|
||||
}
|
||||
}
|
||||
input[type='text'], textarea {
|
||||
border : 1px solid gray;
|
||||
@@ -78,7 +92,6 @@
|
||||
textarea.value {
|
||||
height : auto;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 0.8em;
|
||||
resize : none;
|
||||
}
|
||||
}
|
||||
@@ -87,12 +100,6 @@
|
||||
z-index : 200;
|
||||
max-width : 150px;
|
||||
}
|
||||
small {
|
||||
display : inline-block;
|
||||
font-size : 0.6em;
|
||||
font-style : italic;
|
||||
line-height : 1.4em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,18 +120,13 @@
|
||||
display : inline-flex;
|
||||
align-items : center;
|
||||
margin-right : 15px;
|
||||
font-size : 0.7em;
|
||||
font-size : 0.9em;
|
||||
font-weight : 800;
|
||||
white-space : nowrap;
|
||||
vertical-align : middle;
|
||||
cursor : pointer;
|
||||
user-select : none;
|
||||
}
|
||||
a {
|
||||
display : inline-flex;
|
||||
font-size : 0.7em;
|
||||
font-weight : 800;
|
||||
}
|
||||
input {
|
||||
margin : 3px;
|
||||
vertical-align : middle;
|
||||
@@ -149,12 +151,10 @@
|
||||
}
|
||||
}
|
||||
.authors.field .value {
|
||||
font-size : 0.8em;
|
||||
line-height : 1.5em;
|
||||
}
|
||||
|
||||
.themes.field {
|
||||
font-size : 13.33px;
|
||||
.navDropdownContainer {
|
||||
position : relative;
|
||||
z-index : 100;
|
||||
@@ -165,9 +165,9 @@
|
||||
background-color : darkgray;
|
||||
}
|
||||
& > div:first-child {
|
||||
padding : 6px 3px;
|
||||
padding : 3px 3px;
|
||||
background-color : inherit;
|
||||
border : 2px solid rgb(118,118,118);
|
||||
border : 1px solid gray;
|
||||
i { float : right; }
|
||||
&:hover {
|
||||
color : white;
|
||||
@@ -240,6 +240,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field .list {
|
||||
display : flex;
|
||||
flex : 1 0;
|
||||
@@ -258,15 +259,15 @@
|
||||
color : white;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
|
||||
|
||||
i {
|
||||
position : relative;
|
||||
top : 50%;
|
||||
transform : translateY(-50%);
|
||||
}
|
||||
|
||||
|
||||
&:not(:last-child) { border-right : 1px solid black; }
|
||||
|
||||
|
||||
&:last-child { border-radius : 0 0.5em 0.5em 0; }
|
||||
}
|
||||
|
||||
@@ -277,8 +278,7 @@
|
||||
background-color : #DDDDDD;
|
||||
border-radius : 0.5em;
|
||||
|
||||
.icon {
|
||||
#groupedIcon; }
|
||||
.icon { #groupedIcon; }
|
||||
}
|
||||
|
||||
.input-group {
|
||||
@@ -294,17 +294,30 @@
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
.invalid:focus { background-color : pink; }
|
||||
.input-group {
|
||||
height : ~'calc(.9em + 4px + .6em)';
|
||||
|
||||
.icon {
|
||||
#groupedIcon;
|
||||
top : -0.54em;
|
||||
right : 1px;
|
||||
height : 97%;
|
||||
font-size : 0.8em;
|
||||
input { border-radius : 0.5em 0 0 0.5em; }
|
||||
|
||||
i { font-size : 1.125em; }
|
||||
input:last-child { border-radius : 0.5em; }
|
||||
|
||||
.value {
|
||||
width : 7.5vw;
|
||||
min-width : 75px;
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
.invalid:focus { background-color : pink; }
|
||||
|
||||
.icon {
|
||||
#groupedIcon;
|
||||
top : -0.54em;
|
||||
right : 1px;
|
||||
height : 97%;
|
||||
|
||||
i { font-size : 1.125em; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
/*eslint max-lines: ["warn", {"max": 250, "skipBlankLines": true, "skipComments": true}]*/
|
||||
/*eslint max-lines: ["warn", {"max": 350, "skipBlankLines": true, "skipComments": true}]*/
|
||||
require('./snippetbar.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const _ = require('lodash');
|
||||
const cx = require('classnames');
|
||||
|
||||
import { getHistoryItems, historyExists } from '../../utils/versionHistory.js';
|
||||
import { loadHistory } from '../../utils/versionHistory.js';
|
||||
|
||||
//Import all themes
|
||||
const ThemeSnippets = {};
|
||||
@@ -50,27 +50,47 @@ const Snippetbar = createClass({
|
||||
renderer : this.props.renderer,
|
||||
themeSelector : false,
|
||||
snippets : [],
|
||||
historyExists : false
|
||||
showHistory : false,
|
||||
historyExists : false,
|
||||
historyItems : []
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount : async function() {
|
||||
componentDidMount : async function(prevState) {
|
||||
const snippets = this.compileSnippets();
|
||||
this.setState({
|
||||
snippets : snippets
|
||||
});
|
||||
},
|
||||
|
||||
componentDidUpdate : async function(prevProps) {
|
||||
componentDidUpdate : async function(prevProps, prevState) {
|
||||
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
|
||||
this.setState({
|
||||
snippets : this.compileSnippets()
|
||||
});
|
||||
};
|
||||
|
||||
this.setState({
|
||||
historyExists : historyExists(this.props.brew)
|
||||
// Update history list if it has changed
|
||||
const checkHistoryItems = await loadHistory(this.props.brew);
|
||||
|
||||
// If all items have the noData property, there is no saved data
|
||||
const checkHistoryExists = !checkHistoryItems.every((historyItem)=>{
|
||||
return historyItem?.noData;
|
||||
});
|
||||
if(prevState.historyExists != checkHistoryExists){
|
||||
this.setState({
|
||||
historyExists : checkHistoryExists
|
||||
});
|
||||
}
|
||||
|
||||
// If any history items have changed, update the list
|
||||
if(checkHistoryExists && checkHistoryItems.some((historyItem, index)=>{
|
||||
return index >= prevState.historyItems.length || !_.isEqual(historyItem, prevState.historyItems[index]);
|
||||
})){
|
||||
this.setState({
|
||||
historyItems : checkHistoryItems
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
mergeCustomizer : function(oldValue, newValue, key) {
|
||||
@@ -148,12 +168,18 @@ const Snippetbar = createClass({
|
||||
return this.props.updateBrew(item);
|
||||
},
|
||||
|
||||
toggleHistoryMenu : function(){
|
||||
this.setState({
|
||||
showHistory : !this.state.showHistory
|
||||
});
|
||||
},
|
||||
|
||||
renderHistoryItems : function() {
|
||||
const historyItems = getHistoryItems(this.props.brew);
|
||||
if(!this.state.historyExists) return;
|
||||
|
||||
return <div className='dropdown'>
|
||||
{_.map(historyItems, (item, index)=>{
|
||||
if(!item.savedAt) return;
|
||||
{_.map(this.state.historyItems, (item, index)=>{
|
||||
if(item.noData || !item.savedAt) return;
|
||||
|
||||
const saveTime = new Date(item.savedAt);
|
||||
const diffMs = new Date() - saveTime;
|
||||
@@ -194,9 +220,10 @@ const Snippetbar = createClass({
|
||||
}
|
||||
|
||||
return <div className='editors'>
|
||||
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`} >
|
||||
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
|
||||
onClick={this.toggleHistoryMenu} >
|
||||
<i className='fas fa-clock-rotate-left' />
|
||||
{this.state.historyExists && this.renderHistoryItems() }
|
||||
{ this.state.showHistory && this.renderHistoryItems() }
|
||||
</div>
|
||||
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
|
||||
onClick={this.props.undo} >
|
||||
|
||||
@@ -128,7 +128,7 @@ const StringArrayEditor = createClass({
|
||||
|
||||
return <div className='field'>
|
||||
<label>{this.props.label}</label>
|
||||
<div style={{ flex: '1 0' }}>
|
||||
<div style={{ flex: '1 0' }} className='value'>
|
||||
<div className='list'>
|
||||
{valueElements}
|
||||
<div className='input-group'>
|
||||
|
||||
@@ -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 <Nav.item className='editTitle'>
|
||||
<input placeholder='Brew Title' type='text' value={this.props.title} onChange={this.handleChange} />
|
||||
|
||||
<div className={cx('charCount', { 'max': this.props.title.length >= MAX_TITLE_LENGTH })}>
|
||||
{this.props.title.length}/{MAX_TITLE_LENGTH}
|
||||
</div>
|
||||
</Nav.item>;
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
module.exports = EditTitle;
|
||||
@@ -36,7 +36,7 @@ const RecentItems = createClass({
|
||||
//== Add current brew to appropriate recent items list (depending on storageKey) ==//
|
||||
if(this.props.storageKey == 'edit'){
|
||||
let editId = this.props.brew.editId;
|
||||
if(this.props.brew.googleId){
|
||||
if(this.props.brew.googleId && !this.props.brew.stubbed){
|
||||
editId = `${this.props.brew.googleId}${this.props.brew.editId}`;
|
||||
}
|
||||
edited = _.filter(edited, (brew)=>{
|
||||
@@ -51,7 +51,7 @@ const RecentItems = createClass({
|
||||
}
|
||||
if(this.props.storageKey == 'view'){
|
||||
let shareId = this.props.brew.shareId;
|
||||
if(this.props.brew.googleId){
|
||||
if(this.props.brew.googleId && !this.props.brew.stubbed){
|
||||
shareId = `${this.props.brew.googleId}${this.props.brew.shareId}`;
|
||||
}
|
||||
viewed = _.filter(viewed, (brew)=>{
|
||||
@@ -83,7 +83,7 @@ const RecentItems = createClass({
|
||||
let edited = JSON.parse(localStorage.getItem(EDIT_KEY) || '[]');
|
||||
if(this.props.storageKey == 'edit') {
|
||||
let prevEditId = prevProps.brew.editId;
|
||||
if(prevProps.brew.googleId){
|
||||
if(prevProps.brew.googleId && !this.props.brew.stubbed){
|
||||
prevEditId = `${prevProps.brew.googleId}${prevProps.brew.editId}`;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ const RecentItems = createClass({
|
||||
return brew.id !== prevEditId;
|
||||
});
|
||||
let editId = this.props.brew.editId;
|
||||
if(this.props.brew.googleId){
|
||||
if(this.props.brew.googleId && !this.props.brew.stubbed){
|
||||
editId = `${this.props.brew.googleId}${this.props.brew.editId}`;
|
||||
}
|
||||
edited.unshift({
|
||||
|
||||
@@ -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 <Nav.item icon='fa-reddit-alien' color='red' onClick={this.handleClick}>
|
||||
share on reddit
|
||||
</Nav.item>;
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
module.exports = RedditShare;
|
||||
@@ -32,7 +32,7 @@ import { updateHistory, versionHistoryGarbageCollection } from '../../utils/vers
|
||||
|
||||
const googleDriveIcon = require('../../googleDrive.svg');
|
||||
|
||||
const SAVE_TIMEOUT = 3000;
|
||||
const SAVE_TIMEOUT = 10000;
|
||||
|
||||
const EditPage = createClass({
|
||||
displayName : 'EditPage',
|
||||
@@ -228,8 +228,8 @@ const EditPage = createClass({
|
||||
htmlErrors : Markdown.validate(prevState.brew.text)
|
||||
}));
|
||||
|
||||
updateHistory(this.state.brew);
|
||||
versionHistoryGarbageCollection();
|
||||
await updateHistory(this.state.brew);
|
||||
await versionHistoryGarbageCollection();
|
||||
|
||||
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ const SharePage = createClass({
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
themeBundle : {}
|
||||
themeBundle : {},
|
||||
currentBrewRendererPageNum : 1
|
||||
};
|
||||
},
|
||||
|
||||
@@ -39,6 +40,10 @@ const SharePage = createClass({
|
||||
document.removeEventListener('keydown', this.handleControlKeys);
|
||||
},
|
||||
|
||||
handleBrewRendererPageChange : function(pageNumber){
|
||||
this.setState({ currentBrewRendererPageNum: pageNumber });
|
||||
},
|
||||
|
||||
handleControlKeys : function(e){
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
const P_KEY = 80;
|
||||
@@ -114,9 +119,12 @@ const SharePage = createClass({
|
||||
<BrewRenderer
|
||||
text={this.props.brew.text}
|
||||
style={this.props.brew.style}
|
||||
lang={this.props.brew.lang}
|
||||
renderer={this.props.brew.renderer}
|
||||
theme={this.props.brew.theme}
|
||||
themeBundle={this.state.themeBundle}
|
||||
onPageChange={this.handleBrewRendererPageChange}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/*eslint max-lines: ["warn", {"max": 400, "skipBlankLines": true, "skipComments": true}]*/
|
||||
/*eslint max-params:["warn", { max: 10 }], */
|
||||
require('./vaultPage.less');
|
||||
|
||||
const React = require('react');
|
||||
@@ -18,13 +20,15 @@ const request = require('../../utils/request-middleware.js');
|
||||
const VaultPage = (props)=>{
|
||||
const [pageState, setPageState] = useState(parseInt(props.query.page) || 1);
|
||||
|
||||
const [sortState, setSort] = useState(props.query.sort || 'title');
|
||||
const [dirState, setdir] = useState(props.query.dir || 'asc');
|
||||
|
||||
//Response state
|
||||
const [brewCollection, setBrewCollection] = useState(null);
|
||||
const [totalBrews, setTotalBrews] = useState(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
|
||||
const titleRef = useRef(null);
|
||||
const authorRef = useRef(null);
|
||||
const countRef = useRef(null);
|
||||
@@ -34,7 +38,7 @@ const VaultPage = (props)=>{
|
||||
|
||||
useEffect(()=>{
|
||||
disableSubmitIfFormInvalid();
|
||||
loadPage(pageState, true);
|
||||
loadPage(pageState, true, props.query.sort, props.query.dir);
|
||||
}, []);
|
||||
|
||||
const updateStateWithBrews = (brews, page)=>{
|
||||
@@ -43,7 +47,7 @@ const VaultPage = (props)=>{
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const updateUrl = (titleValue, authorValue, countValue, v3Value, legacyValue, page)=>{
|
||||
const updateUrl = (titleValue, authorValue, countValue, v3Value, legacyValue, page, sort, dir)=>{
|
||||
const url = new URL(window.location.href);
|
||||
const urlParams = new URLSearchParams(url.search);
|
||||
|
||||
@@ -53,21 +57,23 @@ const VaultPage = (props)=>{
|
||||
urlParams.set('v3', v3Value);
|
||||
urlParams.set('legacy', legacyValue);
|
||||
urlParams.set('page', page);
|
||||
urlParams.set('sort', sort);
|
||||
urlParams.set('dir', dir);
|
||||
|
||||
url.search = urlParams.toString();
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
};
|
||||
|
||||
const performSearch = async (title, author, count, v3, legacy, page)=>{
|
||||
updateUrl(title, author, count, v3, legacy, page);
|
||||
const performSearch = async (title, author, count, v3, legacy, page, sort, dir)=>{
|
||||
updateUrl(title, author, count, v3, legacy, page, sort, dir);
|
||||
|
||||
const response = await request.get(
|
||||
`/api/vault?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}&count=${count}&page=${page}`
|
||||
).catch((error)=>{
|
||||
console.log('error at loadPage: ', error);
|
||||
setError(error);
|
||||
updateStateWithBrews([], 1);
|
||||
});
|
||||
const response = await request
|
||||
.get(`/api/vault?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}&count=${count}&page=${page}&sort=${sort}&dir=${dir}`)
|
||||
.catch((error)=>{
|
||||
console.log('error at loadPage: ', error);
|
||||
setError(error);
|
||||
updateStateWithBrews([], 1);
|
||||
});
|
||||
|
||||
if(response.ok)
|
||||
updateStateWithBrews(response.body.brews, page);
|
||||
@@ -76,9 +82,8 @@ const VaultPage = (props)=>{
|
||||
const loadTotal = async (title, author, v3, legacy)=>{
|
||||
setTotalBrews(null);
|
||||
|
||||
const response = await request.get(
|
||||
`/api/vault/total?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}`
|
||||
).catch((error)=>{
|
||||
const response = await request.get(`/api/vault/total?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}`)
|
||||
.catch((error)=>{
|
||||
console.log('error at loadTotal: ', error);
|
||||
setError(error);
|
||||
updateStateWithBrews([], 1);
|
||||
@@ -88,9 +93,8 @@ const VaultPage = (props)=>{
|
||||
setTotalBrews(response.body.totalBrews);
|
||||
};
|
||||
|
||||
const loadPage = async (page, updateTotal)=>{
|
||||
if(!validateForm())
|
||||
return;
|
||||
const loadPage = async (page, updateTotal, sort, dir)=>{
|
||||
if(!validateForm()) return;
|
||||
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
@@ -100,8 +104,14 @@ const VaultPage = (props)=>{
|
||||
const count = countRef.current.value || 10;
|
||||
const v3 = v3Ref.current.checked != false;
|
||||
const legacy = legacyRef.current.checked != false;
|
||||
const sortOption = sort || 'title';
|
||||
const dirOption = dir || 'asc';
|
||||
const pageProp = page || 1;
|
||||
|
||||
performSearch(title, author, count, v3, legacy, page);
|
||||
setSort(sortOption);
|
||||
setdir(dirOption);
|
||||
|
||||
performSearch(title, author, count, v3, legacy, pageProp, sortOption, dirOption);
|
||||
|
||||
if(updateTotal)
|
||||
loadTotal(title, author, v3, legacy);
|
||||
@@ -248,6 +258,33 @@ const VaultPage = (props)=>{
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSortOption = (optionTitle, optionValue)=>{
|
||||
const oppositeDir = dirState === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
return (
|
||||
<div className={`sort-option ${sortState === optionValue ? `active` : ''}`}>
|
||||
<button onClick={()=>loadPage(1, false, optionValue, oppositeDir)}>
|
||||
{optionTitle}
|
||||
</button>
|
||||
{sortState === optionValue && (
|
||||
<i className={`sortDir fas ${dirState === 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSortBar = ()=>{
|
||||
|
||||
return (
|
||||
<div className='sort-container'>
|
||||
{renderSortOption('Title', 'title', props.query.dir)}
|
||||
{renderSortOption('Created Date', 'createdAt', props.query.dir)}
|
||||
{renderSortOption('Updated Date', 'updatedAt', props.query.dir)}
|
||||
{renderSortOption('Views', 'views', props.query.dir)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPaginationControls = ()=>{
|
||||
if(!totalBrews) return null;
|
||||
|
||||
@@ -271,10 +308,8 @@ const VaultPage = (props)=>{
|
||||
.map((_, index)=>(
|
||||
<a
|
||||
key={startPage + index}
|
||||
className={`pageNumber ${
|
||||
pageState === startPage + index ? 'currentPage' : ''
|
||||
}`}
|
||||
onClick={()=>loadPage(startPage + index, false)}
|
||||
className={`pageNumber ${pageState === startPage + index ? 'currentPage' : ''}`}
|
||||
onClick={()=>loadPage(startPage + index, false, sortState, dirState)}
|
||||
>
|
||||
{startPage + index}
|
||||
</a>
|
||||
@@ -284,7 +319,7 @@ const VaultPage = (props)=>{
|
||||
<div className='paginationControls'>
|
||||
<button
|
||||
className='previousPage'
|
||||
onClick={()=>loadPage(pageState - 1, false)}
|
||||
onClick={()=>loadPage(pageState - 1, false, sortState, dirState)}
|
||||
disabled={pageState === startPage}
|
||||
>
|
||||
<i className='fa-solid fa-chevron-left'></i>
|
||||
@@ -293,7 +328,7 @@ const VaultPage = (props)=>{
|
||||
{startPage > 1 && (
|
||||
<a
|
||||
className='pageNumber firstPage'
|
||||
onClick={()=>loadPage(1, false)}
|
||||
onClick={()=>loadPage(1, false, sortState, dirState)}
|
||||
>
|
||||
1 ...
|
||||
</a>
|
||||
@@ -302,7 +337,7 @@ const VaultPage = (props)=>{
|
||||
{endPage < totalPages && (
|
||||
<a
|
||||
className='pageNumber lastPage'
|
||||
onClick={()=>loadPage(totalPages, false)}
|
||||
onClick={()=>loadPage(totalPages, false, sortState, dirState)}
|
||||
>
|
||||
... {totalPages}
|
||||
</a>
|
||||
@@ -310,7 +345,7 @@ const VaultPage = (props)=>{
|
||||
</ol>
|
||||
<button
|
||||
className='nextPage'
|
||||
onClick={()=>loadPage(pageState + 1, false)}
|
||||
onClick={()=>loadPage(pageState + 1, false, sortState, dirState)}
|
||||
disabled={pageState === totalPages}
|
||||
>
|
||||
<i className='fa-solid fa-chevron-right'></i>
|
||||
@@ -385,6 +420,7 @@ const VaultPage = (props)=>{
|
||||
<div className='form dataGroup'>{renderForm()}</div>
|
||||
|
||||
<div className='resultsContainer dataGroup'>
|
||||
{renderSortBar()}
|
||||
{renderFoundBrews()}
|
||||
</div>
|
||||
</SplitPane>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*:not(input) { user-select : none; }
|
||||
|
||||
.content {
|
||||
height : 100%;
|
||||
background : #2C3E50;
|
||||
height: 100%;
|
||||
|
||||
.dataGroup {
|
||||
width : 100%;
|
||||
@@ -27,9 +27,9 @@
|
||||
|
||||
code {
|
||||
padding-inline : 5px;
|
||||
font-family : monospace;
|
||||
background : lightgrey;
|
||||
border-radius : 5px;
|
||||
font-family : monospace;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
@@ -165,6 +165,48 @@
|
||||
color : white;
|
||||
}
|
||||
|
||||
.sort-container {
|
||||
display : flex;
|
||||
flex-wrap : wrap;
|
||||
column-gap : 15px;
|
||||
justify-content : center;
|
||||
height : 30px;
|
||||
color : white;
|
||||
background-color : #555555;
|
||||
border-top : 1px solid #666666;
|
||||
border-bottom : 1px solid #666666;
|
||||
|
||||
.sort-option {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
padding : 0 8px;
|
||||
|
||||
&:hover { background-color : #444444; }
|
||||
|
||||
&.active {
|
||||
background-color : #333333;
|
||||
|
||||
button {
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
|
||||
& + .sortDir { padding-left : 5px; }
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding : 0;
|
||||
font-size : 11px;
|
||||
font-weight : normal;
|
||||
color : #CCCCCC;
|
||||
text-transform : uppercase;
|
||||
background-color : transparent;
|
||||
|
||||
&:hover { background : none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.foundBrews {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
@@ -236,15 +278,15 @@
|
||||
width : 47%;
|
||||
margin-right : 40px;
|
||||
color : black;
|
||||
isolation:isolate;
|
||||
isolation : isolate;
|
||||
|
||||
&:after {
|
||||
position:absolute;
|
||||
inset:0;
|
||||
display:block;
|
||||
content:'';
|
||||
&::after {
|
||||
position : absolute;
|
||||
inset : 0;
|
||||
z-index : -2;
|
||||
display : block;
|
||||
content : '';
|
||||
background-image : url('/assets/parchmentBackground.jpg');
|
||||
z-index:-1;
|
||||
}
|
||||
|
||||
&:nth-child(even of .brewItem) { margin-right : 0; }
|
||||
@@ -257,28 +299,24 @@
|
||||
color : var(--HB_Color_HeaderText);
|
||||
}
|
||||
.info {
|
||||
position : relative;
|
||||
z-index : 2;
|
||||
font-family : 'ScalySansRemake';
|
||||
font-size : 1.2em;
|
||||
position:relative;
|
||||
z-index:2;
|
||||
|
||||
>span {
|
||||
margin-right : 12px;
|
||||
line-height : 1.5em;
|
||||
}
|
||||
}
|
||||
.links {
|
||||
z-index:2;
|
||||
}
|
||||
.links { z-index : 2; }
|
||||
|
||||
hr {
|
||||
margin: 0px;
|
||||
visibility: hidden;
|
||||
margin : 0px;
|
||||
visibility : hidden;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
z-index:1;
|
||||
}
|
||||
.thumbnail { z-index : -1; }
|
||||
}
|
||||
|
||||
.paginationControls {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as IDB from 'idb-keyval/dist/index.js';
|
||||
|
||||
export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY';
|
||||
export const HISTORY_SLOTS = 5;
|
||||
|
||||
// History values in minutes
|
||||
const DEFAULT_HISTORY_SAVE_DELAYS = {
|
||||
const HISTORY_SAVE_DELAYS = {
|
||||
'0' : 0,
|
||||
'1' : 2,
|
||||
'2' : 10,
|
||||
@@ -10,29 +12,30 @@ const DEFAULT_HISTORY_SAVE_DELAYS = {
|
||||
'4' : 12 * 60,
|
||||
'5' : 2 * 24 * 60
|
||||
};
|
||||
// const HISTORY_SAVE_DELAYS = {
|
||||
// '0' : 0,
|
||||
// '1' : 1,
|
||||
// '2' : 2,
|
||||
// '3' : 3,
|
||||
// '4' : 4,
|
||||
// '5' : 5
|
||||
// };
|
||||
|
||||
const DEFAULT_GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
|
||||
|
||||
const HISTORY_SAVE_DELAYS = global.config?.historyData?.HISTORY_SAVE_DELAYS ?? DEFAULT_HISTORY_SAVE_DELAYS;
|
||||
const GARBAGE_COLLECT_DELAY = global.config?.historyData?.GARBAGE_COLLECT_DELAY ?? DEFAULT_GARBAGE_COLLECT_DELAY;
|
||||
const HB_DB = 'HOMEBREWERY-DB';
|
||||
const HB_STORE = 'HISTORY';
|
||||
|
||||
const GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
|
||||
// const GARBAGE_COLLECT_DELAY = 10;
|
||||
|
||||
|
||||
function getKeyBySlot(brew, slot){
|
||||
// Return a string representing the key for this brew and history slot
|
||||
return `${HISTORY_PREFIX}-${brew.shareId}-${slot}`;
|
||||
};
|
||||
|
||||
function getVersionBySlot(brew, slot){
|
||||
// Read stored brew data
|
||||
// - If it exists, parse data to object
|
||||
// - If it doesn't exist, pass default object
|
||||
const key = getKeyBySlot(brew, slot);
|
||||
const storedVersion = localStorage.getItem(key);
|
||||
const output = storedVersion ? JSON.parse(storedVersion) : { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true };
|
||||
return output;
|
||||
};
|
||||
|
||||
function updateStoredBrew(brew, slot = 0) {
|
||||
function parseBrewForStorage(brew, slot = 0) {
|
||||
// Strip out unneeded object properties
|
||||
// Returns an array of [ key, brew ]
|
||||
const archiveBrew = {
|
||||
title : brew.title,
|
||||
text : brew.text,
|
||||
@@ -46,44 +49,55 @@ function updateStoredBrew(brew, slot = 0) {
|
||||
archiveBrew.expireAt.setMinutes(archiveBrew.expireAt.getMinutes() + HISTORY_SAVE_DELAYS[slot]);
|
||||
|
||||
const key = getKeyBySlot(brew, slot);
|
||||
localStorage.setItem(key, JSON.stringify(archiveBrew));
|
||||
|
||||
return [key, archiveBrew];
|
||||
}
|
||||
|
||||
|
||||
export function historyExists(brew){
|
||||
return Object.keys(localStorage)
|
||||
.some((key)=>{
|
||||
return key.startsWith(`${HISTORY_PREFIX}-${brew.shareId}`);
|
||||
});
|
||||
// Create a custom IDB store
|
||||
async function createHBStore(){
|
||||
return await IDB.createStore(HB_DB, HB_STORE);
|
||||
}
|
||||
|
||||
export function loadHistory(brew){
|
||||
const history = {};
|
||||
export async function loadHistory(brew){
|
||||
const DEFAULT_HISTORY_ITEM = { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true };
|
||||
|
||||
// Load data from local storage to History object
|
||||
const historyKeys = [];
|
||||
|
||||
// Create array of all history keys
|
||||
for (let i = 1; i <= HISTORY_SLOTS; i++){
|
||||
history[i] = getVersionBySlot(brew, i);
|
||||
historyKeys.push(getKeyBySlot(brew, i));
|
||||
};
|
||||
|
||||
return history;
|
||||
// Load all keys from IDB at once
|
||||
const dataArray = await IDB.getMany(historyKeys, await createHBStore());
|
||||
return dataArray.map((data)=>{ return data ?? DEFAULT_HISTORY_ITEM; });
|
||||
}
|
||||
|
||||
export function updateHistory(brew) {
|
||||
const history = loadHistory(brew);
|
||||
export async function updateHistory(brew) {
|
||||
const history = await loadHistory(brew);
|
||||
|
||||
// Walk each version position
|
||||
for (let slot = HISTORY_SLOTS; slot > 0; slot--){
|
||||
for (let slot = HISTORY_SLOTS - 1; slot >= 0; slot--){
|
||||
const storedVersion = history[slot];
|
||||
|
||||
// If slot has expired, update all lower slots and break
|
||||
if(new Date() >= new Date(storedVersion.expireAt)){
|
||||
for (let updateSlot = slot - 1; updateSlot>0; updateSlot--){
|
||||
|
||||
// Create array of arrays : [ [key1, value1], [key2, value2], ..., [keyN, valueN] ]
|
||||
// to pass to IDB.setMany
|
||||
const historyUpdate = [];
|
||||
|
||||
for (let updateSlot = slot; updateSlot > 0; updateSlot--){
|
||||
// Move data from updateSlot to updateSlot + 1
|
||||
!history[updateSlot]?.noData && updateStoredBrew(history[updateSlot], updateSlot + 1);
|
||||
if(!history[updateSlot - 1]?.noData) {
|
||||
historyUpdate.push(parseBrewForStorage(history[updateSlot - 1], updateSlot + 1));
|
||||
}
|
||||
};
|
||||
|
||||
// Update the most recent brew
|
||||
updateStoredBrew(brew, 1);
|
||||
historyUpdate.push(parseBrewForStorage(brew, 1));
|
||||
|
||||
await IDB.setMany(historyUpdate, await createHBStore());
|
||||
|
||||
// Break out of data checks because we found an expired value
|
||||
break;
|
||||
@@ -91,26 +105,15 @@ export function updateHistory(brew) {
|
||||
};
|
||||
};
|
||||
|
||||
export function getHistoryItems(brew){
|
||||
const historyArray = [];
|
||||
export async function versionHistoryGarbageCollection(){
|
||||
|
||||
for (let i = 1; i <= HISTORY_SLOTS; i++){
|
||||
historyArray.push(getVersionBySlot(brew, i));
|
||||
}
|
||||
const entries = await IDB.entries(await createHBStore());
|
||||
|
||||
return historyArray;
|
||||
};
|
||||
|
||||
export function versionHistoryGarbageCollection(){
|
||||
Object.keys(localStorage)
|
||||
.filter((key)=>{
|
||||
return key.startsWith(HISTORY_PREFIX);
|
||||
})
|
||||
.forEach((key)=>{
|
||||
const collectAt = new Date(JSON.parse(localStorage.getItem(key)).savedAt);
|
||||
collectAt.setMinutes(collectAt.getMinutes() + GARBAGE_COLLECT_DELAY);
|
||||
if(new Date() > collectAt){
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
for (const [key, value] of entries){
|
||||
const expireAt = new Date(value.savedAt);
|
||||
expireAt.setMinutes(expireAt.getMinutes() + GARBAGE_COLLECT_DELAY);
|
||||
if(new Date() > expireAt){
|
||||
await IDB.del(key, await createHBStore());
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
|
||||
44
package.json
44
package.json
@@ -4,17 +4,17 @@
|
||||
"version": "3.15.0",
|
||||
"engines": {
|
||||
"npm": "^10.2.x",
|
||||
"node": "^20.8.x"
|
||||
"node": "^20.17.x"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/naturalcrit/homebrewery.git"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.js",
|
||||
"quick": "node scripts/quick.js",
|
||||
"build": "node scripts/buildHomebrew.js && node scripts/buildAdmin.js",
|
||||
"builddev": "node scripts/buildHomebrew.js --dev",
|
||||
"dev": "node --experimental-require-module scripts/dev.js",
|
||||
"quick": "node --experimental-require-module scripts/quick.js",
|
||||
"build": "node --experimental-require-module scripts/buildHomebrew.js && node --experimental-require-module scripts/buildAdmin.js",
|
||||
"builddev": "node --experimental-require-module scripts/buildHomebrew.js --dev",
|
||||
"lint": "eslint --fix",
|
||||
"lint:dry": "eslint",
|
||||
"stylelint": "stylelint --fix **/*.{less}",
|
||||
@@ -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",
|
||||
@@ -37,10 +38,10 @@
|
||||
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
|
||||
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
|
||||
"test:route": "jest tests/routes/static-pages.test.js --verbose",
|
||||
"phb": "node scripts/phb.js",
|
||||
"phb": "node --experimental-require-module scripts/phb.js",
|
||||
"prod": "set NODE_ENV=production && npm run build",
|
||||
"postinstall": "npm run build",
|
||||
"start": "node server.js"
|
||||
"start": "node --experimental-require-module server.js"
|
||||
},
|
||||
"author": "stolksdorf",
|
||||
"license": "MIT",
|
||||
@@ -85,23 +86,24 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-transform-runtime": "^7.25.4",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-react": "^7.24.7",
|
||||
"@babel/core": "^7.25.7",
|
||||
"@babel/plugin-transform-runtime": "^7.25.7",
|
||||
"@babel/preset-env": "^7.25.7",
|
||||
"@babel/preset-react": "^7.25.7",
|
||||
"@googleapis/drive": "^8.14.0",
|
||||
"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.6",
|
||||
"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",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jwt-simple": "^0.5.6",
|
||||
"less": "^3.13.1",
|
||||
@@ -113,7 +115,7 @@
|
||||
"marked-smartypants-lite": "^1.0.2",
|
||||
"markedLegacy": "npm:marked@^0.3.19",
|
||||
"moment": "^2.30.1",
|
||||
"mongoose": "^8.6.2",
|
||||
"mongoose": "^8.7.1",
|
||||
"nanoid": "3.3.4",
|
||||
"nconf": "^0.12.1",
|
||||
"react": "^18.3.1",
|
||||
@@ -125,17 +127,17 @@
|
||||
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/stylelint-plugin": "^3.0.1",
|
||||
"eslint": "^9.10.0",
|
||||
"@stylistic/stylelint-plugin": "^3.1.1",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-plugin-jest": "^28.8.3",
|
||||
"eslint-plugin-react": "^7.36.1",
|
||||
"globals": "^15.9.0",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"globals": "^15.11.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"postcss-less": "^6.0.0",
|
||||
"stylelint": "^16.9.0",
|
||||
"stylelint-config-recess-order": "^5.1.0",
|
||||
"stylelint-config-recess-order": "^5.1.1",
|
||||
"stylelint-config-recommended": "^14.0.1",
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 templateFn = require('../client/template.js');
|
||||
@@ -137,12 +138,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;
|
||||
|
||||
116
server/admin.api.spec.js
Normal file
116
server/admin.api.spec.js
Normal file
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
@@ -475,8 +481,7 @@ const renderPage = async (req, res)=>{
|
||||
const configuration = {
|
||||
local : isLocalEnvironment,
|
||||
publicUrl : config.get('publicUrl') ?? '',
|
||||
environment : nodeEnv,
|
||||
history : config.get('historyConfig') ?? {}
|
||||
environment : nodeEnv
|
||||
};
|
||||
const props = {
|
||||
version : require('./../package.json').version,
|
||||
@@ -520,7 +525,7 @@ app.use(async (err, req, res, next)=>{
|
||||
err.originalUrl = req.originalUrl;
|
||||
console.error(err);
|
||||
|
||||
if(err.originalUrl?.startsWith('/api/')) {
|
||||
if(err.originalUrl?.startsWith('/api')) {
|
||||
// console.log('API error');
|
||||
res.status(err.status || err.response?.status || 500).send(err);
|
||||
return;
|
||||
|
||||
@@ -172,7 +172,6 @@ const GoogleActions = {
|
||||
})
|
||||
.catch((err)=>{
|
||||
console.log('Error saving to google');
|
||||
console.error(err);
|
||||
throw (err);
|
||||
});
|
||||
|
||||
@@ -211,7 +210,6 @@ const GoogleActions = {
|
||||
})
|
||||
.catch((err)=>{
|
||||
console.log('Error while creating new Google brew');
|
||||
console.error(err);
|
||||
throw (err);
|
||||
});
|
||||
|
||||
|
||||
@@ -242,11 +242,8 @@ const api = {
|
||||
|
||||
let googleId, saved;
|
||||
if(saveToGoogle) {
|
||||
googleId = await api.newGoogleBrew(req.account, newHomebrew, res)
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
res.status(err?.status || err?.response?.status || 500).send(err?.message || err);
|
||||
});
|
||||
googleId = await api.newGoogleBrew(req.account, newHomebrew, res);
|
||||
|
||||
if(!googleId) return;
|
||||
api.excludeStubProps(newHomebrew);
|
||||
newHomebrew.googleId = googleId;
|
||||
@@ -351,19 +348,13 @@ const api = {
|
||||
brew.googleId = undefined;
|
||||
} else if(!brew.googleId && saveToGoogle) {
|
||||
// If we don't have a google id and the user wants to save to google, create the google brew and set the google id on the brew
|
||||
brew.googleId = await api.newGoogleBrew(req.account, api.excludeGoogleProps(brew), res)
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
res.status(err.status || err.response.status).send(err.message || err);
|
||||
});
|
||||
brew.googleId = await api.newGoogleBrew(req.account, api.excludeGoogleProps(brew), res);
|
||||
|
||||
if(!brew.googleId) return;
|
||||
} else if(brew.googleId) {
|
||||
// If the google id exists and no other actions are being performed, update the google brew
|
||||
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew))
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
res.status(err?.response?.status || 500).send(err);
|
||||
});
|
||||
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew));
|
||||
|
||||
if(!updated) return;
|
||||
}
|
||||
|
||||
|
||||
@@ -560,16 +560,6 @@ brew`);
|
||||
views : 0
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle google error', async()=>{
|
||||
google.newGoogleBrew = jest.fn(()=>{
|
||||
throw 'err';
|
||||
});
|
||||
await api.newBrew({ body: { text: 'asdf', title: '' }, query: { saveToGoogle: true }, account: { username: 'test user' } }, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.send).toHaveBeenCalledWith('err');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteGoogleBrew', ()=>{
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
|
||||
62
server/notifications.model.js
Normal file
62
server/notifications.model.js
Normal file
@@ -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,
|
||||
};
|
||||
@@ -29,12 +29,18 @@ const rendererConditions = (legacy, v3)=>{
|
||||
return {}; // If all renderers selected, renderer field not needed in query for speed
|
||||
};
|
||||
|
||||
const sortConditions = (sort, dir) => {
|
||||
return { [sort]: dir === 'asc' ? 1 : -1 };
|
||||
};
|
||||
|
||||
const findBrews = async (req, res)=>{
|
||||
const title = req.query.title || '';
|
||||
const author = req.query.author || '';
|
||||
const page = Math.max(parseInt(req.query.page) || 1, 1);
|
||||
const count = Math.max(parseInt(req.query.count) || 20, 10);
|
||||
const skip = (page - 1) * count;
|
||||
const sort = req.query.sort || 'title';
|
||||
const dir = req.query.dir || 'asc';
|
||||
|
||||
const combinedQuery = {
|
||||
$and : [
|
||||
@@ -54,6 +60,7 @@ const findBrews = async (req, res)=>{
|
||||
};
|
||||
|
||||
await HomebrewModel.find(combinedQuery, projection)
|
||||
.sort(sortConditions(sort, dir))
|
||||
.skip(skip)
|
||||
.limit(count)
|
||||
.maxTimeMS(5000)
|
||||
|
||||
Reference in New Issue
Block a user