Compare commits

..

66 Commits

Author SHA1 Message Date
Kayne Ruse 76417747b3 Bumped node to version 16 LTS 2021-11-17 04:58:08 +00:00
Kayne Ruse b8323723ed Updated package-lock.json 2021-11-15 22:37:51 +00:00
Kayne Ruse 415b2f32f1 Bumped version number 2021-08-24 08:02:17 +01:00
Kayne Ruse a0dbe0aee1 Imported the directory structure from egg trainer 2021-08-22 02:22:28 +10:00
Kayne Ruse f415a7ece2 Fixed analyzer 2021-08-20 21:21:18 +01:00
Kayne Ruse 9c863f309f Fixed webpack config 2021-08-21 05:49:01 +10:00
Kayne Ruse 7547b1717e Added gzip compression for JS files 2021-08-21 05:36:23 +10:00
Kayne Ruse 22e6286e0a Webpack config tweak 2021-08-16 07:44:32 +10:00
Kayne Ruse c766c43223 No longer needs the refreshToken to logout 2021-08-15 00:54:28 +10:00
Kayne Ruse bb1590bae7 Bumped version number 2021-08-10 17:39:09 +10:00
Kayne Ruse 5f7b9dda3a Tweaked README.md 2021-08-07 13:54:39 +10:00
Kayne Ruse 051f3dfb2a Updated README.md 2021-07-30 10:20:14 +10:00
Kayne Ruse 85456e0892 Minor markdown tweak 2021-07-30 03:36:40 +10:00
Kayne Ruse 6130337846 Fixed classNames 2021-07-30 01:34:06 +10:00
Kayne Ruse ac99f3bf38 Fixed CSS classes 2021-07-29 22:13:17 +10:00
Kayne Ruse 20e94db628 I really hate these line endings 2021-07-29 21:15:32 +10:00
Kayne Ruse bcb4a37f5a Tweak 2021-07-29 21:13:53 +10:00
Kayne Ruse 3b0d3c87b1 Tweak 2021-07-28 15:52:50 +01:00
Kayne Ruse 51a116503d Bumped version number 2021-07-28 15:43:09 +01:00
Kayne Ruse eb370663d2 Tweaked some display 2021-07-29 00:37:23 +10:00
Kayne Ruse 462116d980 Fixed whitespace 2021-07-28 23:18:22 +10:00
Kayne Ruse af06ddc06d Working on password recovery 2021-07-28 23:01:41 +10:00
Kayne Ruse f937ee47db Removed universal-cookie, fixed package.json versioning 2021-07-26 05:05:39 +01:00
Kayne Ruse 6d0dd419ca Fixed #22 2021-07-26 13:48:08 +10:00
Kayne Ruse 2919467dff Fixed #23 2021-07-26 13:38:33 +10:00
Kayne Ruse 269caac88c Tweaked README.md 2021-07-25 20:35:34 +01:00
Kayne Ruse 69c297fa74 Stupid line endings 2021-07-25 19:22:17 +10:00
Kayne Ruse 0f538be3e5 Fixed #20 2021-07-25 18:53:14 +10:00
Kayne Ruse f85b6e8793 Fixed passwords 2021-07-25 18:52:46 +10:00
Kayne Ruse 2af9532930 Fixed deploy bug, properly 2021-07-24 14:01:20 +01:00
Kayne Ruse 191da50740 Deploy bug 2021-07-24 22:20:17 +10:00
Kayne Ruse f24c7990f6 Fixed line-endings 2021-07-24 20:23:18 +10:00
Kayne Ruse 7a48780f50 Update package.json 2021-07-23 21:25:24 +10:00
Kayne Ruse 33952a9896 Changed chat line id to index 2021-07-23 21:24:19 +10:00
Kayne Ruse 8076b0cc40 Pointed to new dev-services 2021-07-22 21:48:42 +01:00
Kayne Ruse e216474196 Bumped version number 2021-07-15 08:53:57 +10:00
Kayne Ruse 2532bf1867 Updated packages to fix vulnerabilities 2021-07-15 08:52:54 +10:00
Kayne Ruse 93a3c30e81 Fixed the database hostname
It's supposed to point to localhost when developing locally.
2021-04-08 04:21:31 +10:00
Kayne Ruse ae8c82e83a Fixed embedded source-map error 2021-04-07 05:00:16 +10:00
Kayne Ruse bc6a795750 WHOOPS THAT WAS A MISTAKE 2021-04-07 03:18:48 +10:00
Kayne Ruse 9947ef13c1 Added a workaround for a mysql bug 2021-04-07 02:21:44 +10:00
Kayne Ruse d3f0b1ac7d Fixed WEB_PORT setting 2021-04-03 03:59:08 +11:00
Kayne Ruse b5f9c45a1b Updated README.md 2021-03-28 08:56:02 +11:00
Kayne Ruse 5189415e1a Implemented permabans 2021-03-28 08:33:26 +11:00
Kayne Ruse be793ae2ff Chat report table working 2021-03-28 07:58:52 +11:00
Kayne Ruse 19f5c20056 "Fixed" errors on logout, read more
I really don't like this solution.

The problem was caused by fetch being called twice after the component was
rendered twice. I don't know why it renders twice.
2021-03-28 04:38:03 +11:00
Kayne Ruse 7923f51aae Updated README.md 2021-03-25 18:10:03 +11:00
Kayne Ruse 793e54e334 Updated admin and mod flag system 2021-03-24 08:21:40 +11:00
Kayne Ruse 4fa54668e6 Added reporting feature 2021-03-24 03:22:16 +11:00
Kayne Ruse ff0230b77f Tested the new libs 2021-03-24 01:53:33 +11:00
Kayne Ruse 6b53bee033 Updated libraries 2021-03-24 01:11:01 +11:00
Kayne Ruse 4280319443 Replaced react-loadable with @loadable/component 2021-03-24 01:03:11 +11:00
Kayne Ruse 9788552d0c Updated README.md 2021-03-22 16:49:24 +11:00
Kayne Ruse 3fd76375dd Each microservice has received a tweak to .envdev, read more
This should make it easier to set time zones and enable database logging.

