This commit is contained in:
2017-02-10 00:33:25 +00:00
commit a3e6ce3fb5
99 changed files with 11317 additions and 0 deletions

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# http://editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = space
indent_size = 2
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
# editorconfig-tools is unable to ignore longs strings or urls
max_line_length = null

7
.flowconfig Normal file
View File

@@ -0,0 +1,7 @@
[ignore]
.*/build
.*/config
.*/node_modules
.*/gulpfile.js
[include]

20
.gitattributes vendored Normal file
View File

@@ -0,0 +1,20 @@
# Automatically normalize line endings for all text-based files
# http://git-scm.com/docs/gitattributes#_end_of_line_conversion
* text=auto
# For the following file types, normalize line endings to LF on
# checkin and prevent conversion to CRLF when they are checked out
# (this is required in order to prevent newline related issues like,
# for example, after the build script is run)
.* text eol=lf
*.html text eol=lf
*.css text eol=lf
*.less text eol=lf
*.scss text eol=lf
*.sss text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.sh text eol=lf
*.txt text eol=lf
*.xml text eol=lf

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Include your project-specific ignores in this file
# Read about how to use .gitignore: https://help.github.com/articles/ignoring-files
build
database.sqlite
node_modules
ncp-debug.log
npm-debug.log
wedding.db

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:7.2.1-alpine
# Copy application files
COPY ./build /usr/src/app
WORKDIR /usr/src/app
ENV DATABASE_PATH db/wedding.db
ENV PORT 80
EXPOSE 80
# Install Node.js dependencies
RUN npm install --production --silent
CMD [ "node", "server.js" ]

21
LICENSE.txt Normal file
View File

@@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2014-2016 Konstantin Tarkus, KriaSoft LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

112
README.md Normal file
View File

