0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2025-12-28 07:02:38 +00:00

Merge branch 'master' into addLockRoutes-#3326

This commit is contained in:
G.Ambatte
2025-03-27 17:43:21 +13:00
committed by GitHub
166 changed files with 8599 additions and 7186 deletions

View File

@@ -1,67 +1,50 @@
require('./admin.less');
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');
import './admin.less';
import React, { useEffect, useState } from 'react';
const BrewUtils = require('./brewUtils/brewUtils.jsx');
const NotificationUtils = require('./notificationUtils/notificationUtils.jsx');
import AuthorUtils from './authorUtils/authorUtils.jsx';
const LockTools = require('./lockTools/lockTools.jsx');
const tabGroups = ['brews', 'locks'];
const tabGroups = ['brew', 'notifications', 'authors'];
const Admin = createClass({
getDefaultProps : function() {
return {};
},
const Admin = ()=>{
const [currentTab, setCurrentTab] = useState('brew');
getInitialState : function() {
return {
currentTab : 'brews'
};
},
useEffect(()=>{
setCurrentTab(localStorage.getItem('hbAdminTab'));
}, []);
handleClick : function(newTab) {
if(this.state.currentTab === newTab) return;
this.setState({
currentTab : newTab
});
},
renderBrewTools : function(){
return <>
<Stats />
<hr />
<BrewLookup />
<hr />
<BrewCleanup />
<hr />
<BrewCompress />
</>;
},
render : function(){
return <div className='admin'>
useEffect(()=>{
localStorage.setItem('hbAdminTab', currentTab);
}, [currentTab]);
return (
<div className='admin'>
<header>
<div className='container'>
<i className='fas fa-rocket' />
homebrewery admin
The Homebrewery Admin Page
<a href='/'>back to homepage</a>
</div>
</header>
<div className='container'>
<div className='tabs'>
{tabGroups.map((name, idx)=>{
return <button className={`tab ${this.state.currentTab === name ? 'active' : ''}`} key={idx} onClick={()=>{return this.handleClick(name);}}>{name.toUpperCase()}</button>;
})}
</div>
{this.state.currentTab == 'brews' && this.renderBrewTools()}
{this.state.currentTab == 'locks' && <LockTools />}
</div>
</div>;
}
});
<main className='container'>
<nav className='tabs'>
{tabGroups.map((tab, idx)=>(
<button
className={tab === currentTab ? 'active' : ''}
key={idx}
onClick={()=>setCurrentTab(tab)}>
{tab.toUpperCase()}
</button>
))}
</nav>
{currentTab === 'brew' && <BrewUtils />}
{currentTab === 'notifications' && <NotificationUtils />}
{currentTab === 'authors' && <AuthorUtils />}
{currentTab === 'locks' && <LockTools />}
</main>
</div>
);
};
module.exports = Admin;

View File

