├── .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 | --------------------------------------------------------------------------------