@@ -0,0 +1,112 @@
# Wedding RSVP site
Forked from [React Starter Kit](https://www.reactstarterkit.com)
development built on top of [Node.js](https://nodejs.org/),
[Express](http://expressjs.com/), [GraphQL](http://graphql.org/) and
[React](https://facebook.github.io/react/), containing modern web development
tools such as [Webpack](http://webpack.github.io/), [Babel](http://babeljs.io/)
and [Browsersync](http://www.browsersync.io/). Helping you to stay productive
following the best practices. A solid starting point for both professionals
and newcomers to the industry.
## Getting Started
### Requirements
* Mac OS X, Windows, or Linux
* [Yarn](https://yarnpkg.com/) package + [Node.js](https://nodejs.org/) v6.5 or newer
* `node-gyp` prerequisites mentioned [here](https://github.com/nodejs/node-gyp)
* Text editor or IDE pre-configured with React/JSX/Flow/ESlint ([learn more](./how-to-configure-text-editors.md))
### Quick Start
#### 1. Run `yarn install`
This will install both run-time project dependencies and developer tools listed
in [package.json](../package.json) file.
#### 2. Run `yarn start`
This command will build the app from the source files (`/src`) into the output
`/build` folder. As soon as the initial build completes, it will start the
Node.js server (`node build/server.js`) and [Browsersync](https://browsersync.io/)
with [HMR](https://webpack.github.io/docs/hot-module-replacement) on top of it.
> [http://localhost:3000/](http://localhost:3000/) — Node.js server (`build/server.js`)<br>
> [http://localhost:3000/graphql](http://localhost:3000/graphql) — GraphQL server and IDE<br>
> [http://localhost:3001/](http://localhost:3001/) — BrowserSync proxy with HMR, React Hot Transform<br>
> [http://localhost:3002/](http://localhost:3002/) — BrowserSync control panel (UI)
Now you can open your web app in a browser, on mobile devices and start
hacking. Whenever you modify any of the source files inside the `/src` folder,
the module bundler ([Webpack](http://webpack.github.io/)) will recompile the
app on the fly and refresh all the connected browsers.
![browsersync](https://dl.dropboxusercontent.com/u/16006521/react-starter-kit/brwosersync.jpg)
Note that the `yarn start` command launches the app in `development` mode,
the compiled output files are not optimized and minimized in this case.
You can use `--release` command line argument to check how your app works
in release (production) mode:
```shell
$ yarn start -- --release
```
*NOTE: double dashes are required*
### How to Build, Test, Deploy
If you need just to build the app (without running a dev server), simply run:
```shell
$ yarn run build
```
or, for a production build:
```shell
$ yarn run build -- --release
```
or, for a production docker build:
```shell
$ yarn run build -- --release --docker
```
*NOTE: double dashes are required*
After running this command, the `/build` folder will contain the compiled
version of the app. For example, you can launch Node.js server normally by
running `node build/server.js`.
To check the source code for syntax errors and potential issues run:
```shell
$ yarn run lint
```
To launch unit tests:
```shell
$ yarn run test # Run unit tests with Mocha
$ yarn run test:watch # Launch unit test runner and start watching for changes
```
By default, [Mocha](https://mochajs.org/) test runner is looking for test files
matching the `src/**/*.test.js` pattern. Take a look at `src/components/Layout/Layout.test.js`
as an example.
To deploy the app, run:
```shell
$ yarn run deploy
```
The deployment script `tools/deploy.js` is configured to push the contents of
the `/build` folder to a remote server via Git. You can easily deploy your app
to [Azure Web Apps](https://azure.microsoft.com/en-us/services/app-service/web/),
or [Heroku](https://www.heroku.com/) this way. Both will execute `yarn install --production`
upon receiving new files from you. Note, you should only deploy the contents
of the `/build` folder to a remote server.

220
package.json Normal file
View File

@@ -0,0 +1,220 @@
{
"name": "wedding",
"version": "0.0.1",
"private": true,
"engines": {
"node": ">=6.5",
"npm": ">=3.10"
},
"dependencies": {
"babel-polyfill": "^6.20.0",
"babel-runtime": "^6.20.0",
"bluebird": "^3.4.6",
"body-parser": "^1.15.2",
"classnames": "^2.2.5",
"cookie-parser": "^1.4.3",
"core-js": "^2.4.1",
"express": "^4.14.0",
"express-graphql": "^0.6.1",
"express-jwt": "^5.1.0",
"fastclick": "^1.0.6",
"fbjs": "^0.8.6",
"graphql": "^0.8.2",
"history": "^4.5.0",
"isomorphic-style-loader": "^1.1.0",
"jsonwebtoken": "^7.2.1",
"node-fetch": "^1.6.3",
"normalize.css": "^5.0.0",
"passport": "^0.3.2",
"passport-facebook": "^2.1.1",
"pretty-error": "^2.0.2",
"query-string": "^4.2.3",
"react": "^15.4.1",
"react-dom": "^15.4.1",
"react-icon-base": "^2.0.4",
"react-icons": "^2.2.3",
"rollbar": "^0.6.3",
"sequelize": "^3.28.0",
"source-map-support": "^0.4.6",
"sqlite3": "^3.1.8",
"universal-router": "^2.0.0",
"whatwg-fetch": "^2.0.1"
},
"devDependencies": {
"assets-webpack-plugin": "^3.5.0",
"autoprefixer": "^6.5.3",
"babel-cli": "^6.18.0",
"babel-core": "^6.18.2",
"babel-eslint": "^7.1.1",
"babel-loader": "^6.2.8",
"babel-plugin-rewire": "^1.0.0",
"babel-plugin-transform-runtime": "^6.15.0",
"babel-preset-latest": "^6.16.0",
"babel-preset-react": "^6.16.0",
"babel-preset-react-optimize": "^1.0.1",
"babel-preset-stage-0": "^6.16.0",
"babel-register": "^6.18.0",
"babel-template": "^6.16.0",
"babel-types": "^6.19.0",
"browser-sync": "^2.18.2",
"chai": "^3.5.0",
"chokidar": "^1.6.1",
"css-loader": "^0.26.0",
"editorconfig-tools": "^0.1.1",
"enzyme": "^2.6.0",
"eslint": "^3.10.2",
"eslint-config-airbnb": "^13.0.0",
"eslint-loader": "^1.6.1",
"eslint-plugin-css-modules": "^1.0.9",
"eslint-plugin-import": "^2.2.0",
"eslint-plugin-jsx-a11y": "^2.2.3",
"eslint-plugin-react": "^6.7.1",
"extend": "^3.0.0",
"file-loader": "^0.9.0",
"front-matter": "^2.1.1",
"git-repository": "^0.1.4",
"glob": "^7.1.1",
"json-loader": "^0.5.4",
"lint-staged": "^3.2.2",
"markdown-it": "^8.2.2",
"mkdirp": "^0.5.1",
"mocha": "^3.1.2",
"pixrem": "^3.0.2",
"pleeease-filters": "^3.0.0",
"postcss": "^5.2.5",
"postcss-calc": "^5.3.1",
"postcss-color-function": "^2.0.1",
"postcss-custom-media": "^5.0.1",
"postcss-custom-properties": "^5.0.1",
"postcss-custom-selectors": "^3.0.0",
"postcss-flexbugs-fixes": "^2.0.0",
"postcss-loader": "^1.1.1",
"postcss-media-minmax": "^2.1.2",
"postcss-nested": "^1.0.0",
"postcss-nesting": "^2.3.1",
"postcss-partial-import": "^2.1.0",
"postcss-pseudoelements": "^3.0.0",
"postcss-selector-matches": "^2.0.5",
"postcss-selector-not": "^2.0.0",
"postcss-url": "^5.1.2",
"pre-commit": "^1.2.2",
"raw-loader": "^0.5.1",
"react-addons-test-utils": "15.4.1",
"react-deep-force-update": "^2.0.1",
"react-hot-loader": "^3.0.0-beta.6",
"redbox-react": "^1.3.3",
"rimraf": "^2.5.4",
"sinon": "^2.0.0-pre.3",
"stylefmt": "^5.0.2",
"stylelint": "^7.6.0",
"stylelint-config-standard": "^15.0.0",
"url-loader": "^0.5.7",
"webpack": "^1.13.3",
"webpack-hot-middleware": "^2.13.2",
"webpack-middleware": "^1.5.1"
},
"babel": {
"presets": [
"react",
"stage-0"
],
"plugins": [
"syntax-trailing-function-commas",
"transform-async-to-generator",
"transform-es2015-destructuring",
"transform-es2015-parameters",
"transform-es2015-duplicate-keys",
"transform-es2015-modules-commonjs",
"transform-exponentiation-operator",
"transform-runtime"
],
"env": {
"test": {
"plugins": [
"rewire"
]
}
}
},
"eslintConfig": {
"parser": "babel-eslint",
"extends": [
"airbnb",
"plugin:css-modules/recommended"
],
"plugins": [
"css-modules"
],
"globals": {
"__DEV__": true
},
"env": {
"browser": true
},
"rules": {
"arrow-parens": "off",
"generator-star-spacing": "off",
"import/extensions": "off",
"import/no-extraneous-dependencies": "off",
"react/forbid-prop-types": "off",
"react/jsx-filename-extension": "off",
"react/no-danger": "off",
"react/no-unused-prop-types": "off",
"react/prefer-stateless-function": "off"
}
},
"stylelint": {
"extends": "stylelint-config-standard",
"rules": {
"string-quotes": "single",
"property-no-unknown": [
true,
{
"ignoreProperties": [
"composes"
]
}
],
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": [
"global"
]
}
]
}
},
"pre-commit": "lint:staged",
"lint-staged": {
"*.{md,sh,txt,xml,html,json}": [
"editorconfig-tools fix",
"git add"
],
"*.{js,jsx}": [
"eslint --fix",
"git add"
],
"*.{css,less,scss,sss}": [
"stylefmt",
"stylelint",
"git add"
]
},
"scripts": {
"lint:js": "eslint src tools",
"lint:css": "stylelint \"src/**/*.{css,less,scss,sss}\"",
"lint:staged": "lint-staged",
"lint": "npm run lint:js && npm run lint:css",
"test": "mocha \"src/**/*.test.js\" --require babel-register --require test/setup.js",
"test:watch": "npm run test -- --reporter min --watch",
"clean": "babel-node tools/run clean",
"copy": "babel-node tools/run copy",
"bundle": "babel-node tools/run bundle",
"build": "babel-node tools/run build",
"deploy": "babel-node tools/run deployToAzureWebApps",
"render": "babel-node tools/run render",
"serve": "babel-node tools/run runServer",
"start": "babel-node tools/run start"
}
}

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

12
public/browserconfig.xml Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Please read: http://msdn.microsoft.com/en-us/library/ie/dn455106.aspx -->
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="tile.png"/>
<square150x150logo src="tile.png"/>
<wide310x150logo src="tile-wide.png"/>
<square310x310logo src="tile.png"/>
</tile>
</msapplication>
</browserconfig>

15
public/crossdomain.xml Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0"?>
<!DOCTYPE cross-domain-policy SYSTEM "http://www.adobe.com/xml/dtds/cross-domain-policy.dtd">
<cross-domain-policy>
<!-- Read this: https://www.adobe.com/devnet/articles/crossdomain_policy_file_spec.html -->
<!-- Most restrictive policy: -->
<site-control permitted-cross-domain-policies="none"/>
<!-- Least restrictive policy: -->
<!--
<site-control permitted-cross-domain-policies="all"/>
<allow-access-from domain="*" to-ports="*" secure="false"/>
<allow-http-request-headers-from domain="*" headers="*" secure="false"/>
-->
</cross-domain-policy>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

15
public/humans.txt Normal file
View File

@@ -0,0 +1,15 @@
# humanstxt.org/
# The humans responsible & technology colophon
# TEAM
Daniel Head -- Developer/Groom -- @danhead
# THANKS
Hana Becka, for saying yes
# TECHNOLOGY COLOPHON
CSS3, HTML5, JavaScript
React, Flux, SuperAgent

5
public/robots.txt Normal file
View File

@@ -0,0 +1,5 @@
# www.robotstxt.org/
# Allow crawling of all content
User-agent: *
Disallow:

BIN
public/tile-wide.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
public/tile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

189
src/client.js Normal file
View File

@@ -0,0 +1,189 @@
import 'babel-polyfill';
import React from 'react';
import ReactDOM from 'react-dom';
import FastClick from 'fastclick';
import UniversalRouter from 'universal-router';
import queryString from 'query-string';
import { createPath } from 'history/PathUtils';
import history from './core/history';
import App from './components/App';
import { ErrorReporter, deepForceUpdate } from './core/devUtils';
// Global (context) variables that can be easily accessed from any React component
// https://facebook.github.io/react/docs/context.html
const context = {
// Enables critical path CSS rendering
// https://github.com/kriasoft/isomorphic-style-loader
insertCss: (...styles) => {
// eslint-disable-next-line no-underscore-dangle
const removeCss = styles.map(x => x._insertCss());
return () => { removeCss.forEach(f => f()); };
},
};
function updateTag(tagName, keyName, keyValue, attrName, attrValue) {
const node = document.head.querySelector(`${tagName}[${keyName}="${keyValue}"]`);
if (node && node.getAttribute(attrName) === attrValue) return;
// Remove and create a new tag in order to make it work with bookmarks in Safari
if (node) {
node.parentNode.removeChild(node);
}
if (typeof attrValue === 'string') {
const nextNode = document.createElement(tagName);
nextNode.setAttribute(keyName, keyValue);
nextNode.setAttribute(attrName, attrValue);
document.head.appendChild(nextNode);
}
}
function updateMeta(name, content) {
updateTag('meta', 'name', name, 'content', content);
}
function updateCustomMeta(property, content) { // eslint-disable-line no-unused-vars
updateTag('meta', 'property', property, 'content', content);
}
function updateLink(rel, href) { // eslint-disable-line no-unused-vars
updateTag('link', 'rel', rel, 'href', href);
}
// Switch off the native scroll restoration behavior and handle it manually
// https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration
const scrollPositionsHistory = {};
if (window.history && 'scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}
let onRenderComplete = function initialRenderComplete() {
const elem = document.getElementById('css');
if (elem) elem.parentNode.removeChild(elem);
onRenderComplete = function renderComplete(route, location) {
document.title = route.title;
updateMeta('description', route.description);
// Update necessary tags in <head> at runtime here, ie:
// updateMeta('keywords', route.keywords);
// updateCustomMeta('og:url', route.canonicalUrl);
// updateCustomMeta('og:image', route.imageUrl);
// updateLink('canonical', route.canonicalUrl);
// etc.
let scrollX = 0;
let scrollY = 0;
const pos = scrollPositionsHistory[location.key];
if (pos) {
scrollX = pos.scrollX;
scrollY = pos.scrollY;
} else {
const targetHash = location.hash.substr(1);
if (targetHash) {
const target = document.getElementById(targetHash);
if (target) {
scrollY = window.pageYOffset + target.getBoundingClientRect().top;
}
}
}
// Restore the scroll position if it was saved into the state
// or scroll to the given #hash anchor
// or scroll to top of the page
window.scrollTo(scrollX, scrollY);
// Google Analytics tracking. Don't send 'pageview' event after
// the initial rendering, as it was already sent
if (window.ga) {
window.ga('send', 'pageview', createPath(location));
}
};
};
// Make taps on links and buttons work fast on mobiles
FastClick.attach(document.body);
const container = document.getElementById('app');
let appInstance;
let currentLocation = history.location;
let routes = require('./routes').default;
// Re-render the app when window.location changes
async function onLocationChange(location) {
// Remember the latest scroll position for the previous location
scrollPositionsHistory[currentLocation.key] = {
scrollX: window.pageXOffset,
scrollY: window.pageYOffset,
};
// Delete stored scroll position for next page if any
if (history.action === 'PUSH') {
delete scrollPositionsHistory[location.key];
}
currentLocation = location;
try {
// Traverses the list of routes in the order they are defined until
// it finds the first route that matches provided URL path string
// and whose action method returns anything other than `undefined`.
const route = await UniversalRouter.resolve(routes, {
path: location.pathname,
query: queryString.parse(location.search),
});
// Prevent multiple page renders during the routing process
if (currentLocation.key !== location.key) {
return;
}
if (route.redirect) {
history.replace(route.redirect);
return;
}
appInstance = ReactDOM.render(
<App context={context}>{route.component}</App>,
container,
() => onRenderComplete(route, location),
);
} catch (error) {
console.error(error); // eslint-disable-line no-console
// Current url has been changed during navigation process, do nothing
if (currentLocation.key !== location.key) {
return;
}
// Display the error in full-screen for development mode
if (process.env.NODE_ENV !== 'production') {
appInstance = null;
document.title = `Error: ${error.message}`;
ReactDOM.render(<ErrorReporter error={error} />, container);
return;
}
// Avoid broken navigation in production mode by a full page reload on error
window.location.reload();
}
}
// Handle client-side navigation by using HTML5 History API
// For more information visit https://github.com/mjackson/history#readme
history.listen(onLocationChange);
onLocationChange(currentLocation);
// Enable Hot Module Replacement (HMR)
if (module.hot) {
module.hot.accept('./routes', () => {
routes = require('./routes').default; // eslint-disable-line global-require
if (appInstance) {
try {
// Force-update the whole tree, including components that refuse to update
deepForceUpdate(appInstance);
} catch (error) {
appInstance = null;
document.title = `Hot Update Error: ${error.message}`;
ReactDOM.render(<ErrorReporter error={error} />, container);
return;
}
}
onLocationChange(currentLocation);
});
}

52
src/components/App.js Normal file
View File

@@ -0,0 +1,52 @@
import React, { PropTypes } from 'react';
const ContextType = {
// Enables critical path CSS rendering
// https://github.com/kriasoft/isomorphic-style-loader
insertCss: PropTypes.func.isRequired,
};
/**
* The top-level React component setting context (global) variables
* that can be accessed from all the child components.
*
* https://facebook.github.io/react/docs/context.html
*
* Usage example:
*
* const context = {
* history: createBrowserHistory(),
* store: createStore(),
* };
*
* ReactDOM.render(
* <App context={context}>
* <Layout>
* <LandingPage />
* </Layout>
* </App>,
* container,
* );
*/
class App extends React.PureComponent {
static propTypes = {
context: PropTypes.shape(ContextType).isRequired,
children: PropTypes.element.isRequired,
};
static childContextTypes = ContextType;
getChildContext() {
return this.props.context;
}
render() {
// NOTE: If you need to add or modify header, footer etc. of the app,
// please do that inside the Layout component.
return React.Children.only(this.props.children);
}
}
export default App;

45
src/components/Html.js Normal file
View File

@@ -0,0 +1,45 @@
import React, { PropTypes } from 'react';
import { analytics } from '../config';
class Html extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
style: PropTypes.string,
scripts: PropTypes.arrayOf(PropTypes.string.isRequired),
children: PropTypes.string,
};
render() {
const { title, description, style, scripts, children } = this.props;
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="x-ua-compatible" content="ie=edge" />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
{style && <style id="css" dangerouslySetInnerHTML={{ __html: style }} />}
</head>
<body>
<div id="app" dangerouslySetInnerHTML={{ __html: children }} />
{scripts && scripts.map(script => <script key={script} src={script} />)}
{analytics.google.trackingId &&
<script
dangerouslySetInnerHTML={{ __html:
'window.ga=function(){ga.q.push(arguments)};ga.q=[];ga.l=+new Date;' +
`ga('create','${analytics.google.trackingId}','auto');ga('send','pageview')` }}
/>
}
{analytics.google.trackingId &&
<script src="https://www.google-analytics.com/analytics.js" async defer />
}
</body>
</html>
);
}
}
export default Html;

View File

@@ -0,0 +1,170 @@
@import '../../../node_modules/normalize.css/normalize.css';
@import '../variables.css';
/*
* Base styles
* ========================================================================== */
html {
color: #222;
font-weight: 100;
font-size: 1em; /* ~16px; */
font-family: var(--font-family-base);
line-height: 1.375; /* ~22px */
}
a {
color: #0074c2;
}
/*
* Remove text-shadow in selection highlight:
* https://twitter.com/miketaylr/status/12228805301
*
* These selection rule sets have to be separate.
* Customize the background color to match your design.
*/
::-moz-selection {
background: #b3d4fc;
text-shadow: none;
}
::selection {
background: #b3d4fc;
text-shadow: none;
}
/*
* A better looking default horizontal rule
*/
hr {
display: block;
height: 1px;
border: 0;
border-top: 1px solid #ccc;
margin: 1em 0;
padding: 0;
}
/*
* Remove the gap between audio, canvas, iframes,
* images, videos and the bottom of their containers:
* https://github.com/h5bp/html5-boilerplate/issues/440
*/
audio,
canvas,
iframe,
img,
svg,
video {
vertical-align: middle;
}
/*
* Remove default fieldset styles.
*/
fieldset {
border: 0;
margin: 0;
padding: 0;
}
/*
* Allow only vertical resizing of textareas.
*/
textarea {
resize: vertical;
}
/*
* Browser upgrade prompt
* ========================================================================== */
:global(.browserupgrade) {
margin: 0.2em 0;
background: #ccc;
color: #000;
padding: 0.2em 0;
}
/*
* Print styles
* Inlined to avoid the additional HTTP request:
* http://www.phpied.com/delay-loading-your-print-css/
* ========================================================================== */
@media print {
*,
*::before,
*::after {
background: transparent !important;
color: #000 !important; /* Black prints faster: http://www.sanbeiji.com/archives/953 */
box-shadow: none !important;
text-shadow: none !important;
}
a,
a:visited {
text-decoration: underline;
}
a[href]::after {
content: ' (' attr(href) ')';
}
abbr[title]::after {
content: ' (' attr(title) ')';
}
/*
* Don't show links that are fragment identifiers,
* or use the `javascript:` pseudo protocol
*/
a[href^='#']::after,
a[href^='javascript:']::after {
content: '';
}
pre,
blockquote {
border: 1px solid #999;
page-break-inside: avoid;
}
/*
* Printing Tables:
* http://css-discuss.incutio.com/wiki/Printing_Tables
*/
thead {
display: table-header-group;
}
tr,
img {
page-break-inside: avoid;
}
img {
max-width: 100% !important;
}
p,
h2,
h3 {
orphans: 3;
widows: 3;
}
h2,
h3 {
page-break-after: avoid;
}
}

View File

@@ -0,0 +1,19 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Layout.css';
class Layout extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
};
render() {
return (
<div>
{this.props.children}
</div>
);
}
}
export default withStyles(s)(Layout);

View File

@@ -0,0 +1,23 @@
/* eslint-env mocha */
/* eslint-disable padded-blocks, no-unused-expressions */
import React from 'react';
import { expect } from 'chai';
import { render } from 'enzyme';
import App from '../App';
import Layout from './Layout';
describe('Layout', () => {
it('renders children correctly', () => {
const wrapper = render(
<App context={{ insertCss: () => {} }}>
<Layout>
<div className="child" />
</Layout>
</App>,
);
expect(wrapper.find('div.child').length).to.eq(1);
});
});

View File

@@ -0,0 +1,6 @@
{
"name": "Layout",
"version": "0.0.0",
"private": true,
"main": "./Layout.js"
}

View File

@@ -0,0 +1,42 @@
import React, { PropTypes } from 'react';
import history from '../../core/history';
function isLeftClickEvent(event) {
return event.button === 0;
}
function isModifiedEvent(event) {
return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
}
class Link extends React.Component {
static propTypes = {
to: PropTypes.string.isRequired,
children: PropTypes.node,
onClick: PropTypes.func,
};
handleClick = (event) => {
if (this.props.onClick) {
this.props.onClick(event);
}
if (isModifiedEvent(event) || !isLeftClickEvent(event)) {
return;
}
if (event.defaultPrevented === true) {
return;
}
event.preventDefault();
history.push(this.props.to);
};
render() {
const { to, children, ...props } = this.props;
return <a href={to} {...props} onClick={this.handleClick}>{children}</a>;
}
}
export default Link;

View File

@@ -0,0 +1,6 @@
{
"name": "Link",
"version": "0.0.0",
"private": true,
"main": "./Link.js"
}

View File

@@ -0,0 +1,12 @@
@import '../variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}

View File

@@ -0,0 +1,24 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Page.css';
class Page extends React.Component {
static propTypes = {
title: PropTypes.string,
html: PropTypes.string.isRequired,
};
render() {
const { title, html } = this.props;
return (
<div className={s.root}>
<div className={s.container}>
{title && <h1>{title}</h1>}
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
</div>
);
}
}
export default withStyles(s)(Page);

View File

@@ -0,0 +1,6 @@
{
"name": "Page",
"version": "0.0.0",
"private": true,
"main": "./Page.js"
}

View File

@@ -0,0 +1,98 @@
@import '../variables.css';
.root {
display: flex;
flex-flow: row wrap;
flex-direction: column;
width: 100%;
margin: 0;
padding: 20px;
}
@media (--sm-viewport) {
.root {
width: 50%;
}
}
.formGroup {
position: relative;
margin-bottom: 15px;
}
.label {
display: inline-block;
margin-bottom: 5px;
max-width: 100%;
font-weight: 700;
}
.input {
display: block;
box-sizing: border-box;
padding: 10px 16px;
width: 100%;
height: 46px;
outline: 0;
border: 1px solid var(--colour-grey);
border-radius: 0;
background-color: #fff;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
color: #616161;
font-size: 18px;
line-height: 1.3333333;
transition: border-color ease-in-out 0.15s,
box-shadow ease-in-out 0.15s,
background-color ease-in-out 0.15s;
}
.input:focus {
border-color: var(--colour-tertiary);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 116, 194, 0.6);
}
.inputSuccess {
background-color: var(--colour-green);
}
.inputError {
background-color: var(--colour-red);
}
.rsvpDetails {
display: none;
}
.rsvpDetailsVisible {
display: block;
}
.iconContainer {
display: inline-block;
position: absolute;
top: 35px;
right: 15px;
}
.rsvpSummary {
position: fixed;
right: 10px;
bottom: 10px;
left: 10px;
padding: 10px;
text-align: center;
opacity: 0;
border-radius: 0;
background-color: #fff;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
color: #616161;
font-size: 18px;
pointer-events: none;
transition: opacity ease-in-out .4s;
}
.rsvpSummarySuccess {
opacity: 1;
background-color: var(--colour-green);
}

View File

@@ -0,0 +1,261 @@
import React, { PropTypes } from 'react';
import cx from 'classnames';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import { MdCheck, MdError } from 'react-icons/lib/md';
import s from './RsvpCard.css';
import { starters, mains } from '../../config';
class RsvpCard extends React.Component {
static propTypes = {
className: PropTypes.string,
person: PropTypes.shape({
key: PropTypes.string,
firstname: PropTypes.string,
lastname: PropTypes.string,
email: PropTypes.string,
password: PropTypes.string,
completed: PropTypes.boolean,
attending: PropTypes.boolean,
dietary: PropTypes.string,
starter: PropTypes.string,
main: PropTypes.string,
}).isRequired,
};
state = {
detailsVisible: this.props.person.attending,
saveAttending: this.props.person.attending ? 'saved' : null,
saveStarter: this.props.person.starter !== '-1' ? 'saved' : null,
saveMain: this.props.person.main !== '-1' ? 'saved' : null,
saveDietary: this.props.person.dietary ? 'saved' : null,
};
isRSVPComplete(prop, value) {
function isOK(str) {
return (str === 'saved' || str === 'success');
}
if (prop === 'attending' && value === 'false') { return true; }
return (isOK(this.state.saveAttending) &&
isOK(this.state.saveStarter) &&
isOK(this.state.saveMain));
}
saveData(prop, value) {
const stateKey = `save${prop.charAt(0).toUpperCase()}${prop.slice(1)}`;
const state = {};
fetch('/rsvp/save', {
method: 'post',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: this.props.person.key,
prop,
value,
}),
credentials: 'include',
}).then(res => res.json())
.then(data => {
state[stateKey] = data.success ? 'success' : 'error';
this.setState(state);
if (this.isRSVPComplete(prop, value)) {
this.setState({
summaryState: 'success',
summary: `Thanks ${this.props.person.firstname}, your RSVP has been received`,
});
if (!this.state.saveCompleted) {
this.setState({ saveSompleted: true });
this.saveData('completed', true);
}
setTimeout(() => {
this.setState({
summaryState: null,
summary: null,
});
}, 5000);
}
state[stateKey] = data.success ? 'saved' : 'error';
setTimeout(() => {
this.setState(state);
}, 500);
}).catch(() => {
state[stateKey] = 'error';
});
}
handleAttendingChange = (event) => {
this.saveData('attending', event.target.value);
this.setState({
detailsVisible: event.target.value === 'true',
attendingResponse: event.target.value === 'true' ? '👍' : '☹️',
});
};
handleDietaryBlur = (event) => {
this.saveData('dietary', event.target.value);
};
handleStarterChange = (event) => {
this.saveData('starter', event.target.value);
};
handleMainChange = (event) => {
this.saveData('main', event.target.value);
};
handleFormSubmit = (event) => {
event.preventDefault();
};
render() {
return (
<div className={cx(s.root, this.props.className)}>
<h2>
{this.props.person.firstname} {this.props.person.lastname}
<span> {this.state.attendingResponse}</span>
</h2>
<form onSubmit={this.handleFormSubmit}>
<div className={s.formGroup}>
<label className={s.label} htmlFor="attending">
Attending:
</label>
{this.props.person.attending === null ?
<select
className={[
s.input,
this.state.saveAttending === 'success' ? s.inputSuccess : null,
this.state.saveAttending === 'error' ? s.inputError : null,
].join(' ')}
id="attending"
name="attending"
defaultValue="-1"
onChange={this.handleAttendingChange}
>
<option value="-1" disabled>Will you be attending?</option>
<option value="true">
Yes
</option>
<option value="false">
No
</option>
</select>
:
<select
className={[
s.input,
this.state.saveAttending === 'success' ? s.inputSuccess : null,
this.state.saveAttending === 'error' ? s.inputError : null,
].join(' ')}
id="attending"
name="attending"
defaultValue={this.props.person.attending === true ? 'true' : 'false'}
onChange={this.handleAttendingChange}
>
<option value="true">
Yes
</option>
<option value="false">
No
</option>
</select>
}
<div className={s.iconContainer}>
{this.state.saveAttending === 'saved' ? <MdCheck size={30} color="green" /> : null}
{this.state.saveAttending === 'error' ? <MdError size={30} color="red" /> : null}
</div>
</div>
<div className={[s.rsvpDetails, this.state.detailsVisible ? s.rsvpDetailsVisible : null].join(' ')}>
<div className={s.formGroup}>
<label className={s.label} htmlFor="starter">
Starter:
</label>
<select
className={[
s.input,
this.state.saveStarter === 'success' ? s.inputSuccess : null,
this.state.saveStarter === 'error' ? s.inputError : null,
].join(' ')}
id="starter"
name="starter"
defaultValue={this.props.person.starter}
onChange={this.handleStarterChange}
>
{this.props.person.starter === '-1' ? <option value="-1" disabled>Please select a starter</option> : null}
{starters.map((starter, index) => (
<option key={index} value={index}>{starter}</option>
))}
</select>
<div className={s.iconContainer}>
{this.state.saveStarter === 'saved' ? <MdCheck size={30} color="green" /> : null}
{this.state.saveStarter === 'error' ? <MdError size={30} color="red" /> : null}
</div>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="main">
Main:
</label>
<select
className={[
s.input,
this.state.saveMain === 'success' ? s.inputSuccess : null,
this.state.saveMain === 'error' ? s.inputError : null,
].join(' ')}
id="main"
name="main"
defaultValue={this.props.person.main}
onChange={this.handleMainChange}
>
{this.props.person.main === '-1' ? <option value="-1" disabled>Please select a main course</option> : null}
{mains.map((main, index) => (
<option key={index} value={index}>{main}</option>
))}
</select>
<div className={s.iconContainer}>
{this.state.saveMain === 'saved' ? <MdCheck size={30} color="green" /> : null}
{this.state.saveMain === 'error' ? <MdError size={30} color="red" /> : null}
</div>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="dietary">
Dietary requirements (if any):
</label>
<input
className={[
s.input,
this.state.saveDietary === 'success' ? s.inputSuccess : null,
this.state.saveDietary === 'error' ? s.inputError : null,
].join(' ')}
id="dietary"
type="text"
name="dietary"
defaultValue={this.props.person.dietary}
onBlur={this.handleDietaryBlur}
/>
<div className={s.iconContainer}>
{this.state.saveDietary === 'saved' ? <MdCheck size={30} color="green" /> : null}
{this.state.saveDietary === 'error' ? <MdError size={30} color="red" /> : null}
</div>
</div>
</div>
</form>
<div
className={[
s.rsvpSummary,
this.state.summaryState === 'success' ? s.rsvpSummarySuccess : null,
].join(' ')}
>
{this.state.summary}
</div>
</div>
);
}
}
export default withStyles(s)(RsvpCard);

View File

@@ -0,0 +1,6 @@
{
"name": "RsvpCard",
"version": "0.0.0",
"private": true,
"main": "./RsvpCard.js"
}

View File

@@ -0,0 +1,34 @@
:root {
/*
* Typography
* ======================================================================== */
--font-family-base: 'Segoe UI', 'HelveticaNeue-Light', sans-serif;
/*
* Layout
* ======================================================================== */
--max-content-width: 1000px;
/*
* Media queries breakpoints
* ======================================================================== */
/* --screen-xs-min: 480px; /* Extra small screen / phone */
/* --screen-sm-min: 768px; /* Small screen / tablet */
/* --screen-md-min: 992px; /* Medium screen / desktop */
/* --screen-lg-min: 1200px; /* Large screen / wide desktop */
@custom-media --xs-viewport (min-width: 480px);
@custom-media --sm-viewport (min-width: 768px);
@custom-media --md-viewport (min-width: 992px);
@custom-media --lg-viewport (min-width: 1200px);
--colour-primary: #6495ED;
--colour-secondary: #2A6FEB;
--colour-tertiary: #2A6FEB;
--colour-grey: #CCC;
--colour-green: #87EDA2;
--colour-red: #FA7575;
}

6
src/core/admin.js Normal file
View File

@@ -0,0 +1,6 @@
import { adminUsers } from '../config';
export default function (email) {
return adminUsers.indexOf(email) !== -1;
}

13
src/core/devUtils.js Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-disable global-require */
if (module.hot || process.env.NODE_ENV !== 'production') {
module.exports = {
// The red box (aka red screen of death) component to display your errors
// https://github.com/commissure/redbox-react
ErrorReporter: require('redbox-react').default,
// Force-updates React component tree recursively
// https://github.com/gaearon/react-deep-force-update
deepForceUpdate: require('react-deep-force-update'),
};
}

View File

@@ -0,0 +1,6 @@
import 'whatwg-fetch';
export default self.fetch.bind(self);
export const Headers = self.Headers;
export const Request = self.Request;
export const Response = self.Response;

View File

@@ -0,0 +1,24 @@
import Promise from 'bluebird';
import fetch, { Request, Headers, Response } from 'node-fetch';
import { host } from '../../config';
fetch.Promise = Promise;
Response.Promise = Promise;
function localUrl(url) {
if (url.startsWith('//')) {
return `https:${url}`;
}
if (url.startsWith('http')) {
return url;
}
return `http://${host}${url}`;
}
function localFetch(url, options) {
return fetch(localUrl(url), options);
}
export { localFetch as default, Request, Headers, Response };

View File

@@ -0,0 +1,6 @@
{
"private": true,
"name": "fetch",
"main": "./fetch.server.js",
"browser": "./fetch.client.js"
}

17
src/core/fetchQL.js Normal file
View File

@@ -0,0 +1,17 @@
import fetch from './fetch';
export default function (query, params) {
return fetch('/graphql', {
method: 'post',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
params,
}),
credentials: 'include',
});
}

5
src/core/history.js Normal file
View File

@@ -0,0 +1,5 @@
import createBrowserHistory from 'history/createBrowserHistory';
// Navigation manager, e.g. history.push('/home')
// https://github.com/mjackson/history
export default process.env.BROWSER && createBrowserHistory();

121
src/core/passport.js Normal file
View File

@@ -0,0 +1,121 @@
/**
* Passport.js reference implementation.
* The database schema used in this sample is available at
* https://github.com/membership/membership.db/tree/master/postgres
*/
import passport from 'passport';
import { Strategy as FacebookStrategy } from 'passport-facebook';
import { User, UserLogin, UserClaim, UserProfile } from '../data/models';
import { auth as config } from '../config';
/**
* Sign in with Facebook.
*/
passport.use(new FacebookStrategy({
clientID: config.facebook.id,
clientSecret: config.facebook.secret,
callbackURL: '/login/facebook/return',
profileFields: ['name', 'email', 'link', 'locale', 'timezone'],
passReqToCallback: true,
}, (req, accessToken, refreshToken, profile, done) => {
/* eslint-disable no-underscore-dangle */
const loginName = 'facebook';
const claimType = 'urn:facebook:access_token';
const fooBar = async () => {
if (req.user) {
const userLogin = await UserLogin.findOne({
attributes: ['name', 'key'],
where: { name: loginName, key: profile.id },
});
if (userLogin) {
// There is already a Facebook account that belongs to you.
// Sign in with that account or delete it, then link it with your current account.
done();
} else {
const user = await User.create({
id: req.user.id,
email: profile._json.email,
logins: [
{ name: loginName, key: profile.id },
],
claims: [
{ type: claimType, value: profile.id },
],
profile: {
displayName: profile.displayName,
gender: profile._json.gender,
picture: `https://graph.facebook.com/${profile.id}/picture?type=large`,
},
}, {
include: [
{ model: UserLogin, as: 'logins' },
{ model: UserClaim, as: 'claims' },
{ model: UserProfile, as: 'profile' },
],
});
done(null, {
id: user.id,
email: user.email,
});
}
} else {
const users = await User.findAll({
attributes: ['id', 'email'],
where: { '$logins.name$': loginName, '$logins.key$': profile.id },
include: [
{
attributes: ['name', 'key'],
model: UserLogin,
as: 'logins',
required: true,
},
],
});
if (users.length) {
const user = users[0];
done(null, {
id: user.id,
email: user.email,
});
} else {
let user = await User.findOne({ where: { email: profile._json.email } });
if (user) {
// There is already an account using this email address. Sign in to
// that account and link it with Facebook manually from Account Settings.
done(null);
} else {
user = await User.create({
email: profile._json.email,
emailConfirmed: true,
logins: [
{ name: loginName, key: profile.id },
],
claims: [
{ type: claimType, value: accessToken },
],
profile: {
displayName: profile.displayName,
gender: profile._json.gender,
picture: `https://graph.facebook.com/${profile.id}/picture?type=large`,
},
}, {
include: [
{ model: UserLogin, as: 'logins' },
{ model: UserClaim, as: 'claims' },
{ model: UserProfile, as: 'profile' },
],
});
done(null, {
id: user.id,
email: user.email,
});
}
}
}
};
fooBar().catch(done);
}));
export default passport;

View File

@@ -0,0 +1,5 @@
{
"rules": {
"new-cap": 0
}
}

73
src/data/models/Person.js Normal file
View File

@@ -0,0 +1,73 @@
import DataType from 'sequelize';
import Model from '../sequelize';
const passwordChars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 't', 'u',
'w', 'x', 'y', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q', 'R', 'T', 'U', 'W',
'X', 'Y', '2', '3', '4', '6', '7', '8', '9'];
function generatePassword() {
let pwd = '';
for (let i = 0; i < 4; i += 1) {
pwd += passwordChars[Math.floor(Math.random() * passwordChars.length)];
}
return pwd;
}
const Person = Model.define('Person', {
key: {
type: DataType.UUID,
defaultValue: DataType.UUIDV1,
primaryKey: true,
},
password: {
type: DataType.STRING(4),
defaultValue() {
return generatePassword();
},
},
firstname: {
type: DataType.STRING(50),
},
lastname: {
type: DataType.STRING(50),
},
email: {
type: DataType.STRING(255),
},
completed: {
type: DataType.BOOLEAN,
defaultValue: false,
},
attending: {
type: DataType.BOOLEAN,
},
dietary: {
type: DataType.STRING(255),
},
starter: {
type: DataType.INTEGER,
defaultValue: -1,
},
main: {
type: DataType.INTEGER,
defaultValue: -1,
},
}, {
indexes: [
{ fields: ['email'] },
],
});
export default Person;

30
src/data/models/User.js Normal file
View File

@@ -0,0 +1,30 @@
import DataType from 'sequelize';
import Model from '../sequelize';
const User = Model.define('User', {
id: {
type: DataType.UUID,
defaultValue: DataType.UUIDV1,
primaryKey: true,
},
email: {
type: DataType.STRING(255),
validate: { isEmail: true },
},
emailConfirmed: {
type: DataType.BOOLEAN,
defaultValue: false,
},
}, {
indexes: [
{ fields: ['email'] },
],
});
export default User;

View File

@@ -0,0 +1,16 @@
import DataType from 'sequelize';
import Model from '../sequelize';
const UserClaim = Model.define('UserClaim', {
type: {
type: DataType.STRING,
},
value: {
type: DataType.STRING,
},
});
export default UserClaim;

View File

@@ -0,0 +1,18 @@
import DataType from 'sequelize';
import Model from '../sequelize';
const UserLogin = Model.define('UserLogin', {
name: {
type: DataType.STRING(50),
primaryKey: true,
},
key: {
type: DataType.STRING(100),
primaryKey: true,
},
});
export default UserLogin;

View File

@@ -0,0 +1,33 @@
import DataType from 'sequelize';
import Model from '../sequelize';
const UserProfile = Model.define('UserProfile', {
userId: {
type: DataType.UUID,
primaryKey: true,
},
displayName: {
type: DataType.STRING(100),
},
picture: {
type: DataType.STRING(255),
},
gender: {
type: DataType.STRING(50),
},
location: {
type: DataType.STRING(100),
},
website: {
type: DataType.STRING(255),
},
});
export default UserProfile;

34
src/data/models/index.js Normal file
View File

@@ -0,0 +1,34 @@
import sequelize from '../sequelize';
import User from './User';
import UserLogin from './UserLogin';
import UserClaim from './UserClaim';
import UserProfile from './UserProfile';
import Person from './Person';
User.hasMany(UserLogin, {
foreignKey: 'userId',
as: 'logins',
onUpdate: 'cascade',
onDelete: 'cascade',
});
User.hasMany(UserClaim, {
foreignKey: 'userId',
as: 'claims',
onUpdate: 'cascade',
onDelete: 'cascade',
});
User.hasOne(UserProfile, {
foreignKey: 'userId',
as: 'profile',
onUpdate: 'cascade',
onDelete: 'cascade',
});
function sync(...args) {
return sequelize.sync(...args);
}
export default { sync };
export { User, UserLogin, UserClaim, UserProfile, Person };

View File

@@ -0,0 +1,25 @@
import { GraphQLList as List } from 'graphql';
import PersonItemType from '../types/PersonItemType';
import Person from '../models/Person';
import admin from '../../core/admin';
const person = {
type: new List(PersonItemType),
resolve({ request }) {
const params = request.body.params;
const user = request.user || {};
const isAdmin = admin(user.email);
if (params && params.code) {
return Person.findAll({
where: {
password: params.code,
},
});
}
return isAdmin && Person.findAll();
},
};
export default person;

View File

@@ -0,0 +1,22 @@
import { GraphQLList as List } from 'graphql';
import PersonItemType from '../types/PersonItemType';
import Person from '../models/Person';
const getPersonByKey = {
type: new List(PersonItemType),
resolve({ request }) {
const params = request.body.params;
if (params && params.key) {
return Person.findAll({
where: {
key: params.key,
},
limit: 1,
});
}
return false;
},
};
export default getPersonByKey;

View File

@@ -0,0 +1,22 @@
import { GraphQLList as List } from 'graphql';
import PersonItemType from '../types/PersonItemType';
import Person from '../models/Person';
const getPersonsByPassword = {
type: new List(PersonItemType),
resolve({ request }) {
const params = request.body.params;
if (params && params.password) {
return Person.findAll({
where: {
password: params.password,
},
});
}
return false;
},
};
export default getPersonsByPassword;

13
src/data/queries/me.js Normal file
View File

@@ -0,0 +1,13 @@
import UserType from '../types/UserType';
const me = {
type: UserType,
resolve({ request }) {
return request.user && {
id: request.user.id,
email: request.user.email,
};
},
};
export default me;

23
src/data/schema.js Normal file
View File

@@ -0,0 +1,23 @@
import {
GraphQLSchema as Schema,
GraphQLObjectType as ObjectType,
} from 'graphql';
import me from './queries/me';
import getAllPersons from './queries/getAllPersons';
import getPersonByKey from './queries/getPersonByKey';
import getPersonsByPassword from './queries/getPersonsByPassword';
const schema = new Schema({
query: new ObjectType({
name: 'Query',
fields: {
me,
getAllPersons,
getPersonByKey,
getPersonsByPassword,
},
}),
});
export default schema;

12
src/data/sequelize.js Normal file
View File

@@ -0,0 +1,12 @@
import Sequelize from 'sequelize';
import { databasePath } from '../config';
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: databasePath,
define: {
freezeTableName: true,
},
});
export default sequelize;

View File

@@ -0,0 +1,25 @@
import {
GraphQLObjectType as ObjectType,
GraphQLID as ID,
GraphQLString as StringType,
GraphQLBoolean as BooleanType,
GraphQLNonNull as NonNull,
} from 'graphql';
const PersonItemType = new ObjectType({
name: 'PersonItem',
fields: {
key: { type: new NonNull(ID) },
password: { type: StringType },
firstname: { type: StringType },
lastname: { type: StringType },
email: { type: StringType },
completed: { type: BooleanType },
attending: { type: BooleanType },
dietary: { type: StringType },
starter: { type: StringType },
main: { type: StringType },
},
});
export default PersonItemType;

View File

@@ -0,0 +1,16 @@
import {
GraphQLObjectType as ObjectType,
GraphQLID as ID,
GraphQLString as StringType,
GraphQLNonNull as NonNull,
} from 'graphql';
const UserType = new ObjectType({
name: 'User',
fields: {
id: { type: new NonNull(ID) },
email: { type: StringType },
},
});
export default UserType;

View File

@@ -0,0 +1,13 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}

36
src/routes/admin/Admin.js Normal file
View File

@@ -0,0 +1,36 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Admin.css';
import Link from '../../components/Link';
class Admin extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
people: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string.isRequired,
firstname: PropTypes.string,
lastname: PropTypes.string,
email: PropTypes.string,
password: PropTypes.string,
attending: PropTypes.boolean,
})).isRequired,
};
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.props.title}</h1>
<div>
<h2>People</h2>
<p>{this.props.people.length} people</p>
<p>{this.props.people.filter(person => person.completed).length} responses</p>
<Link to="/admin/people">Manage people</Link>
</div>
</div>
</div>
);
}
}
export default withStyles(s)(Admin);