@@ -6,57 +6,132 @@
@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{
header{
:where(.admin) {
padding-bottom : 50px;
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; }
a { float : right; }
}
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 {
display : grid;
grid-template-columns : 120px 1fr;
row-gap : 10px;
align-items : center;
justify-items : start;
padding-top : 0.5em;
dt {
float : left;
clear : left;
height : fit-content;
font-weight : 900;
text-align : right;
&::after { content : ' : '; }
}
dd { height : fit-content; }
}
.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;
}
table {
padding : 10px;
.tabs {
border-bottom: 1px solid black;
}
tr {
border-bottom : 1px solid;
&:last-of-type { border : none; }
&:nth-child(even) { background : #DDDDDD; }
}
button.tab {
margin: 2px 2px 0px;
border-width: 1px 1px 0px;
border-style: solid;
border-color: black;
border-radius: 5px 5px 0px 0px;
color: black;
background-color: transparent;
&.active {
background-color: #000000a0;
color: white;
text-decoration: underline;
thead {
background : rgb(193,236,230);
border-bottom : 2px solid;
}
th, td {
padding : 5px 10px;
vertical-align : middle;
text-align : center;
border-right : 1px solid;
&:last-child { border-right : none; }
}
th { font-weight : 900; }
td {
&:first-child {
font-weight : 900;
text-align : left;
}
}
}
.error {
float : right;
padding : 10px;
margin-block : 10px;
font-weight : 900;
color : white;
background : rgb(178, 54, 54);
}
}

View File

@@ -0,0 +1,87 @@
import './authorLookup.less';
import React from 'react';
import request from 'superagent';
const authorLookup = ()=>{
const [author, setAuthor] = React.useState('');
const [searching, setSearching] = React.useState(false);
const [results, setResults] = React.useState([]);
const lookup = async ()=>{
if(!author) return;
setSearching(true);
setResults([]);
const brews = await request.get(`/admin/user/list/${author}`);
setResults(brews.body);
setSearching(false);
};
const renderResults = ()=>{
if(results.length == 0) return <>
<h2>Results</h2>
<p>None found.</p>
</>;
return <>
<h2>{`Results - ${results.length} brews` }</h2>
<table className='resultsTable'>
<thead>
<tr>
<th>Title</th>
<th>Share</th>
<th>Edit</th>
<th>Last Update</th>
<th>Storage</th>
</tr>
</thead>
<tbody>
{results
.sort((a, b)=>{ // Sort brews from most recently updated
if(a.updatedAt > b.updatedAt) return -1;
return 1;
})
.map((brew, idx)=>{
return <tr key={idx}>
<td><strong>{brew.title}</strong></td>
<td><a href={`/share/${brew.shareId}`}>{brew.shareId}</a></td>
<td>{brew.editId}</td>
<td style={{ width: '200px' }}>{brew.updatedAt}</td>
<td>{brew.googleId ? 'Google' : 'Homebrewery'}</td>
</tr>;
})}
</tbody>
</table>
</>;
};
const handleKeyPress = (evt)=>{
if(evt.key === 'Enter') return lookup();
};
const handleChange = (evt)=>{
setAuthor(evt.target.value);
};
return (
<div className='authorLookup'>
<div className='authorLookupInputs'>
<h2>Author Lookup</h2>
<label className='field'>
Author Name:
<input className='fieldInput' value={author} onKeyDown={handleKeyPress} onChange={handleChange} />
<button onClick={lookup}>
<i className={`fas ${searching ? 'fa-spin fa-spinner' : 'fa-search'}`} />
</button>
</label>
</div>
<div className='authorLookupResults'>
{renderResults()}
</div>
</div>
);
};
module.exports = authorLookup;

View File

@@ -0,0 +1,29 @@
.authorLookup {
position : relative;
display : flex;
flex-direction : column;
.field {
display : flex;
gap : 5px;
align-items : center;
justify-items : stretch;
width : 100%;
margin-bottom : 20px;
input {
height : 33px;
padding : 0px 10px;
margin-bottom : unset;
font-family : monospace;
}
button {
width : 50px;
i { margin-right : 10px; }
}
}
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import AuthorLookup from './authorLookup/authorLookup.jsx';
const authorUtils = ()=>{
return (
<section className='authorUtils'>
<AuthorLookup />
</section>
);
};
module.exports = authorUtils;

View File

@@ -1,10 +0,0 @@
.BrewCleanup{
.removeBox{
margin-top: 20px;
button{
background-color: @red;
margin-right: 10px;
}
}
}

View File

@@ -1,10 +0,0 @@
.BrewCompress{
.removeBox{
margin-top: 20px;
button{
background-color: @red;
margin-right: 10px;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -1,10 +1,8 @@
require('./brewCleanup.less');
const React = require('react');
const createClass = require('create-react-class');
const request = require('superagent');
const BrewCleanup = createClass({
displayName : 'BrewCleanup',
getDefaultProps(){
@@ -39,9 +37,9 @@ const BrewCleanup = createClass({
if(!this.state.primed) return;
if(!this.state.count){
return <div className='removeBox'>No Matching Brews found.</div>;
return <div className='result noBrews'>No Matching Brews found.</div>;
}
return <div className='removeBox'>
return <div className='result'>
<button onClick={this.cleanup} className='remove'>
{this.state.pending
? <i className='fas fa-spin fa-spinner' />
@@ -52,7 +50,7 @@ const BrewCleanup = createClass({
</div>;
},
render(){
return <div className='BrewCleanup'>
return <div className='brewUtil brewCleanup'>
<h2> Brew Cleanup </h2>
<p>Removes very short brews to tidy up the database</p>
@@ -65,7 +63,7 @@ const BrewCleanup = createClass({
{this.renderPrimed()}
{this.state.error
&& <div className='error'>{this.state.error.toString()}</div>
&& <div className='error noBrews'>{this.state.error.toString()}</div>
}
</div>;
}

View File

@@ -1,10 +1,7 @@
require('./brewCompress.less');
const React = require('react');
const createClass = require('create-react-class');
const request = require('superagent');
const BrewCompress = createClass({
displayName : 'BrewCompress',
getDefaultProps(){
@@ -53,9 +50,9 @@ const BrewCompress = createClass({
if(!this.state.primed) return;
if(!this.state.count){
return <div className='removeBox'>No Matching Brews found.</div>;
return <div className='result noBrews'>No Matching Brews found.</div>;
}
return <div className='removeBox'>
return <div className='result'>
<button onClick={this.cleanup} className='remove'>
{this.state.pending
? <i className='fas fa-spin fa-spinner' />
@@ -69,7 +66,7 @@ const BrewCompress = createClass({
</div>;
},
render(){
return <div className='BrewCompress'>
return <div className='brewUtil brewCompress'>
<h2> Brew Compression </h2>
<p>Compresses the text in brews to binary</p>

View File

@@ -1,4 +1,3 @@
require('./brewLookup.less');
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
@@ -13,27 +12,48 @@ const BrewLookup = createClass({
},
getInitialState() {
return {
query : '',
foundBrew : null,
searching : false,
error : null
query : '',
foundBrew : null,
searching : false,
error : null,
scriptCount : 0
};
},
handleChange(e){
this.setState({ query: e.target.value });
},
lookup(){
this.setState({ searching: true, error: null });
this.setState({ searching: true, error: null, scriptCount: 0 });
request.get(`/admin/lookup/${this.state.query}`)
.then((res)=>this.setState({ foundBrew: res.body }))
.then((res)=>{
const foundBrew = res.body;
const scriptCheck = foundBrew?.text.match(/(<\/?s)cript/g);
this.setState({
foundBrew : foundBrew,
scriptCount : scriptCheck?.length || 0,
});
})
.catch((err)=>this.setState({ error: err }))
.finally(()=>this.setState({ searching: false }));
.finally(()=>{
this.setState({
searching : false
});
});
},
async cleanScript(){
if(!this.state.foundBrew?.shareId) return;
await request.put(`/admin/clean/script/${this.state.foundBrew.shareId}`)
.catch((err)=>{ this.setState({ error: err }); return; });
this.lookup();
},
renderFoundBrew(){
const brew = this.state.foundBrew;
return <div className='foundBrew'>
return <div className='result'>
<dl>
<dt>Title</dt>
<dd>{brew.title}</dd>
@@ -47,17 +67,28 @@ const BrewLookup = createClass({
<dt>Share Link</dt>
<dd><a href={`/share/${brew.shareId}`} target='_blank' rel='noopener noreferrer'>/share/{brew.shareId}</a></dd>
<dt>Created Time</dt>
<dd>{brew.createdAt ? Moment(brew.createdAt).toLocaleString() : 'No creation date'}</dd>
<dt>Last Updated</dt>
<dd>{Moment(brew.updatedAt).fromNow()}</dd>
<dt>Num of Views</dt>
<dd>{brew.views}</dd>
<dt>SCRIPT tags detected</dt>
<dd>{this.state.scriptCount}</dd>
</dl>
{this.state.scriptCount > 0 &&
<div className='cleanButton'>
<button onClick={this.cleanScript}>CLEAN BREW</button>
</div>
}
</div>;
},
render(){
return <div className='brewLookup'>
return <div className='brewUtil brewLookup'>
<h2>Brew Lookup</h2>
<input type='text' value={this.state.query} onChange={this.handleChange} placeholder='edit or share id' />
<button onClick={this.lookup}>
@@ -73,7 +104,7 @@ const BrewLookup = createClass({
{this.state.foundBrew
? this.renderFoundBrew()
: <div className='noBrew'>No brew found.</div>
: <div className='result noBrew'>No brew found.</div>
}
</div>;
}

View File

@@ -0,0 +1,24 @@
const React = require('react');
const createClass = require('create-react-class');
require('./brewUtils.less');
const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
const BrewLookup = require('./brewLookup/brewLookup.jsx');
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;

View File

@@ -0,0 +1,29 @@
.brewUtil {
.result {
margin-top : 20px;
button {
margin-right : 10px;
background-color : @red;
}
}
.cleanButton {
display : inline-block;
width : 100%;
}
}
.stats {
position : relative;
.pending {
position : absolute;
top : 0.5em;
left : 100px;
width : 100%;
height : 100%;
}
&:has(.pending) { opacity : 0.5; }
dl { grid-template-columns : 200px 250px; }
}

View File

@@ -1,11 +1,8 @@
require('./stats.less');
const React = require('react');
const createClass = require('create-react-class');
const cx = require('classnames');
const request = require('superagent');
const Stats = createClass({
displayName : 'Stats',
getDefaultProps(){
@@ -14,7 +11,8 @@ const Stats = createClass({
getInitialState(){
return {
stats : {
totalBrews : 0
totalBrews : 0,
totalPublishedBrews : 0
},
fetching : false
};
@@ -29,11 +27,13 @@ const Stats = createClass({
.finally(()=>this.setState({ fetching: false }));
},
render(){
return <div className='Stats'>
return <div className='brewUtil stats'>
<h2> Stats </h2>
<dl>
<dt>Total Brew Count</dt>
<dd>{this.state.stats.totalBrews}</dd>
<dt>Total Brews Published</dt>
<dd>{this.state.stats.totalPublishedBrews}</dd>
</dl>
{this.state.fetching

View File

@@ -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='dismiss_notif_drive'
/>
</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;

View File

@@ -0,0 +1,38 @@
.notificationAdd {
position : relative;
display : flex;
flex-direction : column;
width : 500px;
.field {
display : grid;
grid-template-columns : 120px 200px;
align-items : center;
justify-items : stretch;
width : 100%;
margin-bottom : 20px;
input {
height : 33px;
padding : 0px 10px;
margin-bottom : unset;
font-family : monospace;
&[type='date'] { width : 14ch; }
}
textarea {
width : 50ch;
min-height : 7em;
max-height : 20em;
padding : 10px;
resize : vertical;
}
}
button {
width : 200px;
i { margin-right : 10px; }
}
}

View File

@@ -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>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>
<dt>Text</dt>
<dd>{notification.text || 'No Text'}</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;

View File

@@ -0,0 +1,35 @@
.notificationLookup {
width : 450px;
height : fit-content;
.noNotification { margin-block : 20px; }
.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;
}
}
}
}

View 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;

View File

@@ -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;
}
}
}