├── .babelrc
├── .eslintrc
├── .gitignore
├── README.md
├── package.json
├── scripts
├── dev.js
├── dev.sh
└── util.js
├── src
├── backend
│ ├── app.js
│ ├── db.js
│ ├── server.js
│ └── views
│ │ └── app.ejs
└── frontend
│ └── js
│ ├── actionCreators.js
│ ├── components
│ ├── ComponentWillMount.jsx
│ ├── app.jsx
│ ├── collage.jsx
│ ├── dragImage.jsx
│ ├── dragImages.jsx
│ └── flickr.jsx
│ ├── containers
│ └── appContainer.js
│ ├── main.js
│ ├── model.js
│ ├── reducer.js
│ ├── store.js
│ └── utils.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-0"],
3 | "plugins": ["add-module-exports"]
4 | }
5 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 |
4 | "env": {
5 | "browser": true,
6 | "node": true,
7 | "es6": true
8 | },
9 |
10 | "plugins": ["react"],
11 |
12 | "ecmaFeatures": { "jsx": true }
13 | }
14 |
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .dist
3 | .DS_Store
4 | npm-debug.log
5 | npm-debug.log*
6 | *.swp
7 | .www
8 | public
9 | src/backend/es5/*
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Classroom Coding
2 |
3 | > this is a port which uses [redux][r] with the [redux-future][rf] and [redux-either][re] middleware
4 |
5 | ## Thoughts
6 |
7 | This is a "practical" application built with react and functional techniques. React components are "impure" and our model stays pure (although there's a bit of cheating in utils as it's a hodge podge).
8 |
9 |
10 | ## Install
11 |
12 | First run:
13 |
14 | ```
15 | npm install
16 | ```
17 |
18 | Then run each of these commands in a different terminal window. One for webpack, one for babel-node:
19 |
20 | ```
21 | npm run dev
22 | ```
23 |
24 | ```
25 | npm run server
26 | ```
27 |
28 | [r]: http://redux.js.org/
29 | [rf]: https://github.com/stoeffel/redux-future
30 | [re]: https://github.com/stoeffel/redux-either
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "collagr",
3 | "version": "0.1.0",
4 | "description": "Functional Crap",
5 | "main": "index.js",
6 | "scripts": {
7 | "server": "babel-node src/backend/app.js",
8 | "dev": "npm install && webpack-dev-server --devtool eval --progress --colors --content-base public --port 5431 --hot --inline"
9 | },
10 | "repository": {
11 | "type": "git"
12 | },
13 | "author": "blonsdorf",
14 | "license": "CC-BY-ND-4.0",
15 | "bugs": {},
16 | "homepage": "",
17 | "dependencies": {
18 | "babel-eslint": "^4.1.3",
19 | "babel-preset-es2015": "^6.0.14",
20 | "babel-preset-react": "^6.1.18",
21 | "body-parser": "^1.14.1",
22 | "bootstrap": "^3.3.5",
23 | "classnames": "^2.1.3",
24 | "daggy": "0.0.1",
25 | "data.either": "^1.3.0",
26 | "data.maybe": "^1.2.1",
27 | "data.task": "^3.0.0",
28 | "ejs": "^2.3.4",
29 | "eslint": "^1.8.0",
30 | "eslint-loader": "^1.1.0",
31 | "eslint-plugin-react": "^3.6.3",
32 | "express": "^4.13.3",
33 | "fantasy-options": "0.0.1",
34 | "fantasy-validations": "0.0.2",
35 | "glob": "^5.0.15",
36 | "history": "^1.13.0",
37 | "jquery": "^2.1.4",
38 | "pg": "^4.4.3",
39 | "pg-hstore": "^2.3.2",
40 | "pointfree-fantasy": "^0.1.1",
41 | "ramda": "^0.18.0",
42 | "react": "*",
43 | "react-dom": "^0.14.2",
44 | "react-hot-loader": "*",
45 | "react-redux": "^4.0.6",
46 | "redux": "^3.0.5",
47 | "redux-actions": "^0.9.0",
48 | "redux-either": "^0.1.0",
49 | "redux-future": "0.0.9",
50 | "sanctuary": "^0.7.1",
51 | "sequelize": "^3.13.0",
52 | "superagent": "^1.4.0"
53 | },
54 | "devDependencies": {
55 | "async": "^1.4.2",
56 | "babel-core": "^6.2.1",
57 | "babel-loader": "^6.2.0",
58 | "babel-plugin-add-module-exports": "^0.1.2",
59 | "babel-preset-stage-0": "^6.3.13",
60 | "css-loader": "^0.15.6",
61 | "jsverify": "^0.7.1",
62 | "mocha": "^2.3.3",
63 | "rimraf": "^2.4.2",
64 | "style-loader": "^0.12.3",
65 | "webpack": "^1.12.9",
66 | "webpack-dev-server": "^1.10.1"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/scripts/dev.js:
--------------------------------------------------------------------------------
1 | var fs = require('fs');
2 | var path = require('path');
3 | var rimraf = require('rimraf');
4 | var async = require('async');
5 | var util = require('./util');
6 |
7 | console.log('### Running server');
8 | async.series([
9 | function(done) {
10 | console.log('--- Cleaning up');
11 | rimraf.sync(path.join(__dirname, '../.www'));
12 | fs.mkdirSync(path.join(__dirname, '../.www'));
13 | done();
14 | },
15 | function(done) {
16 | console.log('--- Copy test.html => index.html');
17 | util.cp(path.join(__dirname, '../test.html'), path.join(__dirname, '../.www/index.html'), done);
18 | }
19 | ]);
20 |
--------------------------------------------------------------------------------
/scripts/dev.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | node scripts/dev.js
4 | webpack-dev-server --progress --profile --colors --hot --no-info --port 3100
5 |
--------------------------------------------------------------------------------
/scripts/util.js:
--------------------------------------------------------------------------------
1 | var fs = require('fs');
2 |
3 | function cp(src, dest, cb) {
4 | var cbCalled = false;
5 | var r = fs.createReadStream(src);
6 | var w = fs.createWriteStream(dest);
7 |
8 | function done(e) {
9 | if (!cbCalled) {
10 | cbCalled = true;
11 | cb(e);
12 | }
13 | }
14 |
15 | r.on('error', done);
16 | w.on('error', done);
17 | w.on('close', done);
18 | r.pipe(w);
19 | }
20 |
21 | module.exports = {
22 | cp: cp
23 | };
24 |
--------------------------------------------------------------------------------
/src/backend/app.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const path = require('path');
3 | const server = require('./server');
4 | const bodyParser = require('body-parser');
5 | const {sync} = require('./db');
6 | const Task = require('data.task')
7 |
8 | const app = express();
9 |
10 | app.set('port', 3000);
11 | app.set('views', __dirname + '/views');
12 | app.set('view engine', 'ejs');
13 | app.use(bodyParser.json());
14 | app.use(express.static(path.resolve(__dirname, 'public')));
15 |
16 | const main = sync.chain(() => {
17 | return new Task((rej, res) => {
18 | server(app)
19 | app.listen(3000, () => res('Express: listening on 3000'))
20 | })
21 | })
22 |
23 | main.fork(console.log, console.log)
24 |
--------------------------------------------------------------------------------
/src/backend/db.js:
--------------------------------------------------------------------------------
1 | const Sequelize = require('sequelize');
2 | const sequelize = new Sequelize('collagr', 'stoeffel', '', {dialect: 'postgres'});
3 | const Task = require('data.task')
4 | const {curry} = require('ramda')
5 |
6 | const Photo = sequelize.define('Photo', {
7 | src: Sequelize.STRING,
8 | x: Sequelize.INTEGER,
9 | y: Sequelize.INTEGER
10 | });
11 |
12 |
13 | // sync :: Task Error Conn
14 | const sync = new Task((rej, res) => sequelize.sync().then(res).catch(rej))
15 |
16 | // create :: Table -> {} -> Task Error Record
17 | const create = curry((table, attrs) => new Task((rej, res) => table.create(attrs).then(res).catch(rej)))
18 |
19 | // all :: Table -> {} -> Task Error [Record]
20 | const all = curry((table, query) => new Task((rej, res) => table.findAll(query).then(res).catch(rej)))
21 |
22 | module.exports = {sync, create, all, Photo}
23 |
--------------------------------------------------------------------------------
/src/backend/server.js:
--------------------------------------------------------------------------------
1 | const { all, create, Photo } = require('./db')
2 | const { traverse } = require('pointfree-fantasy')
3 | const Task = require('data.task')
4 |
5 |
6 | // savePhoto :: {} -> Task Error Record
7 | const savePhoto = create(Photo)
8 |
9 | module.exports = (app) => {
10 |
11 | app.get('/', (req, res) => res.render('app', {}))
12 |
13 | app.get('/photos', (req, res) => all(Photo, {}).fork((err) => res.json(err),
14 | (ps) => res.json(ps)))
15 |
16 | app.post('/save', (req, res) =>
17 | traverse(savePhoto, Task.of, req.body).fork((err) => res.json(err),
18 | (ps) => res.json(ps)))
19 | }
20 |
--------------------------------------------------------------------------------
/src/backend/views/app.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
26 | Collagr
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/frontend/js/actionCreators.js:
--------------------------------------------------------------------------------
1 | const { createAction } = require('redux-actions');
2 | const { flickrSearch, save, load } = require('./model')
3 |
4 | const search = createAction('SEARCH', flickrSearch);
5 | const changeTerm = createAction('CHANGE_TERM');
6 | const updateCollage = createAction('UPDATE_COLLAGE');
7 | const saveCollage = createAction('SAVE_COLLAGE', save);
8 | const loadCollage = createAction('LOAD_COLLAGE', () => load);
9 |
10 | module.exports = {
11 | search,
12 | changeTerm,
13 | updateCollage,
14 | saveCollage,
15 | loadCollage
16 | };
17 |
--------------------------------------------------------------------------------
/src/frontend/js/components/ComponentWillMount.jsx:
--------------------------------------------------------------------------------
1 | const React = require('react');
2 | const { curry } = require('ramda');
3 |
4 | const ComponentWillMount = curry((cb, Component) => {
5 | return React.createClass({
6 | componentWillMount: function() {
7 | cb(this.props);
8 | },
9 |
10 | render: function() {
11 | return ;
12 | }
13 | });
14 | });
15 | module.exports = ComponentWillMount;
16 |
--------------------------------------------------------------------------------
/src/frontend/js/components/app.jsx:
--------------------------------------------------------------------------------
1 | const React = require('react');
2 | const { identity } = require('ramda');
3 | const Flickr = require('./flickr')
4 | const Collage = require('./collage')
5 | const { search, changeTerm } = require('../actionCreators');
6 |
7 | const App = ({
8 | search,
9 | changeTerm,
10 | searchTerm,
11 | photos,
12 | collage,
13 | updateCollage,
14 | saveCollage,
15 | loadCollage,
16 | error
17 | }) => (
18 |
19 | { error &&
{error}
}
20 |
search(searchTerm)}
23 | onTermChanged={changeTerm}
24 | term={searchTerm} />
25 |
30 |
31 | );
32 |
33 | module.exports = App;
34 |
--------------------------------------------------------------------------------
/src/frontend/js/components/collage.jsx:
--------------------------------------------------------------------------------
1 | const React = require('react')
2 | const DragImages = require('./dragImages')
3 | const { append } = require('ramda')
4 | const { preventDefault } = require('../utils')
5 | const { Photo, replacePhoto } = require('../model')
6 | const ComponentWillMount = require('./componentWillMount');
7 |
8 | const Collage = ({
9 | photos,
10 | onSaveClick,
11 | updateCollage
12 | }) => {
13 | const onDrop = ({dataTransfer: dt, clientX: x, clientY: y, currentTarget: t}) => {
14 | const offset = t.getBoundingClientRect().top;
15 | const src = dt.getData('text');
16 | const photo = Photo(src, x, (y - offset));
17 | updateCollage(replacePhoto(photo, photos));
18 | };
19 |
20 | return (
21 |
22 |
23 |
24 |
{DragImages(photos)}
25 |
26 |
27 | );
28 | };
29 | module.exports = ComponentWillMount(({ loadCollage }) => loadCollage(), Collage);
30 |
--------------------------------------------------------------------------------
/src/frontend/js/components/dragImage.jsx:
--------------------------------------------------------------------------------
1 | const React = require('react')
2 |
3 | module.exports = React.createClass({
4 | displayName: 'DragImage',
5 |
6 | // onDragStart :: Event -> State Event
7 | onDragStart({dataTransfer: dt, currentTarget: t}) { dt.setData('text', t.src) },
8 |
9 | render() {
10 | return
11 | }
12 | });
13 |
14 |
--------------------------------------------------------------------------------
/src/frontend/js/components/dragImages.jsx:
--------------------------------------------------------------------------------
1 | const React = require('react')
2 | const DragImage = require('./dragImage');
3 | const { map } = require('ramda');
4 |
5 | const DragImages = map(({ src, y, x }) => );
6 |
7 | module.exports = DragImages;
8 |
--------------------------------------------------------------------------------
/src/frontend/js/components/flickr.jsx:
--------------------------------------------------------------------------------
1 | const React = require('react')
2 | const { flickrSearch } = require('../model');
3 | const DragImages = require('./dragImages');
4 |
5 |
6 | const Flickr = ({
7 | onTermChanged,
8 | onSearchClicked,
9 | term,
10 | photos
11 | }) => (
12 |
13 |
onTermChanged(target.value)} defaultValue={term} />
14 |
15 |
{DragImages(photos)}
16 |
17 | );
18 |
19 | module.exports = Flickr;
20 |
21 |
--------------------------------------------------------------------------------
/src/frontend/js/containers/appContainer.js:
--------------------------------------------------------------------------------
1 | const React = require('react');
2 | const { identity, compose } = require('ramda');
3 | const { connect } = require('react-redux');
4 |
5 | const { search, changeTerm, updateCollage, saveCollage, loadCollage } = require('../actionCreators');
6 | const App = require('../components/app')
7 |
8 |
9 | function mapDispatchToProps(dispatch) {
10 | return {
11 | search: compose(dispatch, search),
12 | changeTerm: compose(dispatch, changeTerm),
13 | updateCollage: compose(dispatch, updateCollage),
14 | saveCollage: compose(dispatch, saveCollage),
15 | loadCollage: compose(dispatch, loadCollage)
16 | }
17 | }
18 |
19 | module.exports = connect(
20 | identity,
21 | mapDispatchToProps
22 | )(App)
23 |
--------------------------------------------------------------------------------
/src/frontend/js/main.js:
--------------------------------------------------------------------------------
1 | const React = require('react');
2 | const { Provider } = require('react-redux');
3 | const Dom = require('react-dom');
4 | const App = require('./containers/appContainer');
5 | const {ajaxSetup} = require('jquery');
6 | const Store = require('./store.js');
7 |
8 | ajaxSetup({headers: {'Content-Type': 'application/json'}})
9 |
10 | Dom.render(
11 |
12 |
13 | ,
14 | document.getElementById('main')
15 | );
16 |
--------------------------------------------------------------------------------
/src/frontend/js/model.js:
--------------------------------------------------------------------------------
1 | const {curry, append, remove, compose, replace, prop, map, ifElse, propSatisfies, equals} = require('ramda')
2 | const { indexOf, Http, Compose } = require('./utils')
3 | const { fold } = require('pointfree-fantasy')
4 | const { Some, None } = require('fantasy-options')
5 | const { Left, Right } = require('data.either')
6 | const daggy = require('daggy')
7 |
8 | const Url = String
9 | const Point = Number
10 |
11 | // mayToOpt :: Maybe a -> Option a
12 | const mayToOpt = (m) => m.cata({Just: Some, Nothing: () => None})
13 |
14 | // Photo :: {src :: Url, x :: Point, y :: Point }
15 | const Photo = daggy.tagged('src', 'x', 'y')
16 |
17 | // newPhoto :: Url -> Photo
18 | const newPhoto = (url) => Photo(url, 0, 0)
19 |
20 | const baseUrl = 'https://api.flickr.com/services/rest/?method=flickr.photos.search&api_key=14c4ebab40155d8c54dacb0642f46d68&tags={TAGS}&extras=url_s&format=json&jsoncallback=?'
21 |
22 | // makeUrl :: String -> Url
23 | const makeUrl = (t) => replace("{TAGS}", t, baseUrl)
24 |
25 | // toPhoto :: JSON -> [Photo]
26 | const toPhoto = compose(map(compose(newPhoto, prop('url_s'))), prop('photo'), prop('photos'))
27 |
28 | // failed :: {a} -> Either b a
29 | const statFail = propSatisfies(equals('fail'), 'stat')
30 |
31 | // toPhotoOrFail :: {a} -> Either b c
32 | const toPhotoOrFail = compose(map(toPhoto), ifElse(statFail, Left, Right));
33 |
34 | // flickrSearch :: Term -> Task Error (Either Error [Photo])
35 | const flickrSearch = compose( map(toPhotoOrFail)
36 | , Http.get
37 | , makeUrl
38 | )
39 |
40 | // indexOfPhoto :: Photo -> [Photo] -> Number
41 | const indexOfPhoto = curry((p, ps) => indexOf(p.src, ps.map(prop('src'))))
42 |
43 | // replacePhoto :: Photo -> [Photo] -> [Photo]
44 | const replacePhoto = curry((p, ps) => compose(fold(append(p), () => append(p, ps)),
45 | mayToOpt,
46 | map(i => remove(i, 1, ps)),
47 | indexOfPhoto(p))(ps))
48 |
49 | // save :: [Photo] -> Task Error [Photo]
50 | const save = Http.post('/save');
51 |
52 | // load :: Task Error [Photo]
53 | const load = Http.get('/photos');
54 |
55 | module.exports = { flickrSearch, Photo, replacePhoto, save, load }
56 |
57 |
--------------------------------------------------------------------------------
/src/frontend/js/reducer.js:
--------------------------------------------------------------------------------
1 | const { assoc, dissoc, compose } = require('ramda');
2 |
3 | const initialState = {
4 | searchTerm: '',
5 | collage: [],
6 | photos: [],
7 | error: null
8 | };
9 |
10 | function reducer(state = initialState, { type, payload, error }) {
11 | switch (type) {
12 | case 'CHANGE_TERM':
13 | return assoc('searchTerm', payload, state);
14 | case 'SEARCH':
15 | if (error) return assoc('error', payload.statusText || payload.message, state);
16 |
17 | return compose( assoc('photos', payload)
18 | , dissoc('error')
19 | )( state);
20 | case 'UPDATE_COLLAGE':
21 | case 'LOAD_COLLAGE':
22 | return assoc('collage', payload, state);
23 | default:
24 | return state
25 | }
26 | }
27 |
28 | module.exports = reducer;
29 |
--------------------------------------------------------------------------------
/src/frontend/js/store.js:
--------------------------------------------------------------------------------
1 | const { compose } = require('ramda');
2 | const Either = require('data.either');
3 | const { createStore, applyMiddleware } = require('redux');
4 | const futureMiddleware = require('redux-future').default;
5 | const eitherMiddleware = require('redux-either').default;
6 | const reducer = require('./reducer');
7 |
8 |
9 | const finalCreateStore = compose(
10 | applyMiddleware(futureMiddleware, eitherMiddleware(Either, (l, r, e) => e.fold(l, r))),
11 | window.devToolsExtension ? window.devToolsExtension() : f => f
12 | )(createStore);
13 |
14 | module.exports = finalCreateStore(reducer);
15 |
--------------------------------------------------------------------------------
/src/frontend/js/utils.js:
--------------------------------------------------------------------------------
1 | const Task = require('data.task')
2 | const {getJSON, post} = require('jquery')
3 | const {curry} = require('ramda')
4 | const {Just, Nothing} = require('data.maybe')
5 |
6 | // preventDefault :: Event -> _
7 | const preventDefault = (e) => e.preventDefault()
8 |
9 | const Http = {
10 | // get :: Url -> Task Error JSON
11 | get: (url) => new Task((rej, res) => getJSON(url).error(rej).done(res)),
12 |
13 | // post :: Url -> {} -> Task Error JSON
14 | post: curry((url, params) => new Task((rej, res) => post(url, JSON.stringify(params)).error(rej).done(res)))
15 | }
16 |
17 | // indexOf :: a -> [a] -> Maybe Number
18 | const indexOf = curry((x, xs) => {
19 | const idx = xs.indexOf(x)
20 | return idx < 0 ? Nothing() : Just(idx)
21 | })
22 |
23 | module.exports = { preventDefault, Http, indexOf }
24 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 | var glob = require('glob');
3 |
4 | var config = {
5 | entry: {
6 | build: './src/frontend/js/main.js',
7 | vendors: ['jquery']
8 | },
9 |
10 | resolve: {
11 | extensions: ['', '.js', '.jsx'],
12 | modulesDirectories: ['node_modules'].concat(glob.sync('./src/frontend/**/*'))
13 | },
14 |
15 | plugins: [
16 | new webpack.optimize.CommonsChunkPlugin('vendors', 'vendors.js', Infinity),
17 |
18 | new webpack.ProvidePlugin({
19 | $: 'jquery',
20 | jQuery: 'jquery',
21 | 'window.jquery': 'jquery'
22 | }),
23 |
24 | new webpack.NoErrorsPlugin()
25 | ],
26 |
27 | output: {
28 | path: './public',
29 | filename: '[name].js'
30 | },
31 |
32 | module: {
33 | noParse: [],
34 | loaders: [
35 | {test: /\.jsx?$/,
36 | exclude: /node_modules/,
37 | loader: 'react-hot'
38 | },
39 | {
40 | test: /\.jsx?$/,
41 | exclude: /node_modules/,
42 | loader: 'babel?presets[]=react,presets[]=es2015'
43 | },
44 | { test: /\.css$/, loader: 'style!css' },
45 | { test: /\.html$/, loader: 'file?name=[name].[ext]' }
46 | ],
47 |
48 | preLoaders: [
49 | {
50 | test: /\.jsx?$/,
51 | loader: 'eslint',
52 | exclude: /node_modules/
53 | }
54 | ]
55 | },
56 |
57 | devServer: {
58 | contentBase: './public',
59 | historyApiFallback: true,
60 | proxy: { '*': 'http://localhost:3000' }
61 | },
62 |
63 | eslint: {
64 | configFile: './.eslintrc'
65 | }
66 | };
67 |
68 | module.exports = config;
69 |
70 |
--------------------------------------------------------------------------------