41
src/routes/admin/index.js Normal file
View File

@@ -0,0 +1,41 @@
import React from 'react';
import Layout from '../../components/Layout';
import fetchQL from '../../core/fetchQL';
import { starters, mains } from '../../config';
const title = 'Admin';
export default {
path: '/admin',
async action() {
const resp = await fetchQL('{getAllPersons{key,firstname,lastname,email,password,completed,starter,main}}');
const food = {
starters: starters.map(name => ({ name, total: 0 })),
mains: mains.map(name => ({ name, total: 0 })),
};
let people = await resp.json();
if (people.data && people.data.getAllPersons) {
people = people.data.getAllPersons;
} else {
people = [];
}
const Admin = await new Promise((resolve) => {
require.ensure([], (require) => resolve(require('./Admin').default), 'admin');
});
return {
title,
chunk: 'admin',
component: <Layout><Admin
title={title}
people={people}
food={food}
/></Layout>,
};
},
};

View File

@@ -0,0 +1,77 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}
.formGroup {
position: relative;
margin-bottom: 15px;
}
.label {
display: inline-block;
margin-bottom: 5px;
max-width: 100%;
font-weight: 700;
}
.input {
display: block;
box-sizing: border-box;
padding: 10px 16px;
width: 100%;
height: 46px;
outline: 0;
border: 1px solid var(--colour-grey);
border-radius: 0;
background-color: #fff;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
color: #616161;
font-size: 18px;
line-height: 1.3333333;
transition: border-color ease-in-out 0.15s,
box-shadow ease-in-out 0.15s,
background-color ease-in-out 0.15s;
}
.input:focus {
border-color: var(--colour-tertiary);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 116, 194, 0.6);
}
.button {
display: block;
box-sizing: border-box;
margin: 0;
padding: 10px 16px;
width: 100%;
outline: 0;
border: 1px solid var(--colour-secondary);
border-radius: 0;
background: var(--colour-primary);;
color: #fff;
text-align: center;
text-decoration: none;
font-size: 18px;
line-height: 1.3333333;
cursor: pointer;
}
.button:hover {
background: var(--colour-secondary);
}
.button:focus {
border-color: var(--colour-tertiary);
box-shadow: 0 0 8px var(--colour-secondary);
}

