diff --git a/assets/less/public.less b/assets/less/public.less
index bc30409..9a5623e 100644
--- a/assets/less/public.less
+++ b/assets/less/public.less
@@ -1,6 +1,11 @@
+@import "themes/form/foundation";
+@import "themes/form/labels/linus";
+@import "themes/form/validation/linus";
+
h1 {
text-align: center;
}
+
.timer {
display: flex;
flex-direction: column;
@@ -10,3 +15,25 @@ h1 {
font-weight: bold;
}
}
+
+.modal {
+ padding: 40px;
+ border: 1px solid black;
+ h1:first-child {
+ margin-top: 0;
+ }
+}
+.container {
+ display: flex;
+}
+
+.login-form {
+ z-index: 9999;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ background: white;
+ transform: translateX(-50%) translateY(-50%);
+}
+
+@import "variables";
diff --git a/assets/less/variables.less b/assets/less/variables.less
new file mode 100644
index 0000000..1348a31
--- /dev/null
+++ b/assets/less/variables.less
@@ -0,0 +1,45 @@
+@base-font-size: 16px;
+
+@default-float: left;
+@opposite-direction: right;
+// @text-direction: left;
+
+
+@base-color: #671637;
+@color-1: #00002d;
+@color-2: #0066c6;
+@color-3: #0066c6;
+@color-message: #333;
+@color-error: @base-color;
+@color-success: @color-1;
+
+
+// This needs to be a px value since rem and em-calc use it. It is translated
+// to a percante unit for html and body font-sizing like so: strip-unit(@base-font-size) / 16 * 100%;
+@base-line-height: 1.6;
+@base-line-height-computed: @base-line-height * 1rem;
+
+@body-font-color: #fff;
+@body-font-family: 'Open Sans', sans-serif;
+@body-font-weight: 300;
+@body-font-style: normal;
+@body-bg: @color-1;
+
+@font-smoothing: subpixel-antialiased;
+
+@input-padding: 14px 12px;
+@input-font-weight: 300;
+
+// Colors
+// @form-valid-color: @base-color;
+// @form-invalid-color: #ffae00;
+@form-valid-color: @color-success;
+@form-invalid-color: @color-error;
+@form-icon-width: 20px;
+@form-icon-height: 20px;
+
+
+//
+// Custom
+//
+
diff --git a/client/actions/ui.js b/client/actions/ui.js
new file mode 100644
index 0000000..37f0517
--- /dev/null
+++ b/client/actions/ui.js
@@ -0,0 +1,33 @@
+export const SHOW_LOGIN_MODAL = 'SHOW_LOGIN_MODAL';
+
+export function showLoginModal() {
+ return {
+ type: SHOW_LOGIN_MODAL,
+ };
+}
+
+export const HIDE_LOGIN_MODAL = 'HIDE_LOGIN_MODAL';
+
+export function hideLoginModal() {
+ return {
+ type: HIDE_LOGIN_MODAL,
+ };
+}
+
+export const SHOW_FLAG_FORM = 'SHOW_FLAG_FORM';
+
+export function showFlagForm(id) {
+ return {
+ type: SHOW_FLAG_FORM,
+ id: id,
+ }
+}
+
+export const HIDE_FLAG_FORM = 'HIDE_FLAG_FORM';
+
+export function hideFlagForm(id) {
+ return {
+ type: HIDE_FLAG_FORM,
+ id: id,
+ }
+}
diff --git a/client/actions/user.js b/client/actions/user.js
new file mode 100644
index 0000000..bdea5b9
--- /dev/null
+++ b/client/actions/user.js
@@ -0,0 +1,45 @@
+import { hideLoginModal } from './ui';
+
+export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
+
+export const LOGIN_FAILURE = 'LOGIN_FAILURE';
+
+export function login(credentials) {
+ return (dispatch) => {
+ fetch('/auth/local', {
+ method: 'POST',
+ body: JSON.stringify(credentials),
+ credentials: 'same-origin',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ })
+ .then((res) => res.json())
+ .then((json) => {
+ dispatch(hideLoginModal());
+ dispatch({
+ type: LOGIN_SUCCESS,
+ payload: json,
+ });
+ })
+ .catch((err) => dispatch({
+ type: LOGIN_FAILURE,
+ err,
+ }));
+ };
+}
+
+export const LOGOUT = 'LOGOUT';
+
+export function logout() {
+ return (dispatch) => {
+ fetch('/auth/logout', {
+ credentials: 'same-origin',
+ })
+ .then(() => dispatch({
+ type: LOGOUT,
+ }))
+ .catch((err) => console.error('error logging out', err));
+ };
+}
diff --git a/client/app.jsx b/client/app.jsx
index a8e237f..427942f 100644
--- a/client/app.jsx
+++ b/client/app.jsx
@@ -12,4 +12,4 @@ if (Notification.permission !== 'granted') {
render((
-), null, document.getElementById('app'));
+), document.body);
diff --git a/client/components/Form.jsx b/client/components/Form.jsx
new file mode 100644
index 0000000..0a5973b
--- /dev/null
+++ b/client/components/Form.jsx
@@ -0,0 +1,67 @@
+import { h, Component } from 'preact';
+
+// modules > lodas
+import set from 'set-value';
+import { forEach, bindAll } from 'lowline';
+
+// import Input from '../components/Input.jsx'
+
+export default class Form extends Component {
+ constructor(props) {
+ super(props);
+
+ bindAll(this, ['register', 'validate']);
+
+ this.inputs = {};
+ }
+
+ toJSON() {
+ const attrs = {};
+
+ // eslint-disable-next-line
+ for (let name in this.inputs) {
+ const input = this.inputs[name];
+
+ const value = input.getValue();
+
+ if (value) set(attrs, name, value);
+ }
+
+ return attrs;
+ }
+
+ validate() {
+ let errorCount = 0;
+
+ // eslint-disable-next-line
+ for (let name in this.inputs) {
+ const input = this.inputs[name];
+
+ if (!input.isValid()) {
+ if (errorCount === 0) {
+ input.focus();
+ }
+
+ errorCount += 1;
+ }
+ }
+
+ return !errorCount;
+ }
+
+ register(name) {
+ return (input) => {
+ if (input) {
+ input.validation = this.validation[input.props.name];
+
+ this.inputs[name] = input;
+ } else {
+ delete this.inputs[name];
+ }
+ };
+ }
+
+ reset() {
+ forEach(this.inputs, (input) => input.reset());
+ }
+}
diff --git a/client/components/FormElement.jsx b/client/components/FormElement.jsx
new file mode 100644
index 0000000..fd5594e
--- /dev/null
+++ b/client/components/FormElement.jsx
@@ -0,0 +1,98 @@
+import { h, Component } from 'preact';
+
+import { bindAll, startCase } from 'lowline';
+
+export default class Input extends Component {
+ constructor(props) {
+ super(props);
+
+ bindAll(this, ['reset', 'validate', 'onChange', 'onBlur', 'onFocus']);
+
+ this.state = {
+ value: this.props.value,
+ };
+ }
+
+
+ onChange(e) {
+ e.preventDefault();
+
+ if (document.activeElement !== e.target && !e.target.value) return;
+
+ this.setValue(e.target.value);
+ }
+
+ onBlur(e) {
+ this.setState({
+ focus: false,
+ });
+
+ this.setValue(e.target.value);
+ }
+
+ onFocus() {
+ this.setState({
+ focus: true,
+ touched: true,
+ });
+ }
+
+ focus() {
+ this.input.focus();
+ }
+
+ isValid() {
+ this.setValue(this.input.value);
+
+ return !this.state.error;
+ }
+
+ getValue() {
+ return this.state.value;
+ }
+
+ reset() {
+ this.setState({
+ value: this.props.value,
+ });
+ }
+
+ setValue(value) {
+ value = value || undefined;
+
+ const result = this.validate(value);
+
+ this.setState({
+ error: result !== true ? typeof result === 'string' && result || 'Error!' : false,
+ value,
+ });
+ }
+
+ validate(value) {
+ const validation = this.validation;
+
+ if (!validation) {
+ return true;
+ }
+
+ if (validation.required) {
+ if (value == null || value === '') {
+ return typeof validation.required === 'string' ? validation.required : 'Required';
+ }
+ } else if (value == null || value === '') {
+ return true;
+ }
+
+ if (!validation.tests) {
+ return true;
+ }
+
+ for (let i = 0; i < validation.tests.length; i += 1) {
+ const [fnc, msg] = validation.tests[i];
+
+ if (!fnc(value)) return msg;
+ }
+
+ return true;
+ }
+}
diff --git a/client/components/Input.jsx b/client/components/Input.jsx
new file mode 100644
index 0000000..4b68d7d
--- /dev/null
+++ b/client/components/Input.jsx
@@ -0,0 +1,38 @@
+import { h, Component } from 'preact';
+
+import { omit } from 'lowline';
+
+import FormElement from './FormElement.jsx';
+
+export default class Input extends FormElement {
+ render({ type = 'text', disabled, placeholder }, state = {}) {
+ const classes = Object.assign({
+ 'field-container': true,
+ empty: !state.value,
+ filled: state.value,
+ focus: state.focus,
+ invalid: state.error,
+ touched: state.touched,
+ valid: state.value && !state.error,
+ });
+
+ return (
+
+
+ { this.input = input; }}
+ type={type}
+ value={state.value}
+ />
+
+ {state.error && }
+
+ );
+ }
+}
diff --git a/client/components/Layout.jsx b/client/components/Layout.jsx
index bdb88f0..91032cc 100644
--- a/client/components/Layout.jsx
+++ b/client/components/Layout.jsx
@@ -1,13 +1,17 @@
import { h } from 'preact';
import Timer from './Timer.jsx';
+import LoginForm from './LoginForm.jsx';
+import User from './User.jsx';
export default () => (
Pomodoro Time!
+
+
);
diff --git a/client/components/LoginForm.jsx b/client/components/LoginForm.jsx
new file mode 100644
index 0000000..9492e9b
--- /dev/null
+++ b/client/components/LoginForm.jsx
@@ -0,0 +1,67 @@
+import { h, Component } from 'preact';
+import isEmail from 'validator/lib/isEmail';
+
+import store from '../store';
+
+import Form from './Form.jsx';
+import Input from './Input.jsx';
+
+import { login } from '../actions/user';
+import { hideLoginModal } from '../actions/ui';
+
+export default class LoginForm extends Form {
+ constructor() {
+ super();
+
+ this.validation = {
+ email: {
+ required: true,
+ tests: [
+ [isEmail, 'Not a valid email'],
+ ],
+ },
+ password: {
+ required: true,
+ },
+ };
+
+ this.state = {
+ className: ['login-form', 'modal', 'enter', 'animate'],
+ };
+
+ this.onSubmit = this.onSubmit.bind(this);
+ }
+
+ componentWillMount() {
+ this.unsubscribe = store.subscribe(() => this.forceUpdate());
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ onSubmit(e) {
+ e.preventDefault();
+
+ if (!this.validate()) return;
+
+ store.dispatch(login(this.toJSON()));
+ }
+
+ render() {
+ const { ui } = store.getState();
+
+ return ui.showLoginModal ? (
+
+ ) : null;
+ }
+}
diff --git a/client/components/Timer.jsx b/client/components/Timer.jsx
index 17d798f..b0773c3 100644
--- a/client/components/Timer.jsx
+++ b/client/components/Timer.jsx
@@ -85,6 +85,7 @@ export default class Timer extends Component {
fetch('/api/pomodoros', {
method: 'POST',
body: JSON.stringify(data),
+ credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
diff --git a/client/components/User.jsx b/client/components/User.jsx
new file mode 100644
index 0000000..9917f7a
--- /dev/null
+++ b/client/components/User.jsx
@@ -0,0 +1,29 @@
+import { h, Component } from 'preact';
+
+import store from '../store';
+
+import { logout } from '../actions/user';
+import { showLoginModal } from '../actions/ui';
+
+export default class User extends Component {
+ componentWillMount() {
+ this.unsubscribe = store.subscribe(() => this.forceUpdate());
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ render() {
+ const { user } = store.getState();
+
+ return (user &&
+
+
Logged in as {user.email}
+
+
||
+
+
+
);
+ }
+}
diff --git a/client/master.jsx b/client/master.jsx
index eac0c3a..768051f 100644
--- a/client/master.jsx
+++ b/client/master.jsx
@@ -1,6 +1,4 @@
-import Layout from './components/Layout.jsx';
-
-export default ({ css, js }) => (
+export default ({ INITIAL_STATE, css, js }) => (
'' + (
@@ -16,8 +14,9 @@ export default ({ css, js }) => (
-
-
+
diff --git a/client/reducers/root.js b/client/reducers/root.js
new file mode 100644
index 0000000..68ff40c
--- /dev/null
+++ b/client/reducers/root.js
@@ -0,0 +1,9 @@
+import { combineReducers } from 'redux';
+
+import user from './user';
+import ui from './ui';
+
+export default combineReducers({
+ user,
+ ui,
+});
diff --git a/client/reducers/ui.js b/client/reducers/ui.js
new file mode 100644
index 0000000..1d9c200
--- /dev/null
+++ b/client/reducers/ui.js
@@ -0,0 +1,24 @@
+import {
+ SHOW_LOGIN_MODAL,
+ HIDE_LOGIN_MODAL,
+} from '../actions/ui';
+
+const defaultState = {
+ showLoginModal: false,
+};
+
+export default (state = defaultState, action) => {
+ switch (action.type) {
+ case SHOW_LOGIN_MODAL:
+ return Object.assign({}, state, {
+ showLoginModal: true,
+ });
+ case HIDE_LOGIN_MODAL:
+ return Object.assign({}, state, {
+ showLoginModal: false,
+ });
+ default:
+ return state;
+ }
+};
+
diff --git a/client/reducers/user.js b/client/reducers/user.js
new file mode 100644
index 0000000..fff5a70
--- /dev/null
+++ b/client/reducers/user.js
@@ -0,0 +1,13 @@
+import { LOGIN_SUCCESS, LOGOUT } from '../actions/user';
+
+export default (state = null, action) => {
+ switch (action.type) {
+ case LOGIN_SUCCESS:
+ return action.payload;
+ case LOGOUT:
+ return null;
+ default:
+ return state;
+ }
+};
+
diff --git a/client/store.js b/client/store.js
new file mode 100644
index 0000000..a32321c
--- /dev/null
+++ b/client/store.js
@@ -0,0 +1,16 @@
+import thunkMiddleware from 'redux-thunk';
+import createLogger from 'redux-logger';
+import { createStore, applyMiddleware } from 'redux';
+
+const loggerMiddleware = createLogger();
+
+import rootReducer from './reducers/root';
+
+export default createStore(
+ rootReducer,
+ INITIAL_STATE,
+ applyMiddleware(
+ thunkMiddleware,
+ loggerMiddleware
+ )
+);
diff --git a/package.json b/package.json
index 687891b..4afb91d 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"express": "^4.14.0",
"express-service-errors": "github:thecodebureau/express-service-errors",
"express-session": "^1.14.2",
+ "get-value": "^2.0.6",
"hyperscript-jsx": "0.0.2",
"jsx-node": "^0.1.0",
"lodash": "^4.16.6",
@@ -40,7 +41,13 @@
"passport": "^0.3.2",
"passport-local": "^1.0.0",
"preact": "^6.4.0",
- "require-dir": "^0.3.1"
+ "redux": "^3.6.0",
+ "redux-logger": "^2.7.4",
+ "redux-thunk": "^2.1.0",
+ "require-dir": "^0.3.1",
+ "set-value": "^0.4.0",
+ "spineless": "^0.1.2",
+ "validator": "^6.1.0"
},
"devDependencies": {
"autoprefixer": "^6.5.1",
diff --git a/server/routers/index.js b/server/routers/index.js
index b5911d1..bc24ae3 100644
--- a/server/routers/index.js
+++ b/server/routers/index.js
@@ -1,5 +1,6 @@
'use strict';
+const bootstrap = require('midwest/factories/bootstrap');
const masterTemplate = require('../../build/master.jsx');
const router = new (require('express')).Router();
@@ -7,7 +8,9 @@ const router = new (require('express')).Router();
router.get('/', (req, res, next) => {
res.template = masterTemplate;
+ res.locals.user = req.user;
+
next();
-});
+}, bootstrap());
module.exports = router;