Related to krgamestudios/MERN-template#16
2021-03-22 16:39:44 +11:00
Kayne Ruse 9c8ece5c06 Implemented DB_LOGGING 2021-03-20 05:13:12 +11:00
Kayne Ruse 9201e9374b Updated README.md 2021-03-19 20:39:52 +11:00
Kayne Ruse 8c8b78462f Updated README.md 2021-03-19 18:49:01 +11:00
Kayne Ruse 07e578b4c4 Updated README.md 2021-03-19 18:47:17 +11:00
Kayne Ruse 732eeb933e Tweak 2021-03-18 05:26:21 +11:00
Kayne Ruse 104e15d714 Stupid tabs 2021-03-18 05:21:26 +11:00
Kayne Ruse 2c9871d82a Tweak 2021-03-18 05:17:12 +11:00
Kayne Ruse f5f44ae9f7 Updated configure-script.js with chat info 2021-03-18 05:15:32 +11:00
Kayne Ruse 1c3d24575e Added backlog to chat 2021-03-18 02:40:22 +11:00
Kayne Ruse 13e3ce6db8 Chat is working with a local chat-server 2021-03-17 16:52:14 +11:00
Kayne Ruse 8561219542 Wrote CSS for a chatbox 2021-03-17 03:44:38 +11:00
Kayne Ruse e288a43519 Tweaked README.md 2021-03-17 00:56:33 +11:00
50 changed files with 8828 additions and 5495 deletions
+8 -2
View File
@@ -1,9 +1,15 @@
WEB_PORT=3000 WEB_PORT=3000
DB_HOSTNAME=database DB_HOSTNAME=localhost
DB_DATABASE=template DB_DATABASE=template
DB_USERNAME=template DB_USERNAME=template
DB_PASSWORD=pikachu DB_PASSWORD=pikachu
# Select a "TZ database name" that suits your needs: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
DB_TIMEZONE=Australia/Sydney DB_TIMEZONE=Australia/Sydney
SECRET_ACCESS=access # Give this any value to enable database logging (such as "true")
DB_LOGGING=
# Make sure this value matches the system that you connect to
SECRET_ACCESS=access
+119 -118
View File
@@ -1,118 +1,119 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data # Runtime data
pids pids
*.pid *.pid
*.seed *.seed
*.pid.lock *.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover # Directory for instrumented libs generated by jscoverage/JSCover
lib-cov lib-cov
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage coverage
*.lcov *.lcov
# nyc test coverage # nyc test coverage
.nyc_output .nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt .grunt
# Bower dependency directory (https://bower.io/) # Bower dependency directory (https://bower.io/)
bower_components bower_components
# node-waf configuration # node-waf configuration
.lock-wscript .lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html) # Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release build/Release
# Dependency directories # Dependency directories
node_modules/ node_modules/
jspm_packages/ jspm_packages/
# TypeScript v1 declaration files # TypeScript v1 declaration files
typings/ typings/
# TypeScript cache # TypeScript cache
*.tsbuildinfo *.tsbuildinfo
# Optional npm cache directory # Optional npm cache directory
.npm .npm
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache
# Microbundle cache # Microbundle cache
.rpt2_cache/ .rpt2_cache/
.rts2_cache_cjs/ .rts2_cache_cjs/
.rts2_cache_es/ .rts2_cache_es/
.rts2_cache_umd/ .rts2_cache_umd/
# Optional REPL history # Optional REPL history
.node_repl_history .node_repl_history
# Output of 'npm pack' # Output of 'npm pack'
*.tgz *.tgz
# Yarn Integrity file # Yarn Integrity file
.yarn-integrity .yarn-integrity
# dotenv environment variables file # dotenv environment variables file
.env .env
.env.test .env.test
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache
# Next.js build output # Next.js build output
.next .next
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt
dist dist
# Gatsby files # Gatsby files
.cache/ .cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js # Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support # https://nextjs.org/blog/next-9-1#public-directory-support
# public # public
# vuepress build output # vuepress build output
.vuepress/dist .vuepress/dist
# Serverless directories # Serverless directories
.serverless/ .serverless/
# FuseBox cache # FuseBox cache
.fusebox/ .fusebox/
# DynamoDB Local files # DynamoDB Local files
.dynamodb/ .dynamodb/
# TernJS port file # TernJS port file
.tern-port .tern-port
# Output files # Output files
public/*.html public/*.html
public/*.js public/*.js
public/*.css public/*.css
public/*.map public/*.map
public/*.gz public/*.gz
public/*.txt
letsencrypt/
mysql/ letsencrypt/
Dockerfile mysql/
docker-compose.yml Dockerfile
startup.sql docker-compose.yml
startup.sql
+37 -32
View File
@@ -1,22 +1,26 @@
# MERN-template # MERN-template
A website template using the MERN stack. The primary technology involved is: A website template using the MERN stack. It is geared towards Persistent Browser Based Games (think neopets), but is flexible enough for a number of different uses.
The primary technology involved is:
* React * React
* Nodejs * Nodejs
* MariaDB (with Sequelize) * MariaDB (with Sequelize)
* Docker * Docker (with docker-compose)
This template is designed to support the development of persistent browser based games (PBBGs), but it, and it's component microservices, can be used elsewhere. This template is designed to support the development of persistent browser based games (PBBGs), but it, and it's component microservices, can be used elsewhere.
This template is released under the zlib license (see LICENSE). This template is released under the zlib license (see LICENSE).
See the [github wiki](https://github.com/krgamestudios/MERN-template/wiki) for full documentation.
# Microservices # Microservices
There are external components to this template referred to as "microservices". These can be omitted entirely by simply removing the React components that access them. These are also available via [docker hub](https://hub.docker.com/u/krgamestudios). There are external components to this template referred to as "microservices". These can be omitted entirely by simply removing the React components that access them. These are also available via [docker hub](https://hub.docker.com/u/krgamestudios).
* Auth Server: https://github.com/krgamestudios/auth-server
* News Server: https://github.com/krgamestudios/news-server * News Server: https://github.com/krgamestudios/news-server
* Auth Server: https://github.com/krgamestudios/auth-server
* Chat Server: https://github.com/krgamestudios/chat-server * Chat Server: https://github.com/krgamestudios/chat-server
# Setup Deployment # Setup Deployment
@@ -25,6 +29,7 @@ A clean install is this easy:
``` ```
git clone https://github.com/krgamestudios/MERN-template.git git clone https://github.com/krgamestudios/MERN-template.git
cd MERN-template
node configure-script.js node configure-script.js
docker-compose up --build docker-compose up --build
``` ```
@@ -42,15 +47,26 @@ To set up this template in development mode:
# Features List # Features List
- Fully Featured Account System - Mainly one language across the codebase (JavaScript)
- Full documentation
- Setup tutorial
- Fully Featured Account System (as a microservice)
- Email validation - Email validation
- Logging in and out - Logging in and out
- Account deletion - Account deletion
- Password management - Password management
- JSON web token authentication - JSON web token authentication
- News Blog - Fully Featured News Blog (as a microservice)
- Optional microservice - Publish, edit or delete articles as needed
- Secure publishing and editing of articles via admin panel - Secured via admin panel
- Fully Featured Chat System (as a microservice)
- Available when logged in
- Chat logs saved to the database
- Room-based chat (type `/room name` to access a specific room)
- Moderation tools
- Permanently banning users
- Chat-muting users for a time period
- Users reporting offensive chat-content
- Easy To Use Configuration Script - Easy To Use Configuration Script
- Sets up everything via docker - Sets up everything via docker
- A default admin account (if desired) - A default admin account (if desired)
@@ -58,31 +74,20 @@ To set up this template in development mode:
# Coming Soon # Coming Soon
- Full documentation - Full documentation
- Setup tutorial - Modding tutorials
- Modding tutorial - Fully Featured News Blog (as a microservice)
- Fully Featured Chat System - Individual pages for news articles
- Optional microservice
- Chat logs
- Custom emoji
- Global and room-based chat
- Private messaging?
- Broadcasting to all channels
- Badges next to usernames?
- Moderation tools for banning, suspending, or chat-banning users
# Coming Eventually # Coming Eventually
- Better compression for client files
- Backend for energy systems
- Backend for leaderboards
- Backend for items, shops, trading and currency
# Gmail Email Settings
If you decide to use gmail as your email provider (as I do), then use the following `.env` settings:
MAIL_SMTP=smtp.gmail.com
MAIL_USERNAME=you@gmail.com
MAIL_PASSWORD=yourpassword
you'll also need to enable "less secure apps" for the specified email address. Remember - don't ever commit the `.env` file! You might even want to create a dedicated email address just for your project.
- Fully Featured News Blog (as a microservice)
- Restore deleted articles
- Undo edits
- Fully Featured Chat System (as a microservice)
- Custom emoji
- Private messaging
- Broadcasting to all channels
- Badges next to usernames
- Backend for leaderboards (modding tutorial?)
- Backend for energy systems (modding tutorial?)
- Backend for items, shops, trading and currency (modding tutorial?)
+2 -2
View File
@@ -4,8 +4,8 @@ import 'regenerator-runtime/runtime';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import App from './components/app'; import App from './pages/app';
import TokenProvider from './components/utilities/token-provider'; import TokenProvider from './pages/utilities/token-provider';
ReactDOM.render( ReactDOM.render(
<TokenProvider> <TokenProvider>
-40
View File
@@ -1,40 +0,0 @@
//react
import React from 'react';
import { BrowserRouter, Switch } from 'react-router-dom';
//library components
import LazyRoute from './lazy-route';
import Markdown from './panels/markdown';
//styling
//import a styling template here
//common components
import Header from './panels/header.jsx';
import Footer from './panels/footer.jsx';
const App = props => {
//default render
return (
<BrowserRouter>
<Header />
<Switch>
<LazyRoute exact path='/' component={() => import('./pages/homepage')} />
<LazyRoute path='/signup' component={() => import('./pages/signup')} />
<LazyRoute path='/login' component={() => import('./pages/login')} />
<LazyRoute path='/account' component={() => import('./pages/account')} />
<LazyRoute path='/admin' component={() => import('./pages/admin')} />
<LazyRoute path='/privacypolicy' component={async () => () => <Markdown content={require('../markdown/privacy-policy.md').default} />} />
<LazyRoute path='/credits' component={async () => () => <Markdown content={require('../markdown/credits.md').default} />} />
<LazyRoute path='*' component={() => import('./pages/not-found')} />
</Switch>
<Footer />
</BrowserRouter>
);
};
export default App;
-39
View File
@@ -1,39 +0,0 @@
import React from 'react';
import { Route } from 'react-router-dom';
import Loadable from 'react-loadable';
const Loading = props => {
if (props.error) {
return <p>{props.error}</p>;
}
if (props.timedOut) {
return (
<div className='page'>
<p>Page Timed Out</p>
</div>
);
}
if (props.pastDelay) {
return (
<div className='page'>
<p>Page Loading...</p>
</div>
);
}
return null;
};
const LazyRoute = lazyProps => {
const component = Loadable({
loader: lazyProps.component,
loading: Loading,
timeout: 10000
});
return <Route {...lazyProps} component={component} />
};
export default LazyRoute;
-29
View File
@@ -1,29 +0,0 @@
import React, { useContext } from 'react';
import { Redirect } from 'react-router-dom';
import { TokenContext } from '../utilities/token-provider';
import NewsPublisher from '../panels/news-publisher';
import NewsEditor from '../panels/news-editor';
import PrivilegeEditor from '../panels/privilege-editor';
const Admin = props => {
//context
const authTokens = useContext(TokenContext);
//misplaced? (admin only)
if (!authTokens.accessToken || authTokens.getPayload().privilege != 'administrator') {
return <Redirect to='/' />;
}
return (
<div className='page'>
<h1 className='centered'>Administration</h1>
<NewsPublisher />
<NewsEditor />
<PrivilegeEditor />
</div>
);
};
export default Admin;
-14
View File
@@ -1,14 +0,0 @@
import React from 'react';
import NewsFeed from '../panels/news-feed';
const HomePage = props => {
return (
<div className='page'>
<p>This is the MERN template homepage.</p>
<NewsFeed />
</div>
);
};
export default HomePage;
-11
View File
@@ -1,11 +0,0 @@
import React from 'react';
const NotFound = props => {
return (
<div className='page'>
<h1 className='middle centered'>Page Not Found</h1>
</div>
);
};
export default NotFound;
-12
View File
@@ -1,12 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
const Footer = () => {
return (
<footer>
<p className='centered'>MERN template designed by <a href='https://krgamestudios.com'>Kayne Ruse, KR Game Studios</a> - <Link to='/privacypolicy'>Privacy Policy</Link> - <Link to='/credits'>Credits</Link></p>
</footer>
);
};
export default Footer;
-68
View File
@@ -1,68 +0,0 @@
import React, { useContext } from 'react';
import { Link } from 'react-router-dom';
import { TokenContext } from '../utilities/token-provider';
const Visitor = () => {
return (
<div>
<Link to='/signup'>Sign Up</Link>
<span> - </span>
<Link to='/login'>Log In</Link>
</div>
);
};
const Member = () => {
const authTokens = useContext(TokenContext);
return (
<div>
<Link to='/account'>Account</Link>
<span> - </span>
{ authTokens.getPayload().privilege == 'administrator' ?
<span>
<Link to='/admin'>Admin</Link>
<span> - </span>
</span>:
<span />
}
{ /* Logout button logs you out of the server too */ }
<Link to='/' onClick={async () => {
const result = await authTokens.tokenFetch(`${process.env.AUTH_URI}/auth/logout`, { //NOTE: this gets overwritten as a bugfix
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
token: authTokens.refreshToken
})
});
//any problems?
if (!result.ok) {
console.error(await result.text());
} else {
authTokens.setAccessToken('');
authTokens.setRefreshToken('');
}
}}>Log out</Link>
</div>
);
};
const Header = () => {
const authTokens = useContext(TokenContext);
return (
<header>
<h1><Link to='/'>MERN Template</Link></h1>
{ authTokens.accessToken ? <Member /> : <Visitor /> }
</header>
);
};
export default Header;
-47
View File
@@ -1,47 +0,0 @@
import React, { useState, useEffect } from 'react';
import dateFormat from 'dateformat';
//DOCS: props.uri is the address of a live news-server
const NewsFeed = props => {
const [articles, setArticles] = useState([]);
useEffect(async () => {
const result = await fetch(`${process.env.NEWS_URI}/news`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
alert(err);
} else {
setArticles(await result.json());
}
}, []);
return (
<div>
<h1 className='centered'>News Feed</h1>
{articles.map((article, index) => {
return (
<div key={index}>
<hr />
<h2>{article.title}</h2>
<p>Written by <strong>{article.author}</strong>, {
article.edits > 0 ?
<span>Last Updated {dateFormat(article.updatedAt, 'fullDate')} ({`${article.edits} edit${article.edits > 1 ? 's': ''}`})</span> :
<span>Published {dateFormat(article.createdAt, 'fullDate')}</span>
}</p>
<p style={{whiteSpace: 'pre-wrap'}}>{article.body}</p>
</div>
);
})}
</div>
);
};
export default NewsFeed;
@@ -1,70 +0,0 @@
import React, { useState, useRef, useContext } from 'react';
import Select from 'react-dropdown-select';
import { TokenContext } from '../utilities/token-provider';
const PrivilegeEditor = props => {
//context
const authTokens = useContext(TokenContext);
//state
const [privilege, setPrivilege] = useState('normal');
//ref
const usernameRef = useRef();
return (
<div>
<h2 className='centered'>Privilege Editor</h2>
<form onSubmit={async evt => {
evt.preventDefault();
const [err, result] = await handleSubmit(usernameRef.current.value, privilege, authTokens.tokenFetch);
if (err) {
alert(err);
}
if (result) {
alert('Privilege set');
usernameRef.current.value = '';
}
}}>
<div>
<label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={usernameRef} />
</div>
<Select
options={[{ label: 'administrator', value: 1 }, { label: 'moderator', value: 2 }, { label: 'alpha', value: 3 }, { label: 'beta', value: 4 }, { label: 'gamma', value: 5 }, { label: 'normal', value: 6 }]}
onChange={values => setPrivilege(values[0].label)}
/>
<button type='submit'>Change</button>
</form>
</div>
);
};
const handleSubmit = async (username, privilege, tokenFetch) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/privilege`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username,
privilege
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default PrivilegeEditor;
+6 -2
View File
@@ -1,6 +1,10 @@
# Credits <header>
<h1 class="text centered">Credits</h1>
</header>
MERN Template developed by Kayne Ruse, KR Game Studios ## MERN-template
The MERN-template developed by Kayne Ruse, KR Game Studios
[https://github.com/krgamestudios/MERN-template](https://github.com/krgamestudios/MERN-template) [https://github.com/krgamestudios/MERN-template](https://github.com/krgamestudios/MERN-template)
+3 -1
View File
@@ -1,2 +1,4 @@
# Privacy Policy <header>
<h1 class="text centered">Privacy Policy</h1>
</header>
@@ -1,9 +1,11 @@
import React, { useEffect, useContext, useRef } from 'react'; import React, { useEffect, useContext, useRef } from 'react';
import { Redirect } from 'react-router-dom'; import { Link, Redirect } from 'react-router-dom';
import ApplyToBody from '../utilities/apply-to-body';
import { TokenContext } from '../utilities/token-provider'; import { TokenContext } from '../utilities/token-provider';
import DeleteAccount from '../panels/delete-account'; import DeleteAccount from './panels/delete-account';
const Account = props => { const Account = props => {
//context //context
@@ -35,42 +37,42 @@ const Account = props => {
//render the thing //render the thing
return ( return (
<div className='page'> <>
<h1 className='centered'>Account</h1> <ApplyToBody className='dashboard' />
<form className='constricted' onSubmit={async evt => { <div className='page'>
evt.preventDefault(); <div className='central panel centered middle'>
const [err] = await update(passwordRef.current.value, retypeRef.current.value, contactRef.current.checked, authTokens.tokenFetch); <div className='panel'>
<h1 className='text centered'>Account</h1>
<div className='panel'>
<form className='constrained' onSubmit={async evt => {
evt.preventDefault();
const [err] = await update(passwordRef.current.value, retypeRef.current.value, contactRef.current.checked, authTokens.tokenFetch);
if (err) { if (err) {
alert(err); alert(err);
return; return;
} }
alert('Details updated'); alert('Details updated');
passwordRef.current.value = retypeRef.current.value = ''; passwordRef.current.value = retypeRef.current.value = '';
}}> }}>
<div> <input type='password' name='password' placeholder='New Password' ref={passwordRef} />
<div> <input type='password' name='retype' placeholder='Retype New Password' ref={retypeRef} />
<label htmlFor='password'>Change Password:</label>
<input type='password' name='password' ref={passwordRef} />
</div>
<div> <span>
<label htmlFor='retype'>Retype Password:</label> <label htmlFor='contact'>Allow Promotional Emails:</label>
<input type='password' name='retype' ref={retypeRef} /> <input type='checkbox' name='contact' ref={contactRef} />
</div> </span>
<div> <button type='submit'>Update Information</button>
<label htmlFor='contact'>Allow Promotional Emails:</label> </form>
<input type='checkbox' name='contact' ref={contactRef} /> <DeleteAccount />
</div>
<Link to='/' className='text centered'>Return Home</Link>\
</div> </div>
</div> </div>
</div>
<button type='submit'>Update Information</button> </>
</form>
<DeleteAccount className='constricted' />
</div>
); );
}; };
@@ -1,11 +1,13 @@
import React, { useContext, useRef } from 'react'; import React, { useContext, useRef } from 'react';
import { Redirect } from 'react-router-dom'; import { Link, Redirect } from 'react-router-dom';
import ApplyToBody from '../utilities/apply-to-body';
import { TokenContext } from '../utilities/token-provider'; import { TokenContext } from '../utilities/token-provider';
const validateEmail = require('../../../common/utilities/validate-email'); const validateEmail = require('../../../common/utilities/validate-email');
const LogIn = props => { const Login = props => {
//context //context
const authTokens = useContext(TokenContext); const authTokens = useContext(TokenContext);
@@ -19,39 +21,40 @@ const LogIn = props => {
const passwordRef = useRef(); const passwordRef = useRef();
return ( return (
<div className='page'> <>
<h1 className='centered'>Login</h1> <ApplyToBody className='dashboard' />
<form className='constricted' onSubmit={ <div className='page'>
async evt => { <div className='central panel centered middle'>
//on submit <div className='panel'>
evt.preventDefault(); <h1 className='text centered'>Login</h1>
const [err, newTokens] = await handleSubmit(emailRef.current.value, passwordRef.current.value); <form className='constrained' onSubmit={
if (err) { async evt => {
alert(err); //on submit
} evt.preventDefault();
const [err, newTokens] = await handleSubmit(emailRef.current.value, passwordRef.current.value);
if (err) {
alert(err);
}
//save auth tokens and redirect //save auth tokens and redirect
if (newTokens) { if (newTokens) {
authTokens.setAccessToken(newTokens.accessToken); authTokens.setAccessToken(newTokens.accessToken);
authTokens.setRefreshToken(newTokens.refreshToken); authTokens.setRefreshToken(newTokens.refreshToken);
props.history.push('/'); props.history.push('/');
} }
} }
}> }>
<div> <input type='email' name='email' placeholder='your@email.com' ref={emailRef} />
<label htmlFor="email">Email:</label> <input type='password' name='password' placeholder='********' ref={passwordRef} />
<input type="email" name="email" ref={emailRef} /> <button type='submit'>Login</button>
</form>
<Link to='/recover' className='text centered'>Forgot Password?</Link>
<Link to='/' className='text centered'>Return Home</Link>
</div>
</div> </div>
</div>
<div> </>
<label htmlFor="password">Password:</label>
<input type="password" name="password" ref={passwordRef} />
</div>
<button type='submit'>Login</button>
</form>
</div>
); );
}; };
@@ -104,4 +107,4 @@ const handleValidation = (email, password) => {
}; };
export default LogIn; export default Login;
@@ -1,6 +1,6 @@
import React, { useState, useContext, useRef } from 'react'; import React, { useState, useContext, useRef } from 'react';
import { TokenContext } from '../utilities/token-provider'; import { TokenContext } from '../../utilities/token-provider';
//DOCS: isolated the delete account button into it's own panel, so it can be easily moved as needed //DOCS: isolated the delete account button into it's own panel, so it can be easily moved as needed
const DeleteAccount = props => { const DeleteAccount = props => {
@@ -9,25 +9,27 @@ const DeleteAccount = props => {
const passwordRef = useRef(); const passwordRef = useRef();
if (!open) { if (!open) {
return <button onClick={() => setOpen(true)} className={props.className}>Delete Account</button> return (
<button onClick={() => setOpen(true)}>Delete Account</button>
);
} }
return ( return (
<form className={props.className} onSubmit={async evt => { <div className='panel centered middle'>
evt.preventDefault(); <h2 className='text centered'>Delete Your Account?</h2>
const [err] = await handleSubmit(passwordRef.current.value, authTokens); <form className='constrained' onSubmit={async evt => {
if (err) { evt.preventDefault();
alert(err); const [err] = await handleSubmit(passwordRef.current.value, authTokens);
} if (err) {
}}> alert(err);
<div> }
<label htmlFor="password">Password:</label> }}>
<input type="password" name="password" ref={passwordRef} /> <input type="password" name="password" placeholder='Password' ref={passwordRef} />
</div>
<button type='submit'>Delete Account</button> <button type='submit' style={{backgroundColor: 'red'}}>Delete Account</button>
<button type='cancel' onClick={() => { passwordRef.current.value = ''; setOpen(false); }}>Cancel</button> <button type='cancel' onClick={() => { passwordRef.current.value = ''; setOpen(false); }}>Cancel</button>
</form> </form>
</div>
); );
}; };
+37
View File
@@ -0,0 +1,37 @@
import React, { useContext, useRef } from 'react';
import { Link } from 'react-router-dom';
import { TokenContext } from '../../utilities/token-provider';
//TODO: make this an ACTUAL BUTTON
const Logout = () => {
const authTokens = useContext(TokenContext);
return (
<>
{ /* Logout logs you out of the server too */ }
<Link to='/' onClick={async () => {
const result = await authTokens.tokenFetch(`${process.env.AUTH_URI}/auth/logout`, { //NOTE: this gets overwritten as a bugfix
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
token: authTokens.refreshToken
})
});
//any problems?
if (!result.ok) {
console.error(await result.text());
} else {
authTokens.setAccessToken('');
authTokens.setRefreshToken('');
}
}}>Logout</Link>
</>
);
};
export default Logout;
+95
View File
@@ -0,0 +1,95 @@
import React, { useContext, useRef } from 'react';
import { Link, Redirect } from 'react-router-dom';
import ApplyToBody from '../utilities/apply-to-body';
import { TokenContext } from '../utilities/token-provider';
//utilities
const validateEmail = require('../../../common/utilities/validate-email');
const Recover = props => {
//context
const authTokens = useContext(TokenContext);
//misplaced?
if (authTokens.accessToken) {
return <Redirect to='/' />;
}
//refs
const emailRef = useRef();
const recoverRef = useRef();
return (
<>
<ApplyToBody className='dashboard' />
<div className='page'>
<div className='central panel centered middle'>
<h1 className='text centered'>Forgot Password</h1>
<form className='constrained' onSubmit={
async evt => { //on submit
recoverRef.current.disabled = true;
evt.preventDefault();
const [result, redirect] = await handleSubmit(emailRef.current.value);
if (result) {
alert(result);
recoverRef.current.disabled = false;
}
//redirect
if (redirect) {
props.history.push('/');
}
}
}>
<input type='email' name='email' placeholder='your@email.com' ref={emailRef} />
<button type='submit' ref={recoverRef}>Recover Password</button>
</form>
<Link to='/' className='text centered'>Return Home</Link>
</div>
</div>
</>
);
};
const handleSubmit = async (email) => {
email = email.trim();
const err = handleValidation(email);
if (err) {
return [err];
}
//send to the auth server
const result = await fetch(`${process.env.AUTH_URI}/auth/recover`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
email
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.error(err);
return [err, false];
}
return [await result.text(), true];
};
//returns an error message, or null on success
const handleValidation = (email) => {
if (!validateEmail(email)) {
return 'invalid email';
}
return null;
};
export default Recover;
+86
View File
@@ -0,0 +1,86 @@
import React, { useContext, useRef } from 'react';
import { Link, Redirect } from 'react-router-dom';
import queryString from 'query-string';
import ApplyToBody from '../utilities/apply-to-body';
import { TokenContext } from '../utilities/token-provider';
const Reset = props => {
//context
const authTokens = useContext(TokenContext);
//query
const query = queryString.parse(props.location.search);
//misplaced?
if (authTokens.accessToken || !query.email || !query.token) {
return <Redirect to='/' />;
}
//refs
const passwordRef = useRef();
const retypeRef = useRef();
//render the thing
return (
<>
<ApplyToBody className='dashboard' />
<div className='page'>
<div className='central panel centered middle'>
<h1 className='text centered'>Reset Password</h1>
<form className='constrained' onSubmit={async evt => {
evt.preventDefault();
const [err, redirect] = await update(passwordRef.current.value, retypeRef.current.value, query);
if (err) {
alert(err);
return;
}
alert('Details updated'); //TODO: replace with a message from the auth server
//redirect
if (redirect) {
props.history.push('/');
}
}}>
<input type='password' name='password' placeholder='New Password' ref={passwordRef} />
<input type='password' name='retype' placeholder='Retype New Password' ref={retypeRef} />
<button type='submit'>Update Information</button>
</form>
<Link to='/' className='text centered'>Return Home</Link>
</div>
</div>
</>
);
};
const update = async (password, retype, query) => {
if (password != retype) {
return ['Passwords do not match'];
}
if (password && password.length < 8) {
return ['Password is too short'];
}
const result = await fetch(`${process.env.AUTH_URI}/auth/reset?email=${query.email}&token=${query.token}`, {
method: 'PATCH',
headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json'
},
body: JSON.stringify({
password: password ? password : null,
})
});
if (!result.ok) {
return [`${await result.status}: ${await result.text()}`];
} else {
return [null, true];
}
}
export default Reset;
@@ -1,5 +1,7 @@
import React, { useContext, useRef } from 'react'; import React, { useContext, useRef } from 'react';
import { Redirect } from 'react-router-dom'; import { Link, Redirect } from 'react-router-dom';
import ApplyToBody from '../utilities/apply-to-body';
import { TokenContext } from '../utilities/token-provider'; import { TokenContext } from '../utilities/token-provider';
@@ -7,7 +9,7 @@ import { TokenContext } from '../utilities/token-provider';
const validateEmail = require('../../../common/utilities/validate-email'); const validateEmail = require('../../../common/utilities/validate-email');
const validateUsername = require('../../../common/utilities/validate-username'); const validateUsername = require('../../../common/utilities/validate-username');
const SignUp = props => { const Signup = props => {
//context //context
const authTokens = useContext(TokenContext); const authTokens = useContext(TokenContext);
@@ -22,52 +24,48 @@ const SignUp = props => {
const passwordRef = useRef(); const passwordRef = useRef();
const retypeRef = useRef(); const retypeRef = useRef();
const contactRef = useRef(); const contactRef = useRef();
const signupRef = useRef();
return ( return (
<div className='page'> <>
<h1 className='centered'>Signup</h1> <ApplyToBody className='dashboard' />
<form className='constricted' onSubmit={ <div className='page'>
async evt => { //on submit <div className='central panel centered middle'>
evt.preventDefault(); <h1 className='text centered'>Signup</h1>
const [result, redirect] = await handleSubmit(emailRef.current.value, usernameRef.current.value, passwordRef.current.value, retypeRef.current.value, contactRef.current.checked); <form className='constrained' onSubmit={
if (result) { async evt => { //on submit
alert(result); signupRef.current.disabled = true;
} evt.preventDefault();
const [result, redirect] = await handleSubmit(emailRef.current.value, usernameRef.current.value, passwordRef.current.value, retypeRef.current.value, contactRef.current.checked);
if (result) {
alert(result);
signupRef.current.disabled = false;
}
//redirect //redirect
if (redirect) { if (redirect) {
props.history.push('/'); props.history.push('/');
} }
} }
}> }>
<div>
<label htmlFor='email'>Email:</label> <input type='email' name='email' placeholder='your@email.com' ref={emailRef} />
<input type='email' name='email' ref={emailRef} /> <input type='text' name='username' placeholder='Username' ref={usernameRef} />
<input type='password' name='password' placeholder='********' ref={passwordRef} />
<input type='password' name='retype' placeholder='********' ref={retypeRef} />
<span>
<label htmlFor='contact'>Allow Emails:</label>
<input type='checkbox' name='contact' ref={contactRef} defaultChecked='true' />
</span>
<button type='submit' ref={signupRef}>Signup</button>
</form>
<Link to='/recover' className='text centered'>Forgot Password?</Link>
<Link to='/' className='text centered'>Return Home</Link>
</div> </div>
</div>
<div> </>
<label htmlFor='username'>Username:</label>
<input type='text' name='username' ref={usernameRef} />
</div>
<div>
<label htmlFor='password'>Password:</label>
<input type='password' name='password' ref={passwordRef} />
</div>
<div>
<label htmlFor='retype'>Retype Password:</label>
<input type='password' name='retype' ref={retypeRef} />
</div>
<div>
<label htmlFor='contact'>Allow Promotional Emails:</label>
<input type='checkbox' name='contact' ref={contactRef} />
</div>
<button type='submit'>Signup</button>
</form>
</div>
); );
}; };
@@ -126,4 +124,4 @@ const handleValidation = (email, username, password, retype) => {
return null; return null;
}; };
export default SignUp; export default Signup;
+43
View File
@@ -0,0 +1,43 @@
import React, { useContext } from 'react';
import { Link, Redirect } from 'react-router-dom';
import ApplyToBody from '../utilities/apply-to-body';
import { TokenContext } from '../utilities/token-provider';
import NewsPublisher from './panels/news-publisher';
import NewsEditor from './panels/news-editor';
import GrantAdmin from './panels/grant-admin';
import GrantMod from './panels/grant-mod';
const Admin = props => {
//context
const authTokens = useContext(TokenContext);
//misplaced? (admin only)
if (!authTokens.accessToken || !authTokens.getPayload().admin) {
return <Redirect to='/' />;
}
return (
<>
<ApplyToBody className='dashboard' />
<div className='page panel'>
<div className='central panel'>
<h1 className='text centered'>Administration Tools</h1>
<NewsPublisher />
<br />
<NewsEditor />
<br />
<GrantAdmin />
<br />
<GrantMod />
<Link to='/' className='text centered'>Return Home</Link>
</div>
</div>
</>
);
};
export default Admin;
+35
View File
@@ -0,0 +1,35 @@
import React, { useContext } from 'react';
import { Link, Redirect } from 'react-router-dom';
import ApplyToBody from '../utilities/apply-to-body';
import { TokenContext } from '../utilities/token-provider';
import ChatReports from './panels/chat-reports';
import BanUser from './panels/ban-user';
const Mod = props => {
//context
const authTokens = useContext(TokenContext);
//misplaced? (admin only)
if (!authTokens.accessToken || !(authTokens.getPayload().admin || authTokens.getPayload().mod)) {
return <Redirect to='/' />;
}
return (
<>
<ApplyToBody className='dashboard' />
<div className='page panel'>
<div className='central panel'>
<h1 className='text centered'>Moderation Tools</h1>
<BanUser />
<ChatReports />
<Link to='/' className='text centered'>Return Home</Link>
</div>
</div>
</>
);
};
export default Mod;
@@ -0,0 +1,62 @@
import React, { useRef, useContext } from 'react';
import { TokenContext } from '../../utilities/token-provider';
const BanUser = props => {
//context
const authTokens = useContext(TokenContext);
//ref
const usernameRef = useRef();
return (
<div className='panel'>
<h2 className='text centered'>Permanently Ban User</h2>
<form className='constrained'>
<input type='text' name='username' placeholder='Username' ref={usernameRef} />
<button type='button' onClick={async evt => {
evt.preventDefault();
const yes = confirm('Permanently ban this user from the website?');
if (!yes) {
return;
}
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch);
if (err) {
alert(err);
}
if (result) {
usernameRef.current.value = '';
}
}}>Submit</button>
</form>
</div>
);
};
const handleButtonPress = async (username, tokenFetch) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/banuser`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default BanUser;
@@ -0,0 +1,71 @@
import React, { useState, useEffect, useContext } from 'react';
import { TokenContext } from '../../utilities/token-provider';
import dateFormat from 'dateformat';
const ChatReports = props => {
const [reports, setReports] = useState([]);
const authTokens = useContext(TokenContext);
useEffect(async () => {
const result = await authTokens.tokenFetch(`${process.env.CHAT_URI}/admin/reports`, {
method: 'GET',
headers: {
'Access-Control-Allow-Origin': '*'
}
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
alert(err);
} else {
setReports(await result.json());
}
}, []);
return (
<div className='panel' style={{minWidth: '100%'}}>
<h2 className='text centered'>Chat Reports</h2>
<table>
<thead>
<tr>
<th>Date</th>
<th>Username</th>
<th className='mobile hide'>Room Name</th>
<th>Content</th>
<th>Reported By</th>
<th className='mobile hide'>Delete</th>
</tr>
</thead>
<tbody>
{reports.map((report, index) => (
<tr key={index}>
<td className='text centered'>{dateFormat(report.chatlog.createdAt, 'yyyy-mm-dd, H:MM:ss')}</td>
<td className='text centered'>{report.chatlog.username}</td>
<td className='text mobile hide centered'>{report.chatlog.room}</td>
<td className='text centered'>{report.chatlog.text}</td>
<td className='text centered'>{report.reporter.join(', ')}</td>
<td className='text mobile hide centered'><button onClick={() => deleteReportsFor(report.chatlogIndex, authTokens.tokenFetch, setReports)}>Delete</button></td>
</tr>
))}
</tbody>
</table>
</div>
);
};
const deleteReportsFor = (chatlogIndex, tokenFetch, setReports) => {
tokenFetch(`${process.env.CHAT_URI}/admin/reports`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({ chatlogIndex })
});
setReports(reports => reports.filter(report => report.chatlogIndex != chatlogIndex));
};
export default ChatReports;
@@ -0,0 +1,71 @@
import React, { useRef, useContext } from 'react';
import { TokenContext } from '../../utilities/token-provider';
const GrantAdmin = props => {
//context
const authTokens = useContext(TokenContext);
//ref
const usernameRef = useRef();
return (
<div className='panel'>
<h2 className='text centered'>Grant Admin Privileges</h2>
<form className='constrained'>
<input type='text' name='username' placeholder='Username' ref={usernameRef} />
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'POST');
if (err) {
alert(err);
}
if (result) {
alert('admin set');
usernameRef.current.value = '';
}
}}>Submit</button>
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'DELETE');
if (err) {
alert(err);
}
if (result) {
alert('admin removed');
usernameRef.current.value = '';
}
}}>Remove</button>
</form>
</div>
);
};
const handleButtonPress = async (username, tokenFetch, method) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/admin`, {
method: method,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default GrantAdmin;
@@ -0,0 +1,71 @@
import React, { useRef, useContext } from 'react';
import { TokenContext } from '../../utilities/token-provider';
const GrantMod = props => {
//context
const authTokens = useContext(TokenContext);
//ref
const usernameRef = useRef();
return (
<div className='panel'>
<h2 className='text centered'>Grant Moderation Privileges</h2>
<form className='constrained'>
<input type='text' name='username' placeholder='Username' ref={usernameRef} />
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'POST');
if (err) {
alert(err);
}
if (result) {
alert('mod set');
usernameRef.current.value = '';
}
}}>Submit</button>
<button type='button' onClick={async evt => {
evt.preventDefault();
const [err, result] = await handleButtonPress(usernameRef.current.value, authTokens.tokenFetch, 'DELETE');
if (err) {
alert(err);
}
if (result) {
alert('mod removed');
usernameRef.current.value = '';
}
}}>Remove</button>
</form>
</div>
);
};
const handleButtonPress = async (username, tokenFetch, method) => {
const result = await tokenFetch(`${process.env.AUTH_URI}/admin/mod`, {
method: method,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
username
})
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
return [err, false];
}
return [null, true];
};
export default GrantMod;
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useContext, useRef } from 'react'; import React, { useState, useEffect, useContext, useRef } from 'react';
import Select from 'react-dropdown-select'; import Select from 'react-dropdown-select';
import { TokenContext } from '../utilities/token-provider'; import { TokenContext } from '../../utilities/token-provider';
const NewsEditor = props => { const NewsEditor = props => {
//context //context
@@ -36,38 +36,36 @@ const NewsEditor = props => {
}, []); }, []);
return ( return (
<div> <div className='panel'>
<h2 className='centered'>News Editor</h2> <h2 className='text centered'>News Editor</h2>
<div> <Select
<label htmlFor='article'>Article: </label> options={articles.map(article => { return { label: article.title, value: article.index }; })}
<Select onChange={async values => {
options={articles.map(article => { return { label: article.title, value: article.index }; })} //fetch this article
onChange={async values => { const index = values[0].value;
//fetch this article
const index = values[0].value;
const result = await fetch(`${process.env.NEWS_URI}/news/archive/${index}`, { const result = await fetch(`${process.env.NEWS_URI}/news/archive/${index}`, {
headers: { headers: {
'Access-Control-Allow-Origin': '*' 'Access-Control-Allow-Origin': '*'
}
});
if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
alert(err);
} else {
const article = await result.json();
titleRef.current.value = article.title;
authorRef.current.value = article.author;
bodyRef.current.value = article.body;
setIndex(index);
} }
}} });
/>
</div>
<form onSubmit={async evt => { if (!result.ok) {
const err = `${result.status}: ${await result.text()}`;
console.log(err);
alert(err);
} else {
const article = await result.json();
titleRef.current.value = article.title;
authorRef.current.value = article.author;
bodyRef.current.value = article.body;
setIndex(index);
}
}}
placeholder='Select Article'
/>
<form className='constrained' onSubmit={async evt => {
//onSubmit //onSubmit
evt.preventDefault(); evt.preventDefault();
const [err] = await handleSubmit(titleRef.current.value, authorRef.current.value, bodyRef.current.value, index, authTokens.tokenFetch); const [err] = await handleSubmit(titleRef.current.value, authorRef.current.value, bodyRef.current.value, index, authTokens.tokenFetch);
@@ -78,20 +76,9 @@ const NewsEditor = props => {
alert(`Edited as article index ${index}`); alert(`Edited as article index ${index}`);
} }
}}> }}>
<div> <input type='text' name='title' placeholder='Title' ref={titleRef} />
<label htmlFor='title'>Title: </label> <input type='text' name='author' placeholder='Author' ref={authorRef} />
<input type='text' name='title' ref={titleRef} /> <textarea name='body' rows='10' cols='150' placeholder='Body of the article goes here...' ref={bodyRef} />
</div>
<div>
<label htmlFor='author'>Author: </label>
<input type='text' name='author' ref={authorRef} />
</div>
<div>
<label htmlFor='body'>Body: </label>
<textarea name='body' rows='10' cols='150' ref={bodyRef} />
</div>
<button type='submit'>Update</button> <button type='submit'>Update</button>
<button type='button' onClick={async evt => { <button type='button' onClick={async evt => {
@@ -1,6 +1,6 @@
import React, { useContext, useRef } from 'react'; import React, { useContext, useRef } from 'react';
import { TokenContext } from '../utilities/token-provider'; import { TokenContext } from '../../utilities/token-provider';
const NewsPublisher = props => { const NewsPublisher = props => {
//context //context
@@ -12,34 +12,23 @@ const NewsPublisher = props => {
const bodyRef = useRef(); const bodyRef = useRef();
return ( return (
<div> <div className='panel'>
<h2 className='centered'>News Publisher</h2> <h2 className='text centered'>News Publisher</h2>
<form onSubmit={async evt => { <form className='constrained' onSubmit={async evt => {
//on submit //on submit
evt.preventDefault(); evt.preventDefault();
const [err, index] = await handleSubmit(titleRef.current.value, authorRef.current.value, bodyRef.current.value, authTokens.tokenFetch); const [err, index] = await handleSubmit(titleRef.current.value, authorRef.current.value, bodyRef.current.value, authTokens.tokenFetch);
if (err) { if (err) {
alert(err); alert(err);
} else { } else {
titleRef.current.value = authorRef.current.value = bodyRef.current.value = ''; titleRef.current.value = authorRef.current.value = bodyRef.current.value = ''; //TODO: null bug here?
alert(`Published as article index ${index}`); alert(`Published as article index ${index}`);
} }
}}> }}>
<div> <input type='text' name='title' placeholder='Title' ref={titleRef} />
<label htmlFor='title'>Title: </label> <input type='text' name='author' placeholder='Author' ref={authorRef} />
<input type='text' name='title' ref={titleRef} /> <textarea name='body' rows='10' cols='150' placeholder='Body of the article goes here...' ref={bodyRef} />
</div>
<div>
<label htmlFor='author'>Author: </label>
<input type='text' name='author' ref={authorRef} />
</div>
<div>
<label htmlFor='body'>Body: </label>
<textarea name='body' rows='10' cols='150' ref={bodyRef} />
</div>
<button type='submit'>Publish</button> <button type='submit'>Publish</button>
</form> </form>
</div> </div>
+48
View File
@@ -0,0 +1,48 @@
//react
import React, { useContext } from 'react';
import { BrowserRouter, Switch } from 'react-router-dom';
import { TokenContext } from './utilities/token-provider';
//library components
import LazyRoute from './utilities/lazy-route';
import MarkdownPage from './utilities/markdown-page';
//styling
import '../styles/styles.css';
//common components
import Footer from './panels/footer';
import PopupChat from './panels/popup-chat';
const App = props => {
const authTokens = useContext(TokenContext);
//default render
return (
<BrowserRouter>
<Switch>
<LazyRoute exact path='/' component={() => import('./homepage')} />
<LazyRoute path='/signup' component={() => import('./accounts/signup')} />
<LazyRoute path='/login' component={() => import('./accounts/login')} />
<LazyRoute path='/account' component={() => import('./accounts/account')} />
<LazyRoute path='/dashboard' component={() => import('./dashboard')} />
<LazyRoute path='/recover' component={() => import('./accounts/recover')} />
<LazyRoute path='/reset' component={() => import('./accounts/reset')} />
<LazyRoute path='/admin' component={() => import('./administration/admin')} />
<LazyRoute path='/mod' component={() => import('./administration/mod')} />
<LazyRoute path='/privacypolicy' component={async () => () => <MarkdownPage content={require('../markdown/privacy-policy.md').default} />} />
<LazyRoute path='/credits' component={async () => () => <MarkdownPage content={require('../markdown/credits.md').default} />} />
<LazyRoute path='*' component={() => import('./not-found')} />
</Switch>
{ authTokens.accessToken ? <PopupChat /> : <></> }
<Footer />
</BrowserRouter>
);
};
export default App;
+35
View File
@@ -0,0 +1,35 @@
import React, { useContext } from 'react';
import { Link, Redirect } from 'react-router-dom';
import ApplyToBody from './utilities/apply-to-body';
import { TokenContext } from './utilities/token-provider';
import MarkdownPanel from './utilities/markdown-panel';
import Logout from './accounts/panels/logout';
const Dashboard = props => {
//context
const authTokens = useContext(TokenContext);
//misplaced?
if (!authTokens.accessToken) {
return <Redirect to='/' />;
}
return (
<>
<ApplyToBody className='dashboard' />
<div className='page'>
<div className='central panel centered middle'>
<Link to='/account'>Account</Link>
{ authTokens.getPayload().admin ? <Link to='/admin' className='text centered'>Admin</Link> : <></> }
{ authTokens.getPayload().mod ? <Link to='/mod' className='text centered'>Mod</Link> : <></> }
<Logout />
</div>
</div>
</>
);
};
export default Dashboard;
+46
View File
@@ -0,0 +1,46 @@
import React, { useContext } from 'react';
import { Link, Redirect } from 'react-router-dom';
import ApplyToBody from './utilities/apply-to-body';
import { TokenContext } from './utilities/token-provider';
import MarkdownPanel from './utilities/markdown-panel';
import NewsFeed from './panels/news-feed';
const HomePage = props => {
//context
const authTokens = useContext(TokenContext);
//misplaced?
if (authTokens.accessToken) {
return <Redirect to='/dashboard' />;
}
return (
<>
<ApplyToBody className='homepage' />
<div className='page'>
<div className='panel above'>
<header>
<h1 className='text centered'>MERN Template</h1>
<h2 className='text centered'>This is the MERN-template</h2>
</header>
<div className='panel centered middle'>
<Link to='/signup'><button>Sign Up</button></Link>
<Link to='/login'><button>Login</button></Link>
</div>
</div>
<div className='panel below'>
<div className='central'>
<NewsFeed />
</div>
</div>
</div>
</>
);
};
export default HomePage;
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import { Link } from 'react-router-dom';
import ApplyToBody from './utilities/apply-to-body';
const NotFound = props => {
return (
<>
<ApplyToBody className='dashboard' />
<div className='page'>
<div className='central panel centered middle'>
<h1 className='text centered'>Page Not Found</h1>
<br />
<Link className='text centered' to='/'>Return Home</Link>
</div>
</div>
</>
);
};
export default NotFound;
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import { Link } from 'react-router-dom';
const Break = () => {
return (
<>
<span className='mobile hide'> - </span>
<br className='mobile show' />
</>
);
}
const Footer = () => {
return (
<footer>
<p className='text centered'>© <a href='https://krgamestudios.com'>KR Game Studios</a> 2020-2021<Break /><Link to='/privacypolicy'>Privacy Policy</Link><Break /><Link to='/credits'>Credits</Link></p>
</footer>
);
};
export default Footer;
+51
View File
@@ -0,0 +1,51 @@
import React, { useState, useEffect, useRef } from 'react';
import dateFormat from 'dateformat';
import MarkdownPanel from '../utilities/markdown-panel';
const NewsFeed = props => {
const [articles, setArticles] = useState([]);
const aborter = useRef(new AbortController()); //BUGFIX: double-renders = double fetches + react update after unmount
useEffect(() => {
//this... um...
fetch(`${process.env.NEWS_URI}/news`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
signal: aborter.current.signal //oh dear
})
.then(blob => blob.json())
.then(json => setArticles(json))
.catch(e => null) //swallow errors
;
return () => aborter.current.abort(); //This is an ugly, ugly solution, but it's the only one that works
}, []);
return (
<div className='panel'>
<h1 className='text centered'>News Feed</h1>
{articles.map((article, index) => {
return (
<div key={index} className='panel'>
<hr />
<h2>{article.title}</h2>
<br />
<p><em>Written by <strong>{article.author}</strong>, {
article.edits > 0 ?
<span>Last Updated {dateFormat(article.updatedAt, 'fullDate')} ({`${article.edits} edit${article.edits > 1 ? 's': ''}`})</span> :
<span>Published {dateFormat(article.createdAt, 'fullDate')}</span>
}</em></p>
<br />
<MarkdownPanel style={{whiteSpace: 'pre-wrap'}} content={article.body} />
</div>
);
})}
</div>
);
};
export default NewsFeed;
+114
View File
@@ -0,0 +1,114 @@
import React, { useState, useEffect, useRef, useContext } from 'react';
import { TokenContext } from '../utilities/token-provider';
import { io } from 'socket.io-client';
import '../../styles/popup-chat.css';
//TODO: I very much need to move this out of global state
const socket = io(`${process.env.CHAT_URI}/chat`);
const PopupChat = props => {
const [open, setOpen] = useState(false);
const [chatlog, setChatlog] = useState([{ emphasis: true, text: 'If chat doesn\'t load, reload the page' }]);
const inputRef = useRef();
const sendRef = useRef();
const endRef = useRef();
const authTokens = useContext(TokenContext);
const pushChatlog = line => setChatlog(prevChatlog => [...prevChatlog, line]);
useEffect(() => {
socket.on('message', message => pushChatlog(message));
socket.on('backlog', messages => setChatlog(prev => [...prev, ...messages]));
socket.on('disconnect', reason => pushChatlog({ emphasis: true, text: 'Lost connection' }));
}, []);
useEffect(() => {
if (open) {
endRef.current.scrollIntoView();
}
}, [chatlog, open]);
if (!open) {
return (
<div className='chat'>
<button type='button' className='open' onClick={() => authTokens.tokenCallback(accessToken => handleOpen(setOpen, accessToken))}>Open Chat</button>
</div>
);
}
return (
<div className='chat'>
<div className='log'>
<ul className='scrollable'>
{chatlog.map((line, index) => processLine(line, index, authTokens.accessToken))}
<li ref={endRef} />
</ul>
</div>
<input type='text' className='input' placeholder='message' onKeyPress={evt => evt.key == 'Enter' ? sendRef.current.click() : ''} ref={inputRef} />
<button type='button' className='send' onClick={() => authTokens.tokenCallback(accessToken => handleSend(inputRef, pushChatlog, authTokens.getPayload().username, accessToken))} ref={sendRef}>Send</button>
<button type='button' className='close' onClick={() => handleClose(setOpen)}>Close Chat</button>
</div>
);
};
//handlers
const handleOpen = (setOpen, accessToken) => {
setOpen(true);
socket.emit('open chat', {
accessToken
});
};
const handleClose = setOpen => {
setOpen(false);
};
const handleSend = (inputRef, pushChatlog, username, accessToken) => {
if (inputRef.current.value == '') {
return;
}
socket.emit('message', {
accessToken,
text: inputRef.current.value
});
if (!inputRef.current.value.startsWith('/')) {
pushChatlog({ username: username, text: inputRef.current.value });
}
inputRef.current.value = '';
};
//render each line
const processLine = (line, index, accessToken) => {
let content = <div className='content'>{line.username ? <span className='username'>{line.username}: </span> : ''}{line.text ? <span className='text'>{line.text}</span> : ''}</div>;
//decorators
if (line.emphasis) {
content = <em>{content}</em>;
}
if (line.strong) {
content = <strong>{content}</strong>;
}
return <li key={index} className='line'>{content}<a className='report' onClick={() => processReport(line, accessToken)}>!!!</a></li>;
};
const processReport = (line, accessToken) => {
const yes = confirm('Report this message?');
if (yes) {
socket.emit('report', {
accessToken,
index: line.index
});
}
};
export default PopupChat;
+19
View File
@@ -0,0 +1,19 @@
import React, { useEffect } from 'react';
//applies the classname of 'body'
const ApplyToBody = (props) => {
useEffect(() => {
document.body.classList.add(props.className);
return () => {
document.body.classList.remove(props.className);
};
}, []);
return (
<></>
);
};
export default ApplyToBody;
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import { Route } from 'react-router-dom';
import loadable from '@loadable/component';
const LazyRoute = props => {
const { component, ...lazyProps } = props;
const lazyComponent = loadable(component);
return <Route {...lazyProps} component={lazyComponent} />
};
export default LazyRoute;
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import { Link } from 'react-router-dom';
import ApplyToBody from '../utilities/apply-to-body';
import MarkdownPanel from './markdown-panel';
const MarkdownPage = props => {
return (
<>
<ApplyToBody className='dashboard' />
<div className='page'>
<div className='central panel'>
<MarkdownPanel uri={props.uri} content={props.content} />
<Link to='/' className='text centered'>Return Home</Link>
</div>
</div>
</>
)
};
export default MarkdownPage;
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown/with-html'; import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
const Markdown = props => { const Markdown = props => {
//content? //content?
@@ -19,15 +20,15 @@ const Markdown = props => {
.catch(e => console.error(e)) .catch(e => console.error(e))
; ;
}, []); }, []);
} else }
//assume raw info //assume raw info
if (!contentHook) { else if (!contentHook) {
setContentHook(props.content); setContentHook(props.content);
} }
return ( return (
<ReactMarkdown escapeHtml={false} props={{...props}}>{contentHook}</ReactMarkdown> <ReactMarkdown rehypePlugins={[rehypeRaw]} props={{...props}}>{contentHook}</ReactMarkdown>
); );
}; };
@@ -3,7 +3,10 @@ import decode from 'jwt-decode';
export const TokenContext = createContext(); export const TokenContext = createContext();
//DOCS: tokenFetch() and tokenCallback() are actually closures here
const TokenProvider = props => { const TokenProvider = props => {
//state to be used
const [accessToken, setAccessToken] = useState(''); const [accessToken, setAccessToken] = useState('');
const [refreshToken, setRefreshToken] = useState(''); const [refreshToken, setRefreshToken] = useState('');
@@ -11,8 +14,9 @@ const TokenProvider = props => {
useEffect(() => { useEffect(() => {
setAccessToken(localStorage.getItem("accessToken") || ''); setAccessToken(localStorage.getItem("accessToken") || '');
setRefreshToken(localStorage.getItem("refreshToken") || ''); setRefreshToken(localStorage.getItem("refreshToken") || '');
}, []) }, []);
//update the stored copies
useEffect(() => { useEffect(() => {
localStorage.setItem("accessToken", accessToken); localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken); localStorage.setItem("refreshToken", refreshToken);
@@ -26,6 +30,62 @@ const TokenProvider = props => {
//if expired (10 minutes, normally) //if expired (10 minutes, normally)
const expired = new Date(decode(accessToken).exp * 1000) < Date.now(); const expired = new Date(decode(accessToken).exp * 1000) < Date.now();
if (expired) {
//BUGFIX: if logging out, just skip over the refresh token
if (url === `${process.env.AUTH_URI}/auth/logout`) {
return fetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Authorization': `Bearer ${bearer}`
},
body: JSON.stringify({
token: refreshToken
})
});
}
//ping the auth server for a new token
const response = await fetch(`${process.env.AUTH_URI}/auth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
body: JSON.stringify({
token: refreshToken
})
});
//any errors, throw them
if (!response.ok) {
throw `${response.status}: ${await response.text()}`;
}
//save the new auth stuff (setting bearer as well)
const newAuth = await response.json();
setAccessToken(newAuth.accessToken);
setRefreshToken(newAuth.refreshToken);
bearer = newAuth.accessToken;
}
//finally, delegate to fetch
return fetch(url, {
...(options || {}),
headers: {
...(options || { headers: {} }).headers,
'Authorization': `Bearer ${bearer}`
}
});
};
//access the refreshed token via callback
const tokenCallback = async (cb) => {
//if expired (10 minutes, normally)
const expired = new Date(decode(accessToken).exp * 1000) < Date.now();
if (expired) { if (expired) {
//ping the auth server for a new token //ping the auth server for a new token
const response = await fetch(`${process.env.AUTH_URI}/auth/token`, { const response = await fetch(`${process.env.AUTH_URI}/auth/token`, {
@@ -49,36 +109,16 @@ const TokenProvider = props => {
setAccessToken(newAuth.accessToken); setAccessToken(newAuth.accessToken);
setRefreshToken(newAuth.refreshToken); setRefreshToken(newAuth.refreshToken);
bearer = newAuth.accessToken;
//BUGFIX: logging out correctly requires the new refresh token //finally
if (url == `${process.env.AUTH_URI}/auth/logout`) { return cb(newAuth.accessToken);
return fetch(`${process.env.AUTH_URI}/auth/logout`, { } else {
method: 'DELETE', return cb(accessToken);
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Authorization': `Bearer ${bearer}`
},
body: JSON.stringify({
token: newAuth.refreshToken
})
});
}
} }
//finally, delegate to fetch
return fetch(url, {
...(options || {}),
headers: {
...(options || { headers: {} }).headers,
'Authorization': `Bearer ${bearer}`
}
});
}; };
return ( return (
<TokenContext.Provider value={{ accessToken, refreshToken, setAccessToken, setRefreshToken, tokenFetch, getPayload: () => decode(accessToken) }}> <TokenContext.Provider value={{ accessToken, refreshToken, setAccessToken, setRefreshToken, tokenFetch, tokenCallback, getPayload: () => decode(accessToken) }}>
{props.children} {props.children}
</TokenContext.Provider> </TokenContext.Provider>
) )
+105
View File
@@ -0,0 +1,105 @@
/* clear from the rest of the CSS files */
.chat button, .chat input {
border-radius: unset !important;
font-size: unset !important;
margin: unset !important;
}
.chat {
position: fixed;
bottom: 3.6em; /* Allow space for the footer */
right: 28px;
width: 280px;
border: solid;
border-color: black;
border-width: 2px;
background-color: #CCC;
display: inline-block;
}
.chat button.open {
color: white;
background-color: grey;
}
.chat button.send {
color: white;
background-color: green;
border-style: solid;
border-width: 2px;
border-color: darkslategray;
}
.chat button.close {
color: black;
background-color: red;
border-color: maroon;
border-width: 2px;
}
.chat button {
width: 100%;
height: 2em;
opacity: 0.8;
border: unset;
}
.chat button:hover {
opacity: 1;
}
.chat .input {
width: 100%;
height: 2em;
}
.chat .log {
min-height: 280px;
}
.chat .line {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.chat .report {
color: red;
display: none;
}
.chat .line:hover {
background-color: #BBB;
}
.chat .line:hover .report {
display: flex;
}
.chat .username {
font-weight: bold;
}
.chat .scrollable {
margin: 0;
padding: 10px;
min-height: 280px;
max-height: 180px;
overflow-x: wrap;
overflow-y: scroll;
}
.chat ul {
list-style: none;
}
@media screen and (max-width: 768px) {
.chat {
position: unset;
bottom: unset;
right: unset;
width: calc(100% + 20px);
margin-left: -10px;
}
}
+323
View File
@@ -0,0 +1,323 @@
/* global defaults */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body, #root {
font: 12pt Helvetica, Arial;
min-width: 100vw;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
background-color: #fefefe;
color: #010101;
}
h1 {
font-size: 40pt;
font-weight: bold;
}
h2 {
font-size: 24pt;
font-weight: bold;
}
h3 {
font-size: 16pt;
font-weight: bold;
}
ul {
list-style-type: disc;
list-style-position: inside;
padding-bottom: .5em;
}
p {
margin-bottom: 0 !important;
padding-bottom: 1em;
}
pre {
padding: 5px;
margin-bottom: 1em;
background-color: lightgray;
overflow-x: scroll;
}
a {
color: blue;
text-decoration: none;
}
blockquote {
padding-top: 0.5em;
padding-left: 1em;
margin-bottom: 1em;
border-left: 3px solid #ccc;
}
.text.left {
text-align: left;
}
.text.centered {
text-align: center;
}
.text.right {
text-align: right;
}
.centered {
justify-content: center;
}
.middle {
align-items: center;
}
/* header */
header {
flex: 0 1 auto;
margin-top: 1em;
margin-bottom: 1em;
justify-self: flex-start;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* footer */
footer {
padding-top: 0.5em;
flex: 0 1 auto;
justify-self: flex-end;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
/* central display */
.central {
padding: 0 10px;
margin: 0 20%;
min-height: calc(100vh - 3.6em);
}
@media screen and (max-width: 768px) {
.central {
margin: 0;
}
}
/* components */
.page {
flex: 1 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.page.centered {
justify-content: center;
}
.page.middle {
align-items: center;
}
.panel {
flex: 0 1 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding-bottom: 1em;
}
.panel.centered {
justify-content: center;
}
.panel.middle {
align-items: middle;
}
button:disabled {
opacity: 0.5;
}
input, button {
text-indent: 0.3em;
border-radius: 0.2em;
font-size: 1.8em;
margin: 0.2em;
padding: 0.2em 0;
}
textarea {
margin-left: 0.5em;
margin-right: -0.4em;
max-height: none !important;
resize: vertical;
overflow: auto;
}
/* "constrained" means reusable input area */
.constrained {
flex: 0 1 auto;
align-self: center;
max-width: 480px;
display: flex;
flex-direction: column;
margin-bottom: 1em;
}
.constrained > * {
flex: 1 0 auto;
max-height: 2em;
display: flex;
flex-direction: row;
}
.constrained button, button.constrained {
display: inline-block;
}
.constrained label {
font-size: 1.8em;
text-indent: 0.4em;
}
@media screen and (max-width: 480px) {
.constrained {
max-width: 100vw;
}
}
/* flexbox tables */
.table {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.table .row {
flex: 1;
display: flex;
flex-direction: row;
justify-content: flex-start;
}
@media screen and (max-width: 480px) {
.table .row {
flex-direction: column;
}
}
@media screen and (max-width: 768px) {
.table .row.tabletCollapse {
flex-direction: column;
}
}
.table .row .col {
flex: 1 1 1%;
display: flex;
flex-direction: column;
min-width: 0;
}
.table .row .col.double {
flex: 2 1 2%;
}
.table .row .col.half {
flex: 0.5 1 0.5%;
}
@media screen and (max-width: 480px) {
.table .row .col.double {
flex: 2 1 2.5%;
}
}
.table.noCollapse .row, .table .row.noCollapse {
flex-direction: row;
}
/* mobile control */
.mobile.show {
display: none;
}
@media screen and (max-width: 480px) {
.mobile.centered {
text-align: initial;
}
.mobile.show {
display: inline-block;
}
.mobile.hide {
display: none;
}
.mobile.centered {
text-align: center;
}
/* hybrid of table and mobile control */
.mobile.hide.col {
display: none;
}
.mobile.col.half {
flex: 0.5;
}
}
/* tablet control */
.tablet.show {
display: none;
}
@media screen and (max-width: 768px) {
.tablet.centered {
text-align: initial;
}
.tablet.show {
display: inline-block;
}
.tablet.hide {
display: none;
}
.tablet.centered {
text-align: center;
}
/* hybrid of table and tablet control */
.tablet.hide.col {
display: none;
}
.tablet.col.half {
flex: 0.5;
}
}
+48 -12
View File
@@ -40,6 +40,7 @@ impelented are:
* auth-server * auth-server
* news-server * news-server
* chat-server
See https://github.com/krgamestudios/MERN-template/wiki for help. See https://github.com/krgamestudios/MERN-template/wiki for help.
` `
@@ -56,13 +57,14 @@ See https://github.com/krgamestudios/MERN-template/wiki for help.
const newsName = await question('News Name', 'news'); const newsName = await question('News Name', 'news');
const newsWebAddress = await question('News Web Address', `${newsName}.${projectWebAddress}`); const newsWebAddress = await question('News Web Address', `${newsName}.${projectWebAddress}`);
const newsDBUser = await question('News DB Username', newsName); const newsDBUser = await question('News DB Username', newsName);
const newsDBPass = await question('News DB Password', 'charizard'); const newsDBPass = await question('News DB Password', 'venusaur');
//auth configuration //auth configuration
const authName = await question('Auth Name', 'auth'); const authName = await question('Auth Name', 'auth');
const authWebAddress = await question('Auth Web Address', `${authName}.${projectWebAddress}`); const authWebAddress = await question('Auth Web Address', `${authName}.${projectWebAddress}`);
const authResetAddress = await question('Auth Reset Addr', `${projectWebAddress}/reset`);
const authDBUser = await question('Auth DB Username', authName); const authDBUser = await question('Auth DB Username', authName);
const authDBPass = await question('Auth DB Password', 'venusaur'); const authDBPass = await question('Auth DB Password', 'charizard');
const emailSMTP = await question('Email SMTP', 'smtp.example.com'); const emailSMTP = await question('Email SMTP', 'smtp.example.com');
const emailUser = await question('Email Address', 'foobar@example.com'); const emailUser = await question('Email Address', 'foobar@example.com');
@@ -70,6 +72,10 @@ See https://github.com/krgamestudios/MERN-template/wiki for help.
const emailPhysical = await question('Physical Mailing Address', ''); const emailPhysical = await question('Physical Mailing Address', '');
//chat goes here //chat goes here
const chatName = await question('Chat Name', 'chat');
const chatWebAddress = await question('Chat Web Address', `${chatName}.${projectWebAddress}`);
const chatDBUser = await question('Chat DB Username', chatName);
const chatDBPass = await question('Chat DB Password', 'blastoise');
//database configuration //database configuration
const dbRootPassword = await question('Database Root Password', 'password'); const dbRootPassword = await question('Database Root Password', 'password');
@@ -101,7 +107,7 @@ See https://github.com/krgamestudios/MERN-template/wiki for help.
const projectPort = 3000; const projectPort = 3000;
const newsPort = 3100; const newsPort = 3100;
const authPort = 3200; const authPort = 3200;
//const chatPort = 3300; const chatPort = 3300;
const ymlfile = ` const ymlfile = `
version: "3.6" version: "3.6"
@@ -118,7 +124,7 @@ services:
- traefik.http.routers.${projectName}router.service=${projectName}service@docker - traefik.http.routers.${projectName}router.service=${projectName}service@docker
- traefik.http.services.${projectName}service.loadbalancer.server.port=${projectPort} - traefik.http.services.${projectName}service.loadbalancer.server.port=${projectPort}
environment: environment:
- WEB_PORT=3000 - WEB_PORT=${projectPort}
- DB_HOSTNAME=database - DB_HOSTNAME=database
- DB_DATABASE=${projectName} - DB_DATABASE=${projectName}
- DB_USERNAME=${projectDBUser} - DB_USERNAME=${projectDBUser}
@@ -126,6 +132,7 @@ services:
- DB_TIMEZONE=${dbTimeZone} - DB_TIMEZONE=${dbTimeZone}
- NEWS_URI=https://${newsWebAddress} - NEWS_URI=https://${newsWebAddress}
- AUTH_URI=https://${authWebAddress} - AUTH_URI=https://${authWebAddress}
- CHAT_URI=https://${chatWebAddress}
- SECRET_ACCESS=${accessToken} - SECRET_ACCESS=${accessToken}
networks: networks:
- app-network - app-network
@@ -143,9 +150,9 @@ services:
- traefik.http.routers.${newsName}router.entrypoints=websecure - traefik.http.routers.${newsName}router.entrypoints=websecure
- traefik.http.routers.${newsName}router.tls.certresolver=myresolver - traefik.http.routers.${newsName}router.tls.certresolver=myresolver
- traefik.http.routers.${newsName}router.service=${newsName}service@docker - traefik.http.routers.${newsName}router.service=${newsName}service@docker
- traefik.http.services.${newsName}service.loadbalancer.server.port=3100 - traefik.http.services.${newsName}service.loadbalancer.server.port=${newsPort}
environment: environment:
- WEB_PORT=3100 - WEB_PORT=${newsPort}
- DB_HOSTNAME=database - DB_HOSTNAME=database
- DB_DATABASE=${newsName} - DB_DATABASE=${newsName}
- DB_USERNAME=${newsDBUser} - DB_USERNAME=${newsDBUser}
@@ -173,6 +180,7 @@ services:
environment: environment:
- WEB_PROTOCOL=https - WEB_PROTOCOL=https
- WEB_ADDRESS=${authWebAddress} - WEB_ADDRESS=${authWebAddress}
- WEB_RESET_ADDRESS=${authResetAddress}
- WEB_PORT=${authPort} - WEB_PORT=${authPort}
- DB_HOSTNAME=database - DB_HOSTNAME=database
- DB_DATABASE=${authName} - DB_DATABASE=${authName}
@@ -192,9 +200,31 @@ services:
depends_on: depends_on:
- database - database
- traefik - traefik
#chat: ${chatName}:
# image: krgamestudios/chat-server image: krgamestudios/chat-server:latest
ports:
- ${chatPort}
labels:
- traefik.enable=true
- traefik.http.routers.${chatName}router.rule=Host(\`${chatWebAddress}\`)
- traefik.http.routers.${chatName}router.entrypoints=websecure
- traefik.http.routers.${chatName}router.tls.certresolver=myresolver
- traefik.http.routers.${chatName}router.service=${chatName}service@docker
- traefik.http.services.${chatName}service.loadbalancer.server.port=${chatPort}
environment:
- WEB_PORT=${chatPort}
- DB_HOSTNAME=database
- DB_DATABASE=${chatName}
- DB_USERNAME=${chatDBUser}
- DB_PASSWORD=${chatDBPass}
- DB_TIMEZONE=${dbTimeZone}
- SECRET_ACCESS=${accessToken}
networks:
- app-network
depends_on:
- database
- traefik
database: database:
image: mariadb image: mariadb
@@ -235,12 +265,14 @@ networks:
`; `;
const dockerfile = ` const dockerfile = `
FROM node:15 FROM node:16
WORKDIR "/app" WORKDIR "/app"
COPY package*.json ./
RUN npm install
COPY . /app COPY . /app
RUN mkdir /app/public
RUN chown node:node /app/public
RUN npm install --production
EXPOSE ${projectPort} EXPOSE ${projectPort}
USER node
ENTRYPOINT ["bash", "-c"] ENTRYPOINT ["bash", "-c"]
CMD ["sleep 10 && npm start"] CMD ["sleep 10 && npm start"]
`; `;
@@ -258,6 +290,10 @@ CREATE DATABASE IF NOT EXISTS ${authName};
CREATE USER IF NOT EXISTS '${authDBUser}'@'%' IDENTIFIED BY '${authDBPass}'; CREATE USER IF NOT EXISTS '${authDBUser}'@'%' IDENTIFIED BY '${authDBPass}';
GRANT ALL PRIVILEGES ON ${authName}.* TO '${authDBUser}'@'%'; GRANT ALL PRIVILEGES ON ${authName}.* TO '${authDBUser}'@'%';
CREATE DATABASE IF NOT EXISTS ${chatName};
CREATE USER IF NOT EXISTS '${chatDBUser}'@'%' IDENTIFIED BY '${chatDBPass}';
GRANT ALL PRIVILEGES ON ${chatName}.* TO '${chatDBUser}'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
`; `;
+6913 -4745
View File
File diff suppressed because it is too large Load Diff
+25 -20
View File
@@ -1,6 +1,6 @@
{ {
"name": "mern-template", "name": "mern-template",
"version": "1.0.0", "version": "1.0.6",
"description": "A website template using the MERN stack.", "description": "A website template using the MERN stack.",
"main": "server/server.js", "main": "server/server.js",
"scripts": { "scripts": {
@@ -9,9 +9,9 @@
"build:server": "exit 0", "build:server": "exit 0",
"build:client": "webpack --env=production --config webpack.config.js", "build:client": "webpack --env=production --config webpack.config.js",
"dev": "concurrently npm:watch:server npm:watch:client", "dev": "concurrently npm:watch:server npm:watch:client",
"watch:server": "nodemon . --ext js,jsx,json --ignore 'node_modules/*'", "watch:server": "nodemon ./* --ext js,jsx,json --ignore 'node_modules/*'",
"watch:client": "webpack serve --env=development --config webpack.config.js", "watch:client": "webpack serve --env=development --config webpack.config.js",
"analyzer": "webpack --env=production --analyzer --config webpack.config.js" "analyze": "webpack --env=production --env=analyze --config webpack.config.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -24,34 +24,39 @@
}, },
"homepage": "https://github.com/KRGameStudios/MERN-template#readme", "homepage": "https://github.com/KRGameStudios/MERN-template#readme",
"dependencies": { "dependencies": {
"@babel/core": "^7.12.10", "@babel/core": "^7.14.8",
"@babel/preset-env": "^7.12.11", "@babel/preset-env": "^7.14.8",
"@babel/preset-react": "^7.12.10", "@babel/preset-react": "^7.14.5",
"@loadable/component": "^5.15.0",
"babel-loader": "^8.2.2", "babel-loader": "^8.2.2",
"clean-webpack-plugin": "^3.0.0", "clean-webpack-plugin": "^3.0.0",
"concurrently": "^5.3.0", "compression-webpack-plugin": "^8.0.1",
"concurrently": "^6.2.0",
"css-loader": "^6.2.0",
"dateformat": "^4.5.1", "dateformat": "^4.5.1",
"dotenv": "^8.2.0", "dotenv": "^10.0.0",
"express": "^4.17.1", "express": "^4.17.1",
"html-webpack-plugin": "^5.0.0-alpha.14", "html-webpack-plugin": "^5.3.2",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"mariadb": "^2.5.2", "mariadb": "^2.5.4",
"query-string": "^7.0.1",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"react": "^17.0.1", "react": "^17.0.2",
"react-dom": "^17.0.1", "react-dom": "^17.0.2",
"react-dropdown-select": "^4.7.4", "react-dropdown-select": "^4.7.4",
"react-loadable": "^5.5.0", "react-markdown": "^6.0.2",
"react-markdown": "^5.0.3",
"react-router": "^5.2.0", "react-router": "^5.2.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"sequelize": "^6.4.0", "rehype-raw": "^5.1.0",
"universal-cookie": "^4.0.4", "sequelize": "^6.6.5",
"webpack": "^5.15.0", "socket.io-client": "^4.1.3",
"webpack-cli": "^4.3.1" "style-loader": "^3.2.1",
"webpack": "^5.46.0",
"webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^4.7.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^2.0.7", "nodemon": "^2.0.12",
"webpack-bundle-analyzer": "^4.3.0",
"webpack-dev-server": "^3.11.2" "webpack-dev-server": "^3.11.2"
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@ const sequelize = new Sequelize(process.env.DB_DATABASE, process.env.DB_USERNAME
host: process.env.DB_HOSTNAME, host: process.env.DB_HOSTNAME,
dialect: 'mariadb', dialect: 'mariadb',
timezone: process.env.DB_TIMEZONE, timezone: process.env.DB_TIMEZONE,
logging: false logging: process.env.DB_LOGGING ? console.log : false
}); });
sequelize.sync(); sequelize.sync();
+16 -2
View File
@@ -8,10 +8,24 @@ const path = require('path');
const express = require('express'); const express = require('express');
const app = express(); const app = express();
const server = require('http').Server(app); const server = require('http').Server(app);
const bodyParser = require('body-parser');
//config //config
app.use(bodyParser.json()); app.use(express.json());
//handle compressed files (middleware)
app.get('*.js', (req, res, next) => {
req.url = req.url + '.gz';
res.set('Content-Encoding', 'gzip');
res.set('Content-Type', 'text/javascript');
next();
});
app.get('*.css', (req, res, next) => {
req.url = req.url + '.gz';
res.set('Content-Encoding', 'gzip');
res.set('Content-Type', 'text/css');
next();
});
//database connection //database connection
const database = require('./database'); const database = require('./database');
+19 -8
View File
@@ -2,23 +2,24 @@
const { DefinePlugin } = require('webpack'); const { DefinePlugin } = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
//libraries //libraries
const path = require('path'); const path = require('path');
//the exported config function //the exported config function
module.exports = ({ production, analyzer }) => { module.exports = ({ production, analyze }) => {
return { return {
mode: production ? "production" : "development", mode: production ? "production" : "development",
entry: path.resolve(__dirname, 'client', 'client.jsx'), entry: path.resolve(__dirname, 'client', 'client.jsx'),
output: { output: {
path: path.resolve(__dirname, 'public'), path: path.resolve(__dirname, 'public'),
publicPath: '/',
filename: '[name].[chunkhash].js', filename: '[name].[chunkhash].js',
sourceMapFilename: '[name].[chunkhash].js.map' sourceMapFilename: '[name].[chunkhash].js.map'
}, },
devtool: 'eval-source-map', devtool: production ? 'source-map' : 'eval-source-map',
resolve: { resolve: {
extensions: ['.js', '.jsx'] extensions: ['.js', '.jsx']
}, },
@@ -32,11 +33,15 @@ module.exports = ({ production, analyzer }) => {
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
presets: ['@babel/preset-env', '@babel/preset-react'], presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['react-loadable/babel', '@babel/plugin-syntax-dynamic-import'] plugins: ['@babel/plugin-syntax-dynamic-import']
} }
} }
] ]
}, },
{
test: /\.(css)$/,
use: ['style-loader', 'css-loader']
},
{ {
test: /\.(md)$/, test: /\.(md)$/,
use: [ use: [
@@ -51,9 +56,9 @@ module.exports = ({ production, analyzer }) => {
new DefinePlugin({ new DefinePlugin({
'process.env': { 'process.env': {
'PRODUCTION': production, 'PRODUCTION': production,
'NEWS_URI': production ? `"${process.env.NEWS_URI}"` : '"https://dev-news.eggtrainer.com"', 'NEWS_URI': production ? `"${process.env.NEWS_URI}"` : '"https://dev-news.krgamestudios.com"',
'AUTH_URI': production ? `"${process.env.AUTH_URI}"` : '"https://dev-auth.eggtrainer.com"', 'AUTH_URI': production ? `"${process.env.AUTH_URI}"` : '"https://dev-auth.krgamestudios.com"',
// 'CHAT_URI': production ? `"${process.env.CHAT_URI}"` : '"https://dev-chat.eggtrainer.com"', 'CHAT_URI': production ? `"${process.env.CHAT_URI}"` : '"https://dev-chat.krgamestudios.com"',
} }
}), }),
new CleanWebpackPlugin({ new CleanWebpackPlugin({
@@ -67,8 +72,14 @@ module.exports = ({ production, analyzer }) => {
removeAttributeQuotes: production removeAttributeQuotes: production
} }
}), }),
new CompressionPlugin({
filename: "[path][base].gz[query]",
algorithm: "gzip",
test: /\.js$|\.css$/,
minRatio: 0.8
}),
new BundleAnalyzerPlugin({ new BundleAnalyzerPlugin({
analyzerMode: analyzer ? 'server' : 'disabled' analyzerMode: analyze ? 'server' : 'disabled'
}) })
], ],
devServer: { devServer: {