View File

@@ -0,0 +1,112 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './AdminPeople.css';
import Link from '../../components/Link';
class AdminPeople extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
people: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string.isRequired,
firstname: PropTypes.string,
lastname: PropTypes.string,
completed: PropTypes.boolean,
email: PropTypes.string,
password: PropTypes.string,
attending: PropTypes.boolean,
})).isRequired,
};
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.props.title}</h1>
{this.props.people.length === 0 ? <p> No people yet ☹️</p> :
<table>
<thead>
<tr>
<td>Name</td>
<td>Email</td>
<td>Password</td>
</tr>
</thead>
<tbody>
{this.props.people.map(person => (
<tr key={person.key}>
<td><Link to={`/admin/person/${person.key}`}>{person.firstname} {person.lastname}</Link></td>
<td>{person.email}</td>
<td><Link to={`/rsvp/${person.password}`}>{person.password}</Link></td>
<td>
<form method="post" action="/admin/person/delete">
<input type="hidden" name="key" value={person.key} />
<button className={s.button} type="submit">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
}
<h2>Add Person</h2>
<form method="post" action="/admin/person">
<div className={s.formGroup}>
<label className={s.label} htmlFor="firstname">
First name:
</label>
<input
className={s.input}
id="firstname"
type="text"
name="firstname"
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="lastname">
Last name:
</label>
<input
className={s.input}
id="lastname"
type="text"
name="lastname"
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="email">
Email:
</label>
<input
className={s.input}
id="email"
type="text"
name="email"
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="password">
Password (blank will autogenerate):
</label>
<input
className={s.input}
id="password"
type="text"
name="password"
/>
</div>
<div className={s.formGroup}>
<button className={s.button} type="submit">
Save
</button>
</div>
</form>
</div>
</div>
);
}
}
export default withStyles(s)(AdminPeople);

View File

@@ -0,0 +1,32 @@
import React from 'react';
import Layout from '../../components/Layout';
import fetchQL from '../../core/fetchQL';
const title = 'People';
export default {
path: '/admin/people',
async action() {
const resp = await fetchQL('{getAllPersons{key,firstname,lastname,email,completed,password}}');
let people = await resp.json();
if (people.data && people.data.getAllPersons) {
people = people.data.getAllPersons;
} else {
people = [];
}
const AdminPeople = await new Promise((resolve) => {
require.ensure([], (require) => resolve(require('./AdminPeople').default), 'adminPeople');
});
return {
title,
chunk: 'admin',
component: <Layout><AdminPeople title={title} people={people} /></Layout>,
};
},
};

View File

@@ -0,0 +1,77 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}
.formGroup {
position: relative;
margin-bottom: 15px;
}
.label {
display: inline-block;
margin-bottom: 5px;
max-width: 100%;
font-weight: 700;
}
.input {
display: block;
box-sizing: border-box;
padding: 10px 16px;
width: 100%;
height: 46px;
outline: 0;
border: 1px solid var(--colour-grey);
border-radius: 0;
background-color: #fff;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
color: #616161;
font-size: 18px;
line-height: 1.3333333;
transition: border-color ease-in-out 0.15s,
box-shadow ease-in-out 0.15s,
background-color ease-in-out 0.15s;
}
.input:focus {
border-color: var(--colour-tertiary);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 116, 194, 0.6);
}
.button {
display: block;
box-sizing: border-box;
margin: 0;
padding: 10px 16px;
width: 100%;
outline: 0;
border: 1px solid var(--colour-secondary);
border-radius: 0;
background: var(--colour-primary);;
color: #fff;
text-align: center;
text-decoration: none;
font-size: 18px;
line-height: 1.3333333;
cursor: pointer;
}
.button:hover {
background: var(--colour-secondary);
}
.button:focus {
border-color: var(--colour-tertiary);
box-shadow: 0 0 8px var(--colour-secondary);
}

View File

@@ -0,0 +1,101 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './AdminPerson.css';
class AdminPerson extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
person: PropTypes.shape({
key: PropTypes.string.isRequired,
firstname: PropTypes.string,
lastname: PropTypes.string,
completed: PropTypes.boolean,
email: PropTypes.string,
password: PropTypes.string,
attending: PropTypes.boolean,
}).isRequired,
};
state = {
firstname: this.props.person.firstname,
lastname: this.props.person.lastname,
};
handleFirstnameChange = (event) => {
this.setState({ firstname: event.target.value });
}
handleLastnameChange = (event) => {
this.setState({ lastname: event.target.value });
}
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.state.firstname} {this.state.lastname}</h1>
<form method="post" action="/admin/person_todo">
<div className={s.formGroup}>
<label className={s.label} htmlFor="firstname">
First name:
</label>
<input
className={s.input}
id="firstname"
type="text"
name="firstname"
defaultValue={this.props.person.firstname}
onChange={this.handleFirstnameChange}
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="lastname">
Last name:
</label>
<input
className={s.input}
id="lastname"
type="text"
name="lastname"
defaultValue={this.props.person.lastname}
onChange={this.handleLastnameChange}
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="email">
Email:
</label>
<input
className={s.input}
id="email"
type="text"
name="email"
defaultValue={this.props.person.email}
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="password">
Password:
</label>
<input
className={s.input}
id="password"
type="text"
name="password"
defaultValue={this.props.person.password}
/>
</div>
<div className={s.formGroup}>
<button className={s.button} type="submit">
Save
</button>
</div>
</form>
</div>
</div>
);
}
}
export default withStyles(s)(AdminPerson);

View File

@@ -0,0 +1,38 @@
import React from 'react';
import Layout from '../../components/Layout';
import fetchQL from '../../core/fetchQL';
export default {
path: '/admin/person/:key',
async action({ params }) {
if (!params.key) {
return { redirect: '/admin' };
}
const resp = await fetchQL('{getPersonByKey{key,firstname,lastname,email,completed,password,starter,main,dietary}}', {
key: params.key,
});
let person = await resp.json();
if (person.data && person.data.getPersonByKey && person.data.getPersonByKey.length === 1) {
person = person.data.getPersonByKey[0];
} else {
return { redirect: '/admin' };
}
const AdminPerson = await new Promise((resolve) => {
require.ensure([], (require) => resolve(require('./AdminPerson').default), 'adminPerson');
});
const title = `${person.firstname} ${person.lastname}`;
return {
title,
chunk: 'admin',
component: <Layout><AdminPerson title={title} person={person} /></Layout>,
};
},
};

View File

@@ -0,0 +1,47 @@
* {
line-height: 1.2;
margin: 0;
}
html {
color: #888;
display: table;
font-family: sans-serif;
height: 100%;
text-align: center;
width: 100%;
}
body {
display: table-cell;
vertical-align: middle;
padding: 2em;
}
h1 {
color: #555;
font-size: 2em;
font-weight: 400;
}
p {
margin: 0 auto;
width: 280px;
}
pre {
text-align: left;
margin-top: 2rem;
}
@media only screen and (max-width: 280px) {
body,
p {
width: 95%;
}
h1 {
font-size: 1.5em;
margin: 0 0 0.3em;
}
}

View File

@@ -0,0 +1,32 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './ErrorPage.css';
class ErrorPage extends React.Component {
static propTypes = {
error: PropTypes.object.isRequired,
};
render() {
if (process.env.NODE_ENV !== 'production') {
const { error } = this.props;
return (
<div>
<h1>{error.name}</h1>
<p>{error.message}</p>
<pre>{error.stack}</pre>
</div>
);
}
return (
<div>
<h1>Error</h1>
<p>Sorry, a critical error occurred on this page.</p>
</div>
);
}
}
export { ErrorPage as ErrorPageWithoutStyle };
export default withStyles(s)(ErrorPage);

17
src/routes/error/index.js Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ErrorPage from './ErrorPage';
export default {
path: '/error',
action({ error }) {
return {
title: error.name,
description: error.message,
component: <ErrorPage error={error} />,
status: error.status || 500,
};
},
};

73
src/routes/home/Home.css Normal file
View File

@@ -0,0 +1,73 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.formContainer {
margin: 0 auto;
max-width: 420px;
}
.formGroup {
margin-bottom: 15px;
}
.label {
display: inline-block;
margin-bottom: 5px;
max-width: 100%;
font-weight: 700;
}
.input {
display: block;
box-sizing: border-box;
padding: 10px 16px;
width: 100%;
height: 46px;
outline: 0;
border: 1px solid var(--colour-grey);
border-radius: 0;
background: #fff;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
color: #616161;
font-size: 18px;
line-height: 1.3333333;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
}
.input:focus {
border-color: var(--colour-tertiary);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 116, 194, 0.6);
}
.button {
display: block;
box-sizing: border-box;
margin: 0;
padding: 10px 16px;
width: 100%;
outline: 0;
border: 1px solid var(--colour-secondary);
border-radius: 0;
background: var(--colour-primary);;
color: #fff;
text-align: center;
text-decoration: none;
font-size: 18px;
line-height: 1.3333333;
cursor: pointer;
}
.button:hover {
background: var(--colour-secondary);
}
.button:focus {
border-color: var(--colour-tertiary);
box-shadow: 0 0 8px var(--colour-secondary);
}

35
src/routes/home/Home.js Normal file
View File

@@ -0,0 +1,35 @@
import React from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Home.css';
class Home extends React.Component {
render() {
return (
<div className={s.root}>
<div className={s.formContainer}>
<form method="post" action="/rsvp">
<div className={s.formGroup}>
<label className={s.label} htmlFor="code">
Enter your RSVP code:
</label>
<input
className={s.input}
id="code"
type="text"
name="code"
autoFocus
/>
</div>
<div className={s.formGroup}>
<button className={s.button} type="submit">
RSVP
</button>
</div>
</form>
</div>
</div>
);
}
}
export default withStyles(s)(Home);

15
src/routes/home/index.js Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import Home from './Home';
import Layout from '../../components/Layout';
export default {
path: '/',
action() {
return {
title: 'Welcome',
component: <Layout><Home /></Layout>,
};
},
};

32
src/routes/index.js Normal file
View File

@@ -0,0 +1,32 @@
/* eslint-disable global-require */
// The top-level (parent) route
export default {
path: '/',
// Keep in mind, routes are evaluated in order
children: [
require('./home').default,
require('./rsvp').default,
require('./login').default,
require('./admin').default,
require('./adminPeople').default,
require('./adminPerson').default,
// Wildcard routes, e.g. { path: '*', ... } (must go last)
require('./notFound').default,
],
async action({ next }) {
// Execute each child route until one of them return the result
const route = await next();
// Provide default values for title, description etc.
route.title = `${route.title || 'Untitled Page'} - Daniel and Hana's Wedding`;
route.description = route.description || '';
return route;
},
};

View File

@@ -0,0 +1,51 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: 380px;
}
.lead {
font-size: 1.25em;
}
.formGroup {
margin-bottom: 15px;
}
.facebook {
display: block;
box-sizing: border-box;
margin: 0;
padding: 10px 16px;
width: 100%;
outline: 0;
border: 1px solid #3b5998;
border-radius: 0;
background: #3b5998;
color: #fff;
text-align: center;
text-decoration: none;
font-size: 18px;
line-height: 1.3333333;
cursor: pointer;
}
.facebook:hover {
background: #2d4373;
}
.icon {
display: inline-block;
margin: -2px 12px -2px 0;
width: 20px;
height: 20px;
vertical-align: middle;
fill: currentColor;
}

38
src/routes/login/Login.js Normal file
View File

@@ -0,0 +1,38 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Login.css';
class Login extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
};
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.props.title}</h1>
<p className={s.lead}>Log in with your username or company email address.</p>
<div className={s.formGroup}>
<a className={s.facebook} href="/login/facebook">
<svg
className={s.icon}
width="30"
height="30"
viewBox="0 0 30 30"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 16l1-5h-5V7c0-1.544.784-2 3-2h2V0h-4c-4.072 0-7 2.435-7 7v4H7v5h5v14h6V16h4z"
/>
</svg>
<span>Log in with Facebook</span>
</a>
</div>
</div>
</div>
);
}
}
export default withStyles(s)(Login);

17
src/routes/login/index.js Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import Layout from '../../components/Layout';
import Login from './Login';
const title = 'Log In';
export default {
path: '/login',
async action() {
return {
title,
component: <Layout><Login title={title} /></Layout>,
};
},
};

View File

@@ -0,0 +1,12 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}

View File

@@ -0,0 +1,22 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './NotFound.css';
class NotFound extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
};
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.props.title}</h1>
<p>Sorry, the page you were trying to view does not exist.</p>
</div>
</div>
);
}
}
export default withStyles(s)(NotFound);

View File

@@ -0,0 +1,19 @@
import React from 'react';
import Layout from '../../components/Layout';
import NotFound from './NotFound';
const title = 'Page Not Found';
export default {
path: '*',
action() {
return {
title,
component: <Layout><NotFound title={title} /></Layout>,
status: 404,
};
},
};

24
src/routes/rsvp/Rsvp.css Normal file
View File

@@ -0,0 +1,24 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}
.flexContainer {
display: flex;
flex-direction: row;
flex-flow: row wrap;
}
@media (--sm-viewport) {
.flexContainer {
flex-flow: row nowrap;
}
}

33
src/routes/rsvp/Rsvp.js Normal file
View File

@@ -0,0 +1,33 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Rsvp.css';
import RsvpCard from '../../components/RsvpCard';
class Rsvp extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
people: PropTypes.arrayOf(PropTypes.shape({
firstname: PropTypes.string,
lastname: PropTypes.string,
email: PropTypes.string,
dietary: PropTypes.string,
starter: PropTypes.string,
main: PropTypes.string,
})).isRequired,
};
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.props.title}</h1>
<div className={s.flexContainer}>
{this.props.people.map(person => <RsvpCard key={person.key} person={person} />)}
</div>
</div>
</div>
);
}
}
export default withStyles(s)(Rsvp);

39
src/routes/rsvp/index.js Normal file
View File

@@ -0,0 +1,39 @@
import React from 'react';
import Layout from '../../components/Layout';
import fetchQL from '../../core/fetchQL';
import Rsvp from './Rsvp';
import NotFound from '../notFound/NotFound';
let title = 'RSVP';
export default {
path: '/rsvp/:password',
async action({ params }) {
if (!params.password) {
return { redirect: '/' };
}
const resp = await fetchQL('{getPersonsByPassword{key,firstname,lastname,attending,dietary,starter,main}}', {
password: params.password,
});
const { data } = await resp.json();
const people = data && data.getPersonsByPassword;
if (!people || people.length === 0) {
title = 'RSVP Not Found';
return {
title,
component: <Layout><NotFound title={title} /></Layout>,
status: 404,
};
}
return {
title,
component: <Layout><Rsvp title={title} people={people} /></Layout>,
};
},
};

295
src/server.js Normal file
View File

@@ -0,0 +1,295 @@
import 'babel-polyfill';
import path from 'path';
import express from 'express';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import expressJwt from 'express-jwt';
import expressGraphQL from 'express-graphql';
import jwt from 'jsonwebtoken';
import React from 'react';
import ReactDOM from 'react-dom/server';
import UniversalRouter from 'universal-router';
import PrettyError from 'pretty-error';
import Rollbar from 'rollbar';
import App from './components/App';
import Html from './components/Html';
import { ErrorPageWithoutStyle } from './routes/error/ErrorPage';
import errorPageStyle from './routes/error/ErrorPage.css';
import passport from './core/passport';
import admin from './core/admin';
import models from './data/models';
import schema from './data/schema';
import routes from './routes';
import assets from './assets'; // eslint-disable-line import/no-unresolved
import { port, auth, analytics } from './config';
import Person from './data/models/Person';
const app = express();
//
// Tell any CSS tooling (such as Material UI) to use all vendor prefixes if the
// user agent is not known.
// -----------------------------------------------------------------------------
global.navigator = global.navigator || {};
global.navigator.userAgent = global.navigator.userAgent || 'all';
//
// Register Node.js middleware
// -----------------------------------------------------------------------------
app.use(express.static(path.join(__dirname, 'public')));
app.use(cookieParser());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
//
// Authentication
// -----------------------------------------------------------------------------
app.use(expressJwt({
secret: auth.jwt.secret,
credentialsRequired: false,
getToken: req => req.cookies.id_token,
}));
app.use(passport.initialize());
if (process.env.NODE_ENV !== 'production') {
app.enable('trust proxy');
}
app.get('/login/facebook',
passport.authenticate('facebook', { scope: ['email', 'user_location'], session: false }),
);
app.get('/login/facebook/return',
passport.authenticate('facebook', { failureRedirect: '/login', session: false }),
(req, res) => {
const expiresIn = 60 * 60 * 24 * 180; // 180 days
const token = jwt.sign(req.user, auth.jwt.secret, { expiresIn });
res.cookie('id_token', token, { maxAge: 1000 * expiresIn, httpOnly: true });
res.redirect('/');
},
);
//
// Register API middleware
// -----------------------------------------------------------------------------
app.use('/graphql', expressGraphQL(req => ({
schema,
graphiql: process.env.NODE_ENV !== 'production',
rootValue: { request: req },
pretty: process.env.NODE_ENV !== 'production',
})));
//
// Register RSVP routes
// -----------------------------------------------------------------------------
//
app.get('/:password', (req, res, next) => {
if (req.params.password.length !== 4) {
// Not a password
next();
} else {
Person.findAll({ where: { password: req.params.password } })
.then(data => {
if (data.length > 0) {
res.redirect(`/rsvp/${req.params.password}`);
} else {
next();
}
}).catch((err) => {
Rollbar.handleError(err);
next(err);
});
}
});
app.post('/rsvp', (req, res) => {
res.redirect(`/rsvp/${req.body.code}`);
});
app.post('/rsvp/save', (req, res) => {
try {
const update = {};
update[req.body.prop] = req.body.value;
Person.update(update, {
where: {
key: req.body.key,
},
}).then(() => {
res.json({
success: true,
prop: req.body.prop,
});
}).catch(err => {
Rollbar.handleError(err);
res.json({
success: false,
err,
});
});
} catch (err) {
Rollbar.handleError(err);
res.json({
success: false,
err,
});
}
});
//
// Register Admin routes
// -----------------------------------------------------------------------------
//
app.get('/login', (req, res, next) => {
try {
if (req.user && admin(req.user.email)) {
res.redirect('/admin');
} else {
next();
}
} catch (err) {
Rollbar.handleError(err);
next(err);
}
});
app.use('/admin*', (req, res, next) => {
try {
if (!req.user || !admin(req.user.email)) {
res.redirect(401, '/login');
} else {
next();
}
} catch (err) {
Rollbar.handleError(err);
next(err);
}
});
app.post('/admin/person', (req, res, next) => {
try {
if (!req.user || !admin(req.user.email)) {
res.redirect(401, '/login');
}
const data = {
firstname: req.body.firstname,
lastname: req.body.lastname,
email: req.body.email,
};
if (req.body.password && req.body.password !== '') {
data.password = req.body.password;
}
Person.create(data);
res.redirect('/admin/people');
} catch (err) {
Rollbar.handleError(err);
next(err);
}
});
app.post('/admin/person/delete', (req, res, next) => {
try {
if (!req.user || !admin(req.user.email)) {
res.redirect(401, '/login');
}
if (req.body.key) {
Person.destroy({
where: { key: req.body.key },
}).then(() => {
res.redirect('/admin/people');
});
}
} catch (err) {
Rollbar.handleError(err);
next(err);
}
});
//
// Register server-side rendering middleware
// -----------------------------------------------------------------------------
app.get('*', async (req, res, next) => {
try {
const css = new Set();
// Global (context) variables that can be easily accessed from any React component
// https://facebook.github.io/react/docs/context.html
const context = {
// Enables critical path CSS rendering
// https://github.com/kriasoft/isomorphic-style-loader
insertCss: (...styles) => {
// eslint-disable-next-line no-underscore-dangle
styles.forEach(style => css.add(style._getCss()));
},
};
const route = await UniversalRouter.resolve(routes, {
path: req.path,
query: req.query,
agent: req.headers['user-agent'],
});
if (route.redirect) {
res.redirect(route.status || 302, route.redirect);
return;
}
const data = { ...route };
data.children = ReactDOM.renderToString(<App context={context}>{route.component}</App>);
data.style = [...css].join('');
data.scripts = [
assets.vendor.js,
assets.client.js,
];
if (assets[route.chunk]) {
data.scripts.push(assets[route.chunk].js);
}
const html = ReactDOM.renderToStaticMarkup(<Html {...data} />);
res.status(route.status || 200);
res.send(`<!doctype html>${html}`);
} catch (err) {
Rollbar.handleError(err);
next(err);
}
});
//
// Error handling
// -----------------------------------------------------------------------------
Rollbar.init(analytics.rollbar.serverToken);
Rollbar.handleUncaughtExceptionsAndRejections(analytics.rollbar.serverToken, {
exitOnUncaughtException: true,
});
app.use(Rollbar.errorHandler(analytics.rollbar.serverToken));
const pe = new PrettyError();
pe.skipNodeFiles();
pe.skipPackage('express');
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
console.log(pe.render(err)); // eslint-disable-line no-console
const html = ReactDOM.renderToStaticMarkup(
<Html
title="Internal Server Error"
description={err.message}
style={errorPageStyle._getCss()} // eslint-disable-line no-underscore-dangle
>
{ReactDOM.renderToString(<ErrorPageWithoutStyle error={err} />)}
</Html>,
);
res.status(err.status || 500);
res.send(`<!doctype html>${html}`);
});
//
// Launch the server
// -----------------------------------------------------------------------------
/* eslint-disable no-console */
models.sync().catch(err => console.error(err.stack)).then(() => {
app.listen(port, () => {
console.log(`The server is running at http://localhost:${port}/`);
});
});
/* eslint-enable no-console */

9
test/.eslintrc Normal file
View File

@@ -0,0 +1,9 @@
{
"env": {
"mocha": true
},
"rules": {
"no-unused-expressions": 0,
"padded-blocks": 0
}
}

25
test/setup.js Normal file
View File

@@ -0,0 +1,25 @@
/**
* React Starter Kit (https://www.reactstarterkit.com/)
*
* Copyright © 2014-2016 Kriasoft, LLC. All rights reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE.txt file in the root directory of this source tree.
*/
/* Configure Mocha test runner, see package.json/scripts/test */
process.env.NODE_ENV = 'test';
function noop() {
return null;
}
require.extensions['.css'] = noop;
require.extensions['.scss'] = noop;
require.extensions['.md'] = noop;
require.extensions['.png'] = noop;
require.extensions['.svg'] = noop;
require.extensions['.jpg'] = noop;
require.extensions['.jpeg'] = noop;
require.extensions['.gif'] = noop;

6
tools/.eslintrc Normal file
View File

@@ -0,0 +1,6 @@
{
"rules": {
"no-console": 0,
"global-require": 0
}
}

48
tools/README.md Normal file
View File

@@ -0,0 +1,48 @@
## Build Automation Tools
##### `yarn start` (`start.js`)
* Cleans up the output `/build` directory (`clean.js`)
* Copies static files to the output folder (`copy.js`)
* Launches [Webpack](https://webpack.github.io/) compiler in a watch mode (via [webpack-middleware](https://github.com/kriasoft/webpack-middleware))
* Launches Node.js server from the compiled output folder (`runServer.js`)
* Launches [Browsersync](https://browsersync.io/),
[Hot Module Replacement](https://webpack.github.io/docs/hot-module-replacement), and
[React Hot Loader](https://github.com/gaearon/react-hot-loader)
##### `yarn run build` (`build.js`)
* Cleans up the output `/build` folder (`clean.js`)
* Copies static files to the output folder (`copy.js`)
* Creates application bundles with Webpack (`bundle.js`, `webpack.config.js`)
##### `yarn run deploy` (`deploy.js`)
* Builds the project from source files (`build.js`)
* Pushes the contents of the `/build` folder to a remote server with Git
##### Options
Flag | Description
----------- | --------------------------------------------------
`--release` | Minimizes and optimizes the compiled output
`--verbose` | Prints detailed information to the console
`--static` | Renders [specified routes](./render.js#L15) as static html files
For example:
```sh
$ yarn run build -- --release --verbose # Build the app in production mode
```
or
```sh
$ yarn start -- --release # Launch dev server in production mode
```
#### Misc
* `webpack.config.js` - Webpack configuration for both client-side and server-side bundles
* `run.js` - Helps to launch other scripts with `babel-node` (e.g. `babel-node tools/run build`)
* `.eslintrc` - ESLint overrides for built automation scripts

27
tools/build.js Normal file
View File

@@ -0,0 +1,27 @@
import cp from 'child_process';
import run from './run';
import clean from './clean';
import copy from './copy';
import bundle from './bundle';
import render from './render';
import pkg from '../package.json';
/**
* Compiles the project from source files into a distributable
* format and copies it to the output (build) folder.
*/
async function build() {
await run(clean);
await run(copy);
await run(bundle);
if (process.argv.includes('--static')) {
await run(render);
}
if (process.argv.includes('--docker')) {
cp.spawnSync('docker', ['build', '-t', pkg.name, '.'], { stdio: 'inherit' });
}
}
export default build;

20
tools/bundle.js Normal file
View File

@@ -0,0 +1,20 @@
import webpack from 'webpack';
import webpackConfig from './webpack.config';
/**
* Creates application bundles from the source files.
*/
function bundle() {
return new Promise((resolve, reject) => {
webpack(webpackConfig).run((err, stats) => {
if (err) {
return reject(err);
}
console.log(stats.toString(webpackConfig[0].stats));
return resolve();
});
});
}
export default bundle;

22
tools/clean.js Normal file
View File

@@ -0,0 +1,22 @@
import { cleanDir } from './lib/fs';
/**
* Cleans up the output (build) directory.
*/
function clean() {
return Promise.all([
cleanDir('build/*', {
nosort: true,
dot: true,
ignore: ['build/.git', 'build/public'],
}),
cleanDir('build/public/*', {
nosort: true,
dot: true,
ignore: ['build/public/.git'],
}),
]);
}
export default clean;

57
tools/copy.js Normal file
View File

@@ -0,0 +1,57 @@
import path from 'path';
import chokidar from 'chokidar';
import { writeFile, copyFile, makeDir, copyDir, cleanDir } from './lib/fs';
import pkg from '../package.json';
import { format } from './run';
/**
* Copies static files such as robots.txt, favicon.ico to the
* output (build) folder.
*/
async function copy() {
await makeDir('build');
await Promise.all([
writeFile('build/package.json', JSON.stringify({
private: true,
engines: pkg.engines,
dependencies: pkg.dependencies,
scripts: {
start: 'node server.js',
},
}, null, 2)),
copyFile('LICENSE.txt', 'build/LICENSE.txt'),
copyDir('src/content', 'build/content'),
copyDir('public', 'build/public'),
]);
if (process.argv.includes('--watch')) {
const watcher = chokidar.watch([
'src/content/**/*',
'public/**/*',
], { ignoreInitial: true });
watcher.on('all', async (event, filePath) => {
const start = new Date();
const src = path.relative('./', filePath);
const dist = path.join('build/', src.startsWith('src') ? path.relative('src', src) : src);
switch (event) {
case 'add':
case 'change':
await makeDir(path.dirname(dist));
await copyFile(filePath, dist);
break;
case 'unlink':
case 'unlinkDir':
cleanDir(dist, { nosort: true, dot: true });
break;
default:
return;
}
const end = new Date();
const time = end.getTime() - start.getTime();
console.log(`[${format(end)}] ${event} '${dist}' after ${time} ms`);
});
}
}
export default copy;

View File

@@ -0,0 +1,51 @@
import GitRepo from 'git-repository';
import fetch from 'node-fetch';
import run from './run';
// For more information visit http://gitolite.com/deploy.html
function getRemote(slot) {
return {
name: slot || 'production',
url: `https://example${slot ? `-${slot}` : ''}.scm.azurewebsites.net:443/example.git`,
branch: 'master',
website: `http://example${slot ? `-${slot}` : ''}.azurewebsites.net`,
};
}
/**
* Deploy the contents of the `/build` folder to a remote
* server via Git. Example: `yarn run deploy -- --production`
*/
async function deployToAzureWebApps() {
// By default deploy to the staging deployment slot
const remote = getRemote(process.argv.includes('--production') ? null : 'staging');
// Initialize a new Git repository inside the `/build` folder
// if it doesn't exist yet
const repo = await GitRepo.open('build', { init: true });
await repo.setRemote(remote.name, remote.url);
// Fetch the remote repository if it exists
const isRefExists = await repo.hasRef(remote.url, remote.branch);
if (isRefExists) {
await repo.fetch(remote.name);
await repo.reset(`${remote.name}/${remote.branch}`, { hard: true });
await repo.clean({ force: true });
}
// Build the project in RELEASE mode which
// generates optimized and minimized bundles
process.argv.push('--release');
await run(require('./build'));
// Push the contents of the build folder to the remote server via Git
await repo.add('--all .');
await repo.commit(`Update ${new Date().toISOString()}`);
await repo.push(remote.name, `master:${remote.branch}`);
// Check if the site was successfully deployed
const response = await fetch(remote.website);
console.log(`${remote.website} -> ${response.status}`);
}
export default deployToAzureWebApps;

View File

@@ -0,0 +1,37 @@
import GitRepo from 'git-repository';
import run from './run';
import build from './build';
const remote = {
name: 'github',
url: 'https://github.com/{user}/{repo}.git',
branch: 'gh-pages',
};
/**
* Deploy the contents of the `/build/public` folder to GitHub Pages.
*/
async function deployToGitHubPages() {
// Initialize a new Git repository inside the `/build` folder
// if it doesn't exist yet
const repo = await GitRepo.open('build/public', { init: true });
await repo.setRemote(remote.name, remote.url);
const isRefExists = await repo.hasRef(remote.url, remote.branch);
if (isRefExists) {
await repo.fetch(remote.name);
await repo.reset(`${remote.name}/${remote.branch}`, { hard: true });
await repo.clean({ force: true });
}
// Build the project in RELEASE mode which
// generates optimized and minimized bundles
process.argv.push('--static', '--release');
await run(build);
// Push the contents of the build folder to the remote server via Git
await repo.add('--all .');
await repo.commit(`Update ${new Date().toISOString()}`);
await repo.push(remote.name, `master:${remote.branch}`);
}
export default deployToGitHubPages;

69
tools/lib/fs.js Normal file
View File

@@ -0,0 +1,69 @@
/**
* React Starter Kit (https://www.reactstarterkit.com/)
*
* Copyright © 2014-2016 Kriasoft, LLC. All rights reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE.txt file in the root directory of this source tree.
*/
import fs from 'fs';
import path from 'path';
import glob from 'glob';
import mkdirp from 'mkdirp';
import rimraf from 'rimraf';
export const readFile = (file) => new Promise((resolve, reject) => {
fs.readFile(file, 'utf8', (err, data) => (err ? reject(err) : resolve(data)));
});
export const writeFile = (file, contents) => new Promise((resolve, reject) => {
fs.writeFile(file, contents, 'utf8', err => (err ? reject(err) : resolve()));
});
export const copyFile = (source, target) => new Promise((resolve, reject) => {
let cbCalled = false;
function done(err) {
if (!cbCalled) {
cbCalled = true;
if (err) {
reject(err);
} else {
resolve();
}
}
}
const rd = fs.createReadStream(source);
rd.on('error', err => done(err));
const wr = fs.createWriteStream(target);
wr.on('error', err => done(err));
wr.on('close', err => done(err));
rd.pipe(wr);
});
export const readDir = (pattern, options) => new Promise((resolve, reject) =>
glob(pattern, options, (err, result) => (err ? reject(err) : resolve(result))),
);
export const makeDir = (name) => new Promise((resolve, reject) => {
mkdirp(name, err => (err ? reject(err) : resolve()));
});
export const copyDir = async (source, target) => {
const dirs = await readDir('**/*.*', {
cwd: source,
nosort: true,
dot: true,
});
await Promise.all(dirs.map(async dir => {
const from = path.resolve(source, dir);
const to = path.resolve(target, dir);
await makeDir(path.dirname(to));
await copyFile(from, to);
}));
};
export const cleanDir = (pattern, options) => new Promise((resolve, reject) =>
rimraf(pattern, { glob: options }, (err, result) => (err ? reject(err) : resolve(result))),
);

View File

@@ -0,0 +1,25 @@
/**
* React Starter Kit (https://www.reactstarterkit.com/)
*
* Copyright © 2014-2016 Kriasoft, LLC. All rights reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE.txt file in the root directory of this source tree.
*/
const MarkdownIt = require('markdown-it');
const fm = require('front-matter');
module.exports = function markdownLoader(source) {
this.cacheable();
const md = new MarkdownIt({
html: true,
linkify: true,
});
const frontmatter = fm(source);
frontmatter.attributes.html = md.render(frontmatter.body);
return `module.exports = ${JSON.stringify(frontmatter.attributes)};`;
};

53
tools/render.js Normal file
View File

@@ -0,0 +1,53 @@
import path from 'path';
import fetch from 'node-fetch';
import { writeFile, makeDir } from './lib/fs';
import runServer from './runServer';
// Enter your paths here which you want to render as static
// Example:
// const routes = [
// '/', // => build/public/index.html
// '/page', // => build/public/page.html
// '/page/', // => build/public/page/index.html
// '/page/name', // => build/public/page/name.html
// '/page/name/', // => build/public/page/name/index.html
// ];
const routes = [
'/',
'/contact',
'/login',
'/register',
'/about',
'/privacy',
'/404', // https://help.github.com/articles/creating-a-custom-404-page-for-your-github-pages-site/
];
async function render() {
const server = await runServer();
// add dynamic routes
// const products = await fetch(`http://${server.host}/api/products`).then(res => res.json());
// products.forEach(product => routes.push(
// `/product/${product.uri}`,
// `/product/${product.uri}/specs`
// ));
await Promise.all(routes.map(async (route, index) => {
const url = `http://${server.host}${route}`;
const fileName = route.endsWith('/') ? 'index.html' : `${path.basename(route, '.html')}.html`;
const dirName = path.join('build/public', route.endsWith('/') ? route : path.dirname(route));
const dist = path.join(dirName, fileName);
const timeStart = new Date();
const response = await fetch(url);
const timeEnd = new Date();
const text = await response.text();
await makeDir(dirName);
await writeFile(dist, text);
const time = timeEnd.getTime() - timeStart.getTime();
console.log(`#${index + 1} ${dist} => ${response.status} ${response.statusText} (${time} ms)`);
}));
server.kill('SIGTERM');
}
export default render;

27
tools/run.js Normal file
View File

@@ -0,0 +1,27 @@
export function format(time) {
return time.toTimeString().replace(/.*(\d{2}:\d{2}:\d{2}).*/, '$1');
}
function run(fn, options) {
const task = typeof fn.default === 'undefined' ? fn : fn.default;
const start = new Date();
console.log(
`[${format(start)}] Starting '${task.name}${options ? ` (${options})` : ''}'...`,
);
return task(options).then(resolution => {
const end = new Date();
const time = end.getTime() - start.getTime();
console.log(
`[${format(end)}] Finished '${task.name}${options ? ` (${options})` : ''}' after ${time} ms`,
);
return resolution;
});
}
if (require.main === module && process.argv.length > 2) {
delete require.cache[__filename]; // eslint-disable-line no-underscore-dangle
const module = require(`./${process.argv[2]}.js`).default; // eslint-disable-line import/no-dynamic-require
run(module).catch(err => { console.error(err.stack); process.exit(1); });
}
export default run;

63
tools/runServer.js Normal file
View File

@@ -0,0 +1,63 @@
import path from 'path';
import cp from 'child_process';
import webpackConfig from './webpack.config';
// Should match the text string used in `src/server.js/server.listen(...)`
const RUNNING_REGEXP = /The server is running at http:\/\/(.*?)\//;
let server;
const { output } = webpackConfig.find(x => x.target === 'node');
const serverPath = path.join(output.path, output.filename);
// Launch or restart the Node.js server
function runServer() {
return new Promise(resolve => {
let pending = true;
function onStdOut(data) {
const time = new Date().toTimeString();
const match = data.toString('utf8').match(RUNNING_REGEXP);
process.stdout.write(time.replace(/.*(\d{2}:\d{2}:\d{2}).*/, '[$1] '));
process.stdout.write(data);
if (match) {
server.host = match[1];
server.stdout.removeListener('data', onStdOut);
server.stdout.on('data', x => process.stdout.write(x));
pending = false;
resolve(server);
}
}
if (server) {
server.kill('SIGTERM');
}
server = cp.spawn('node', [serverPath], {
env: Object.assign({ NODE_ENV: 'development' }, process.env),
silent: false,
});
if (pending) {
server.once('exit', (code, signal) => {
if (pending) {
throw new Error(`Server terminated unexpectedly with code: ${code} signal: ${signal}`);
}
});
}
server.stdout.on('data', onStdOut);
server.stderr.on('data', x => process.stderr.write(x));
return server;
});
}
process.on('exit', () => {
if (server) {
server.kill('SIGTERM');
}
});
export default runServer;

71
tools/start.js Normal file
View File

@@ -0,0 +1,71 @@
import browserSync from 'browser-sync';
import webpack from 'webpack';
import webpackMiddleware from 'webpack-middleware';
import webpackHotMiddleware from 'webpack-hot-middleware';
import run from './run';
import runServer from './runServer';
import webpackConfig from './webpack.config';
import clean from './clean';
import copy from './copy';
process.argv.push('--watch');
const [config] = webpackConfig;
/**
* Launches a development web server with "live reload" functionality -
* synchronizing URLs, interactions and code changes across multiple devices.
*/
async function start() {
await run(clean);
await run(copy);
await new Promise(resolve => {
// Hot Module Replacement (HMR) + React Hot Reload
if (config.debug) {
config.entry.client = ['react-hot-loader/patch', 'webpack-hot-middleware/client']
.concat(config.entry.client);
config.output.filename = config.output.filename.replace('[chunkhash', '[hash');
config.output.chunkFilename = config.output.chunkFilename.replace('[chunkhash', '[hash');
config.module.loaders.find(x => x.loader === 'babel-loader')
.query.plugins.unshift('react-hot-loader/babel');
config.plugins.push(new webpack.HotModuleReplacementPlugin());
config.plugins.push(new webpack.NoErrorsPlugin());
}
const bundler = webpack(webpackConfig);
const wpMiddleware = webpackMiddleware(bundler, {
// IMPORTANT: webpack middleware can't access config,
// so we should provide publicPath by ourselves
publicPath: config.output.publicPath,
// Pretty colored output
stats: config.stats,
// For other settings see
// https://webpack.github.io/docs/webpack-dev-middleware
});
const hotMiddleware = webpackHotMiddleware(bundler.compilers[0]);
let handleBundleComplete = async () => {
handleBundleComplete = stats => !stats.stats[1].compilation.errors.length && runServer();
const server = await runServer();
const bs = browserSync.create();
bs.init({
...(config.debug ? {} : { notify: false, ui: false }),
proxy: {
target: server.host,
middleware: [wpMiddleware, hotMiddleware],
proxyOptions: {
xfwd: true,
},
},
}, resolve);
};
bundler.plugin('done', stats => handleBundleComplete(stats));
});
}
export default start;

358
tools/webpack.config.js Normal file
View File

@@ -0,0 +1,358 @@
import path from 'path';
import webpack from 'webpack';
import extend from 'extend';
import AssetsPlugin from 'assets-webpack-plugin';
const isDebug = !process.argv.includes('--release');
const isVerbose = process.argv.includes('--verbose');
//
// Common configuration chunk to be used for both
// client-side (client.js) and server-side (server.js) bundles
// -----------------------------------------------------------------------------
const config = {
context: path.resolve(__dirname, '../src'),
output: {
path: path.resolve(__dirname, '../build/public/assets'),
publicPath: '/assets/',
sourcePrefix: ' ',
pathinfo: isVerbose,
},
module: {
loaders: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
include: [
path.resolve(__dirname, '../src'),
],
query: {
// https://github.com/babel/babel-loader#options
cacheDirectory: isDebug,
// https://babeljs.io/docs/usage/options/
babelrc: false,
presets: [
// Latest stable ECMAScript features
// https://github.com/babel/babel/tree/master/packages/babel-preset-latest
'latest',
// Experimental ECMAScript proposals
// https://github.com/babel/babel/tree/master/packages/babel-preset-stage-0
'stage-0',
// JSX, Flow
// https://github.com/babel/babel/tree/master/packages/babel-preset-react
'react',
...isDebug ? [] : [
// Optimize React code for the production build
// https://github.com/thejameskyle/babel-react-optimize
'react-optimize',
],
],
plugins: [
// Externalise references to helpers and builtins,
// automatically polyfilling your code without polluting globals.
// https://github.com/babel/babel/tree/master/packages/babel-plugin-transform-runtime
'transform-runtime',
...!isDebug ? [] : [
// Adds component stack to warning messages
// https://github.com/babel/babel/tree/master/packages/babel-plugin-transform-react-jsx-source
'transform-react-jsx-source',
// Adds __self attribute to JSX which React will use for some warnings
// https://github.com/babel/babel/tree/master/packages/babel-plugin-transform-react-jsx-self
'transform-react-jsx-self',
],
],
},
},
{
test: /\.js$/,
loader: 'babel',
include: path.resolve(__dirname, './node_modules/react-icons/md'),
query: {
presets: ['latest', 'react'],
},
},
{
test: /\.css/,
loaders: [
'isomorphic-style-loader',
`css-loader?${JSON.stringify({
// CSS Loader https://github.com/webpack/css-loader
importLoaders: 1,
sourceMap: isDebug,
// CSS Modules https://github.com/css-modules/css-modules
modules: true,
localIdentName: isDebug ? '[name]-[local]-[hash:base64:5]' : '[hash:base64:5]',
// CSS Nano http://cssnano.co/options/
minimize: !isDebug,
discardComments: { removeAll: true },
})}`,
'postcss-loader?pack=default',
],
},
{
test: /\.md$/,
loader: path.resolve(__dirname, './lib/markdown-loader.js'),
},
{
test: /\.json$/,
loader: 'json-loader',
},
{
test: /\.txt$/,
loader: 'raw-loader',
},
{
test: /\.(ico|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)(\?.*)?$/,
loader: 'file-loader',
query: {
name: isDebug ? '[path][name].[ext]?[hash:8]' : '[hash:8].[ext]',
},
},
{
test: /\.(mp4|webm|wav|mp3|m4a|aac|oga)(\?.*)?$/,
loader: 'url-loader',
query: {
name: isDebug ? '[path][name].[ext]?[hash:8]' : '[hash:8].[ext]',
limit: 10000,
},
},
],
},
resolve: {
root: path.resolve(__dirname, '../src'),
modulesDirectories: ['node_modules'],
extensions: ['', '.webpack.js', '.web.js', '.js', '.jsx', '.json'],
},
// Don't attempt to continue if there are any errors.
bail: !isDebug,
cache: isDebug,
debug: isDebug,
stats: {
colors: true,
reasons: isDebug,
hash: isVerbose,
version: isVerbose,
timings: true,
chunks: isVerbose,
chunkModules: isVerbose,
cached: isVerbose,
cachedAssets: isVerbose,
},
// The list of plugins for PostCSS
// https://github.com/postcss/postcss
postcss(bundler) {
return {
default: [
// Transfer @import rule by inlining content, e.g. @import 'normalize.css'
// https://github.com/jonathantneal/postcss-partial-import
require('postcss-partial-import')({ addDependencyTo: bundler }),
// Allow you to fix url() according to postcss to and/or from options
// https://github.com/postcss/postcss-url
require('postcss-url')(),
// W3C variables, e.g. :root { --color: red; } div { background: var(--color); }
// https://github.com/postcss/postcss-custom-properties
require('postcss-custom-properties')(),
// W3C CSS Custom Media Queries, e.g. @custom-media --small-viewport (max-width: 30em);
// https://github.com/postcss/postcss-custom-media
require('postcss-custom-media')(),
// CSS4 Media Queries, e.g. @media screen and (width >= 500px) and (width <= 1200px) { }
// https://github.com/postcss/postcss-media-minmax
require('postcss-media-minmax')(),
// W3C CSS Custom Selectors, e.g. @custom-selector :--heading h1, h2, h3, h4, h5, h6;
// https://github.com/postcss/postcss-custom-selectors
require('postcss-custom-selectors')(),
// W3C calc() function, e.g. div { height: calc(100px - 2em); }
// https://github.com/postcss/postcss-calc
require('postcss-calc')(),
// Allows you to nest one style rule inside another
// https://github.com/jonathantneal/postcss-nesting
require('postcss-nesting')(),
// Unwraps nested rules like how Sass does it
// https://github.com/postcss/postcss-nested
require('postcss-nested')(),
// W3C color() function, e.g. div { background: color(red alpha(90%)); }
// https://github.com/postcss/postcss-color-function
require('postcss-color-function')(),
// Convert CSS shorthand filters to SVG equivalent, e.g. .blur { filter: blur(4px); }
// https://github.com/iamvdo/pleeease-filters
require('pleeease-filters')(),
// Generate pixel fallback for "rem" units, e.g. div { margin: 2.5rem 2px 3em 100%; }
// https://github.com/robwierzbowski/node-pixrem
require('pixrem')(),
// W3C CSS Level4 :matches() pseudo class, e.g. p:matches(:first-child, .special) { }
// https://github.com/postcss/postcss-selector-matches
require('postcss-selector-matches')(),
// Transforms :not() W3C CSS Level 4 pseudo class to :not() CSS Level 3 selectors
// https://github.com/postcss/postcss-selector-not
require('postcss-selector-not')(),
// Postcss flexbox bug fixer
// https://github.com/luisrudge/postcss-flexbugs-fixes
require('postcss-flexbugs-fixes')(),
// Add vendor prefixes to CSS rules using values from caniuse.com
// https://github.com/postcss/autoprefixer
require('autoprefixer')({
browsers: [
'>1%',
'last 4 versions',
'Firefox ESR',
'not ie < 9', // React doesn't support IE8 anyway
],
}),
],
};
},
};
//
// Configuration for the client-side bundle (client.js)
// -----------------------------------------------------------------------------
const clientConfig = extend(true, {}, config, {
entry: {
client: './client.js',
},
output: {
filename: isDebug ? '[name].js' : '[name].[chunkhash:8].js',
chunkFilename: isDebug ? '[name].chunk.js' : '[name].[chunkhash:8].chunk.js',
},
target: 'web',
plugins: [
// Define free variables
// https://webpack.github.io/docs/list-of-plugins.html#defineplugin
new webpack.DefinePlugin({
'process.env.NODE_ENV': isDebug ? '"development"' : '"production"',
'process.env.BROWSER': true,
__DEV__: isDebug,
}),
// Emit a file with assets paths
// https://github.com/sporto/assets-webpack-plugin#options
new AssetsPlugin({
path: path.resolve(__dirname, '../build'),
filename: 'assets.js',
processOutput: x => `module.exports = ${JSON.stringify(x, null, 2)};`,
}),
// Move modules that occur in multiple entry chunks to a new entry chunk (the commons chunk).
// http://webpack.github.io/docs/list-of-plugins.html#commonschunkplugin
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: module => /node_modules/.test(module.resource),
}),
...isDebug ? [] : [
// Assign the module and chunk ids by occurrence count
// Consistent ordering of modules required if using any hashing ([hash] or [chunkhash])
// https://webpack.github.io/docs/list-of-plugins.html#occurrenceorderplugin
new webpack.optimize.OccurrenceOrderPlugin(true),
// Search for equal or similar files and deduplicate them in the output
// https://webpack.github.io/docs/list-of-plugins.html#dedupeplugin
new webpack.optimize.DedupePlugin(),
// Minimize all JavaScript output of chunks
// https://github.com/mishoo/UglifyJS2#compressor-options
new webpack.optimize.UglifyJsPlugin({
compress: {
screw_ie8: true, // React doesn't support IE8
warnings: isVerbose,
unused: true,
dead_code: true,
},
mangle: {
screw_ie8: true,
},
output: {
comments: false,
screw_ie8: true,
},
}),
],
],
// Choose a developer tool to enhance debugging
// http://webpack.github.io/docs/configuration.html#devtool
devtool: isDebug ? 'cheap-module-source-map' : false,
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
// https://webpack.github.io/docs/configuration.html#node
// https://github.com/webpack/node-libs-browser/tree/master/mock
node: {
fs: 'empty',
net: 'empty',
tls: 'empty',
},
});
//
// Configuration for the server-side bundle (server.js)
// -----------------------------------------------------------------------------
const serverConfig = extend(true, {}, config, {
entry: {
server: './server.js',
},
output: {
filename: '../../server.js',
libraryTarget: 'commonjs2',
},
target: 'node',
externals: [
/^\.\/assets$/,
(context, request, callback) => {
const isExternal =
request.match(/^[@a-z][a-z/.\-0-9]*$/i) &&
!request.match(/\.(css|less|scss|sss)$/i);
callback(null, Boolean(isExternal));
},
],
plugins: [
// Define free variables
// https://webpack.github.io/docs/list-of-plugins.html#defineplugin
new webpack.DefinePlugin({
'process.env.NODE_ENV': isDebug ? '"development"' : '"production"',
'process.env.BROWSER': false,
__DEV__: isDebug,
}),
// Do not create separate chunks of the server bundle
// https://webpack.github.io/docs/list-of-plugins.html#limitchunkcountplugin
new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
// Adds a banner to the top of each generated chunk
// https://webpack.github.io/docs/list-of-plugins.html#bannerplugin
new webpack.BannerPlugin('require("source-map-support").install();',
{ raw: true, entryOnly: false }),
],
node: {
console: false,
global: false,
process: false,
Buffer: false,
__filename: false,
__dirname: false,
},
devtool: isDebug ? 'cheap-module-source-map' : 'source-map',
});
export default [clientConfig, serverConfig];

6992
yarn.lock Normal file

File diff suppressed because it is too large Load Diff