├── .babelrc ├── .gitignore ├── .sequelizerc ├── HEROKU.md ├── LICENSE ├── README.md ├── __tests__ └── index.spec.tsx ├── api ├── auth.js ├── controllers │ ├── comment.js │ ├── post.js │ ├── queries.js │ └── user.js ├── index.js ├── migrations │ ├── 20170412220603-create-user.js │ ├── 20170412220728-create-post.js │ └── 20170412235342-create-comment.js └── models │ ├── comment.js │ ├── index.js │ ├── post.js │ └── user.js ├── common ├── actions.tsx ├── http.tsx ├── store.tsx └── strings.tsx ├── components ├── AuthLoginForm.tsx ├── AuthSignupForm.tsx ├── Border.tsx ├── BorderedItem.tsx ├── BoxHeaderLayout.tsx ├── Button.tsx ├── ColumnLayout.tsx ├── CommentForm.tsx ├── CommentList.tsx ├── CommentPreview.tsx ├── CommentPreviewHeader.tsx ├── CommentPreviewReply.tsx ├── Document.tsx ├── Input.tsx ├── Label.tsx ├── LabelBold.tsx ├── Link.tsx ├── NavAuthenticated.tsx ├── NavLayout.tsx ├── NavPublic.tsx ├── PostForm.tsx ├── PostList.tsx ├── PostLockup.tsx ├── PostPreview.tsx ├── Text.tsx ├── Textarea.tsx ├── UserList.tsx └── UserPreview.tsx ├── config.js ├── data-models └── index.ts ├── enzyme.js ├── higher-order ├── withData.js └── withEmotion.tsx ├── index.js ├── jest.config.js ├── jest.tsconfig.json ├── next.config.js ├── now-secrets.json ├── now.json ├── package.json ├── pages ├── _document.js ├── comments.tsx ├── index.tsx ├── post.tsx ├── posts.tsx ├── users.tsx └── write.tsx ├── static └── favicon.png └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "next/babel", 4 | "@zeit/next-typescript/babel", 5 | ], 6 | "plugins": [ 7 | ["module-resolver", { 8 | "alias": { 9 | "app": "." 10 | } 11 | }] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | .vscode 3 | .package-lock.json 4 | package-lock.json 5 | .now-secrets.json 6 | node_modules 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (http://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # Dependency directory 33 | node_modules 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional REPL history 39 | .node_repl_history 40 | 41 | # Env variables 42 | .env 43 | 44 | # Finder stuff 45 | .DS_Store 46 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | "config": path.resolve('./', 'config.js'), 5 | "models-path": path.resolve('./api/models'), 6 | "seeders-path": path.resolve('./api/seeders'), 7 | "migrations-path": path.resolve('./api/migrations') 8 | }; 9 | -------------------------------------------------------------------------------- /HEROKU.md: -------------------------------------------------------------------------------- 1 | ## Heroku Deploys: Changes 2 | 3 | #### No 1. 4 | Copy and paste these scripts over the existing `build`, `migrate`, and `heroku-postbuild` commands. 5 | 6 | * The most important thing is that Heroku needs to run `npm install -g babel-cli` on their end. 7 | * `"heroku-postbuild"` only works with Heroku deploys. 8 | 9 | ``` 10 | "scripts": { 11 | "build": "NODE_ENV=production npm install -g babel-cli && next build && npm run migrate", 12 | "migrate": "NODE_ENV=production npm install -g sequelize-cli && sequelize db:migrate", 13 | "heroku-postbuild": "NODE_ENV=production npm run build" 14 | } 15 | ``` 16 | 17 | #### No 2. 18 | No `"devDependencies"`, Heroku needs all dependencies to be in the object `"dependencies"`. 19 | 20 | 21 | ## Heroku Deploys: Setup 22 | 23 | Install Heroku. 24 | 25 | ```sh 26 | npm install -g heroku 27 | heroku login 28 | heroku create 29 | ``` 30 | 31 | Heroku will give you a unique address, like ours: `https://next-postgres.herokuapp.com/`. 32 | 33 | Already have a heroku app to deploy to? 34 | 35 | ``` 36 | heroku git:remote -a name-of-your-heroku-app 37 | ``` 38 | 39 | ## Heroku Deploys: Configure Postgres and environment variables on Heroku 40 | 41 | Go to https://data.heroku.com, add a datastore, pick Postgres. 42 | 43 | You will receive `database`, `host`, `password`, `port`, and `username` values. Here is how you set them: 44 | 45 | ```sh 46 | # Set variables 47 | heroku config:set PRODUCTION_DATABASE=xxxxxxxxxxxxxxx 48 | heroku config:set PRODUCTION_HOST=xxxxxxxxxxxxxxx 49 | heroku config:set PRODUCTION_PASSWORD=xxxxxxxxxxxxxxx 50 | heroku config:set PRODUCTION_PORT=xxxxxxxxxxxxxxx 51 | heroku config:set PRODUCTION_USERNAME=xxxxxxxxxxxxxxx 52 | 53 | # See all of your variables 54 | heroku config 55 | ``` 56 | 57 | Please preview your config values at this point in your terminal to make sure you've set them. 58 | 59 | Set a secret for [cookie-session](https://github.com/expressjs/cookie-session): 60 | 61 | ```sh 62 | heroku config:set PRODUCTION_SECRET=xxxxxxxxxxxxxxx 63 | ``` 64 | 65 | Make sure there is a `DATABASE_URL`. If not, you will have to find it and set it: 66 | 67 | ```sh 68 | heroku config:set DATABASE_URL=postgres://xxxxxxxxxxxxxxx.compute-1.amazonaws.com:xxxx/xxxxxxxxxxxxxxx 69 | ``` 70 | 71 | 72 | ## Heroku Deploys: Last step 73 | 74 | ```sh 75 | git push heroku master 76 | ``` 77 | 78 | append `--force` if necessary. 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 @wwwjim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-postgres-with-typescript 2 | This is a fork of the wonderful next-postgres repo by @jimmylee with additional support for Typescript and Jest. This should compile successfully as is, but it still relies on many 'any' types. To use, refer to the Setup instructions below. 3 | 4 | ### Changes 5 | - Most files in `~/common`, `~/components`, `~/higher-order`, and `~/pages` have been converted to .tsx with provisional types. 6 | - A `~/data-models` file has been created to keep all common types/interfaces. 7 | - Jest and Enzyme are setup to allow for tests (currently only for unconnected, pure components. See issues.). 8 | 9 | ### Remaining issues 10 | - `IUser`, `IPost`, and `IComment` need to be defined (currently all 'any') 11 | - `dispatch` needs to be correctly typed (currently all 'any') 12 | - `~/higher-order/withData.js` needs to converted to Typescript 13 | - `~/pages/_document.js` needs to converted to Typescript 14 | - `~/api` files need to be converted to Typescript files 15 | - ? Redux and Next Router need to be configured for Jest/Enzyme? Currently fails, likely bc components not connected to Provider/Router. 16 | 17 | ### Contributions 18 | Any help is welcome to make this repo more type safe. Feel free to submit PRs! 19 | 20 | --- 21 | 22 | ## next-postgres 23 | 24 | This is an example of a full stack web application with... 25 | 26 | - posts 27 | - comments 28 | - server side rendering. 29 | 30 | It is configured to be deployed to [Zeit](https://zeit.co) but I also provide Heroku [deployment steps](https://github.com/jimmylee/next-postgres/blob/master/HEROKU.md). 31 | 32 | It is code you can use without attribution, please enjoy. 🙏 33 | 34 | #### Production Examples: 35 | 36 | - [Maurice Kenji Clarke](https://twitter.com/mauricekenji) used the setup to create: [https://indvstry.io/](https://indvstry.io/) 37 | - I used some of the ideas here for a serious project: [Reading Supply](https://reading.supply) 38 | 39 | #### Preview: 40 | 41 | - [https://next-postgres.herokuapp.com/](https://next-postgres.herokuapp.com/) 42 | - [https://next-postgres-wvpphfoelq.now.sh/](https://next-postgres-wvpphfoelq.now.sh/posts) 43 | 44 | ### Stack 45 | 46 | - [NextJS + Custom Express](https://github.com/zeit/next.js/) 47 | - [Emotion CSS-in-JS](https://github.com/emotion-js/emotion) 48 | - [Postgres](https://www.postgresql.org/) 49 | - [Sequelize: PostgresSQL ORM](http://docs.sequelizejs.com/) 50 | - [Passport for local authentication](http://passportjs.org/) 51 | - [Redux](http://redux.js.org/) 52 | - [Babel](https://babeljs.io/) 53 | 54 | ### Why is this useful? Why should I care? 55 | 56 | - The UX and UI are garbage, that means everything you do after will be better! 57 | - Client and server are written in JavaScript. 58 | - This is a production ready codebase you can use to test a concept you have. 59 | - [Server side rendering](https://zeit.co/blog/next2) has been made simple. 60 | - Maybe you want to get a head start at a hackathon. 61 | 62 | ## Setup: Prerequisites 63 | 64 | I use [Homebrew](https://brew.sh/) to manage dependencies on a new laptop... You're welcome to use something else. 65 | 66 | - Install Postgres: `brew install postgres`. 67 | - Install [Node 10.7.0+](https://nodejs.org/en/): `brew install node`. (Or update your node) 68 | 69 | ## Setup: Quick newbies guide to Postgres 70 | 71 | - On OSX, to run Postgres in a tab on the default port. 72 | 73 | ```sh 74 | postgres -D /usr/local/var/postgres -p 5432 75 | ``` 76 | 77 | - Postgres config is stored in `./config.js`. 78 | - Local database: `sampledb`. 79 | - Username and password as `test`. 80 | 81 | ### First time Postgres instructions. 82 | 83 | ```sh 84 | # Enter Postgres console 85 | psql postgres 86 | 87 | # Create a new user for yourself 88 | CREATE ROLE yourname WITH LOGIN PASSWORD 'yourname'; 89 | 90 | # Allow yourself to create databases 91 | ALTER ROLE yourname CREATEDB; 92 | 93 | # Exit Postgres console 94 | \q 95 | 96 | # Log in as your new user. 97 | psql postgres -U yourname 98 | 99 | # Create a database named: sampledb. 100 | # If you change this, update config.js 101 | CREATE DATABASE sampledb; 102 | 103 | # Give your self privileges 104 | GRANT ALL PRIVILEGES ON DATABASE sampledb TO yourname; 105 | 106 | # List all of your databases 107 | \list 108 | 109 | # Connect to your newly created DB as a test 110 | \connect sampledb 111 | 112 | # Exit Postgres console 113 | \q 114 | ``` 115 | 116 | I also use a GUI called [TablePlus](https://tableplus.io/) if you don't like command line. 117 | 118 | ## Setup: Run locally 119 | 120 | In the root directory run these commands: 121 | 122 | ```sh 123 | npm install 124 | npm install -g babel-cli 125 | npm install -g sequelize-cli 126 | sequelize db:migrate 127 | npm run dev 128 | ``` 129 | 130 | Visit `localhost:8000` in a browser to start development locally. You will need postgres running. 131 | 132 | ## Deploy Heroku (Alternative choice) 133 | 134 | To deploy with Heroku, please follow the instructions [here](https://github.com/jimmylee/next-postgres/blob/master/HEROKU.md). 135 | 136 | There are very specific details you must pay attention to. 137 | 138 | ## Deploy Zeit 139 | 140 | Deploying with `now-cli` is as simple as 141 | 142 | ```sh 143 | now 144 | ``` 145 | 146 | Do you have a custom domain? You can use an `alias` 147 | 148 | ```sh 149 | # after the deploy, alias to your domain, add "alias" to now.json first 150 | now alias 151 | ``` 152 | 153 | Make sure you configure your alias for [zeit.world](https://zeit.world). Also make sure you add the secrets you need or delete the ones you aren't using from `now-secrets.json`. 154 | 155 | ## Database + Secrets 156 | 157 | You can use a service like [https://compose.io](https://compose.io) to get a hosted Postgres database. 158 | 159 | Then update production secrets using `now secrets`. You must do this or else your website can't connect to the production database. 160 | 161 | ```sh 162 | now secrets add production-username xxxxxxxxxxxxxxx 163 | now secrets add production-password xxxxxxxxxxxxxxx 164 | now secrets add production-database xxxxxxxxxxxxxxx 165 | now secrets add production-host xxxxxxxxxxxxxxx 166 | now secrets add production-port xxxxxxxxxxxxxxx 167 | now secrets add production-secret xxxxxxxxxxxxxxx 168 | ``` 169 | 170 | ## Questions? 171 | 172 | Feel free to slang any feels to [@wwwjim](https://twitter.com/wwwjim). 173 | -------------------------------------------------------------------------------- /__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import IndexPage from '../pages/index'; 4 | 5 | describe('Test description', () => { 6 | describe('Specific test', () => { 7 | it('test expectation', function() { 8 | // write tests here 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /api/auth.js: -------------------------------------------------------------------------------- 1 | import expressSession from 'cookie-session'; 2 | import bcrypt from 'bcrypt'; 3 | import { Strategy } from 'passport-local'; 4 | import { session } from '../config.js'; 5 | import { User } from './models'; 6 | 7 | module.exports = (app, passport) => { 8 | const newExpressSession = expressSession({ 9 | secret: session.secret, 10 | resave: true, 11 | saveUninitialized: true, 12 | cookie: { 13 | secure: false, 14 | httpOnly: false, 15 | maxAge: 600000, 16 | }, 17 | }); 18 | 19 | app.use(newExpressSession); 20 | app.use(passport.initialize()); 21 | app.use(passport.session()); 22 | 23 | const newLocalStrategyOptions = { 24 | usernameField: 'username', 25 | passwordField: 'password', 26 | session: true, 27 | }; 28 | 29 | const newLocalStrategy = new Strategy( 30 | newLocalStrategyOptions, 31 | async (username, password, done) => { 32 | try { 33 | const user = await User.findOne({ 34 | where: { 35 | username: username, 36 | }, 37 | }); 38 | 39 | if (!user) { 40 | return done(null, false, { 41 | message: 'Incorrect credentials.', 42 | }); 43 | } 44 | 45 | const hashed = bcrypt.hashSync(password, user.salt); 46 | if (user.password === hashed) { 47 | return done(null, user); 48 | } 49 | 50 | return done(null, false, { 51 | message: 'Incorrect credentials.', 52 | }); 53 | } catch (err) { 54 | done(null, false, { 55 | message: 'Failed', 56 | }); 57 | } 58 | } 59 | ); 60 | 61 | passport.use(newLocalStrategy); 62 | 63 | passport.serializeUser((user, done) => { 64 | return done(null, user.id); 65 | }); 66 | 67 | passport.deserializeUser(async (id, done) => { 68 | try { 69 | const user = await User.findOne({ 70 | where: { 71 | id: id, 72 | }, 73 | }); 74 | 75 | if (!user) { 76 | return done(null, false, { message: 'User does not exist' }); 77 | } 78 | 79 | return done(null, user); 80 | } catch (err) { 81 | return done(null, false, { message: 'Failed' }); 82 | } 83 | }); 84 | }; 85 | -------------------------------------------------------------------------------- /api/controllers/comment.js: -------------------------------------------------------------------------------- 1 | import queries from './queries'; 2 | import { Comment, User, Post } from '../models'; 3 | 4 | module.exports = { 5 | async create(req, res) { 6 | try { 7 | await Comment.create({ 8 | content: req.body.content, 9 | commentId: req.body.commentId, 10 | postId: req.body.postId, 11 | userId: req.user.id, 12 | }); 13 | 14 | return res.status(200).send({}); 15 | } catch (err) { 16 | return res.status(500).send(err); 17 | } 18 | }, 19 | 20 | async list(req, res) { 21 | try { 22 | const comments = await Comment.findAll( 23 | queries.comments.list({ User, Post, Comment }) 24 | ); 25 | 26 | return res.status(200).send(comments); 27 | } catch (err) { 28 | throw new Error(err); 29 | return res.status(500).send(err); 30 | } 31 | }, 32 | 33 | async get(req, res) { 34 | try { 35 | const comment = await Comment.find( 36 | queries.comments.get({ req, User, Post, Comment }) 37 | ); 38 | 39 | if (!comment) { 40 | return res.status(404).send({ 41 | message: '404 comment', 42 | }); 43 | } 44 | 45 | return res.status(200).send(comment); 46 | } catch (err) { 47 | return res.status(500).send(err); 48 | } 49 | }, 50 | 51 | async getAll(req, res) { 52 | try { 53 | const comments = await Comment.findAll( 54 | queries.comments.listForUser({ req, User, Post, Comment }) 55 | ); 56 | 57 | if (!comments) { 58 | return res.status(404).send({ 59 | message: '404 comments', 60 | }); 61 | } 62 | 63 | return res.status(200).send(comments); 64 | } catch (err) { 65 | return res.status(500).send(err); 66 | } 67 | }, 68 | 69 | async update(req, res) { 70 | try { 71 | const comment = await Comment.find({ 72 | where: { 73 | id: req.params.commentId, 74 | postId: req.params.postId, 75 | userId: req.user.id, 76 | }, 77 | }); 78 | 79 | if (!comment) { 80 | return res.status(404).send({ 81 | message: '404 comment to update', 82 | }); 83 | } 84 | 85 | const updatedComment = await comment.update({ 86 | content: req.body.content || commment.content, 87 | }); 88 | 89 | return res.status(200).send(updatedComment); 90 | } catch (err) { 91 | return res.status(500).send(err); 92 | } 93 | }, 94 | 95 | async delete(req, res) { 96 | try { 97 | const comment = await Comment.find({ 98 | where: { 99 | id: req.params.commentId, 100 | postId: req.params.postId, 101 | userId: req.user.id, 102 | }, 103 | }); 104 | 105 | if (!comment) { 106 | return res.status(404).send({ 107 | message: '404 comment to delete', 108 | }); 109 | } 110 | 111 | await comment.destroy(); 112 | 113 | return res.status(200).send(); 114 | } catch (err) { 115 | return res.status(500).send(err); 116 | } 117 | }, 118 | }; 119 | -------------------------------------------------------------------------------- /api/controllers/post.js: -------------------------------------------------------------------------------- 1 | import queries from './queries'; 2 | import { Post, User, Comment } from '../models'; 3 | 4 | module.exports = { 5 | async create(req, res) { 6 | try { 7 | const post = await Post.create({ 8 | title: req.body.title, 9 | content: req.body.content, 10 | userId: req.user.id, 11 | }); 12 | 13 | return res.status(200).send(post); 14 | } catch (err) { 15 | return res.status(500).send(err); 16 | } 17 | }, 18 | 19 | async list(req, res) { 20 | try { 21 | const posts = await Post.findAll( 22 | queries.posts.list({ User, Post, Comment }) 23 | ); 24 | return res.status(200).send(posts); 25 | } catch (err) { 26 | return res.status(500).send(err); 27 | } 28 | }, 29 | 30 | async get(req, res) { 31 | try { 32 | const post = await Post.findById( 33 | req.params.postId, 34 | queries.posts.get({ User, Post, Comment }) 35 | ); 36 | 37 | if (!post) { 38 | return res.status(404).send({ 39 | message: 'Post Not Found', 40 | }); 41 | } 42 | 43 | return res.status(200).send(post); 44 | } catch (err) { 45 | return res.status(500).send(err); 46 | } 47 | }, 48 | 49 | async update(req, res) { 50 | try { 51 | const post = await Post.find({ 52 | where: { 53 | id: req.params.postId, 54 | userId: req.user.id, 55 | }, 56 | }); 57 | 58 | if (!post) { 59 | return res.status(404).send({ 60 | message: '404 on post update', 61 | }); 62 | } 63 | 64 | const updatedPost = await post.update({ 65 | title: req.body.title || post.title, 66 | content: req.body.content || post.content, 67 | }); 68 | 69 | return res.status(200).send(updatedPost); 70 | } catch (err) { 71 | return res.status(500).send(err); 72 | } 73 | }, 74 | 75 | async delete(req, res) { 76 | try { 77 | const post = await Post.find({ 78 | where: { 79 | id: req.params.postId, 80 | userId: req.user.id, 81 | }, 82 | }); 83 | 84 | if (!post) { 85 | return res.status(404).send({ 86 | message: 'Post Not Found', 87 | }); 88 | } 89 | 90 | await post.destroy(); 91 | 92 | return res.status(200).send({ 93 | message: null, 94 | }); 95 | } catch (err) { 96 | return res.status(500).send(err); 97 | } 98 | }, 99 | }; 100 | -------------------------------------------------------------------------------- /api/controllers/queries.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | users: { 3 | list: ({ req, User, Post, Comment }) => { 4 | return { 5 | attributes: { 6 | exclude: ['salt', 'password'], 7 | }, 8 | order: [['createdAt', 'DESC']], 9 | }; 10 | }, 11 | get: ({ req, User, Post, Comment }) => { 12 | return { 13 | attributes: { 14 | exclude: ['salt', 'password'], 15 | }, 16 | include: [ 17 | { 18 | model: Post, 19 | as: 'posts', 20 | }, 21 | { 22 | model: Comment, 23 | as: 'comments', 24 | }, 25 | ], 26 | order: [['createdAt', 'DESC']], 27 | }; 28 | }, 29 | }, 30 | posts: { 31 | list: ({ req, User, Post, Comment }) => { 32 | return { 33 | include: [ 34 | { 35 | model: Comment, 36 | as: 'comments', 37 | include: [ 38 | { 39 | model: User, 40 | as: 'user', 41 | attributes: { 42 | exclude: ['salt', 'password'], 43 | }, 44 | }, 45 | { 46 | model: Post, 47 | as: 'post', 48 | }, 49 | { 50 | model: Comment, 51 | as: 'replies', 52 | include: [ 53 | { 54 | model: User, 55 | as: 'user', 56 | attributes: { 57 | exclude: ['salt', 'password'], 58 | }, 59 | }, 60 | ], 61 | }, 62 | ], 63 | }, 64 | { 65 | model: User, 66 | as: 'user', 67 | attributes: { 68 | exclude: ['salt', 'password'], 69 | }, 70 | }, 71 | ], 72 | order: [['createdAt', 'DESC']], 73 | }; 74 | }, 75 | get: ({ req, User, Post, Comment }) => { 76 | return { 77 | include: [ 78 | { 79 | model: Comment, 80 | as: 'comments', 81 | where: { 82 | commentId: null, 83 | }, 84 | include: [ 85 | { 86 | model: User, 87 | as: 'user', 88 | attributes: { 89 | exclude: ['salt', 'password'], 90 | }, 91 | }, 92 | { 93 | model: Post, 94 | as: 'post', 95 | }, 96 | { 97 | model: Comment, 98 | as: 'replies', 99 | include: [ 100 | { 101 | model: User, 102 | as: 'user', 103 | attributes: { 104 | exclude: ['salt', 'password'], 105 | }, 106 | }, 107 | ], 108 | }, 109 | ], 110 | }, 111 | { 112 | model: User, 113 | as: 'user', 114 | attributes: { 115 | exclude: ['salt', 'password'], 116 | }, 117 | }, 118 | ], 119 | }; 120 | }, 121 | }, 122 | comments: { 123 | list: ({ req, User, Post, Comment }) => { 124 | return { 125 | order: [['createdAt', 'DESC']], 126 | include: [ 127 | { 128 | model: User, 129 | as: 'user', 130 | attributes: { 131 | exclude: ['salt', 'password'], 132 | }, 133 | }, 134 | { 135 | model: Post, 136 | as: 'post', 137 | }, 138 | { 139 | model: Comment, 140 | as: 'replies', 141 | order: [['createdAt']], 142 | include: [ 143 | { 144 | model: User, 145 | as: 'user', 146 | attributes: { 147 | exclude: ['salt', 'password'], 148 | }, 149 | }, 150 | ], 151 | }, 152 | { 153 | model: Comment, 154 | as: 'parent', 155 | include: [ 156 | { 157 | model: User, 158 | as: 'user', 159 | attributes: { 160 | exclude: ['salt', 'password'], 161 | }, 162 | }, 163 | ], 164 | }, 165 | ], 166 | }; 167 | }, 168 | listForUser: ({ req, User, Post, Comment }) => { 169 | return { 170 | where: { 171 | id: req.params.commentId, 172 | userId: req.user.id, 173 | }, 174 | include: [ 175 | { 176 | model: User, 177 | as: 'user', 178 | attributes: { 179 | exclude: ['salt', 'password'], 180 | }, 181 | }, 182 | { 183 | model: Post, 184 | as: 'post', 185 | }, 186 | { 187 | model: Comment, 188 | as: 'replies', 189 | order: [['createdAt']], 190 | }, 191 | ], 192 | order: [['createdAt', 'DESC']], 193 | }; 194 | }, 195 | get: ({ req, User, Post, Comment }) => { 196 | return { 197 | where: { 198 | id: req.params.commentId, 199 | postId: req.params.postId, 200 | userId: req.user.id, 201 | }, 202 | include: [ 203 | { 204 | model: User, 205 | as: 'user', 206 | attributes: { 207 | exclude: ['salt', 'password'], 208 | }, 209 | }, 210 | { 211 | model: Post, 212 | as: 'post', 213 | }, 214 | { 215 | model: Comment, 216 | as: 'replies', 217 | order: [['createdAt']], 218 | include: [ 219 | { 220 | model: User, 221 | as: 'user', 222 | attributes: { 223 | exclude: ['salt', 'password'], 224 | }, 225 | }, 226 | ], 227 | }, 228 | ], 229 | }; 230 | }, 231 | }, 232 | }; 233 | -------------------------------------------------------------------------------- /api/controllers/user.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import passport from 'passport'; 3 | import queries from './queries'; 4 | import { User, Comment, Post } from '../models'; 5 | 6 | const isEmptyOrNull = string => { 7 | return !string || !string.trim(); 8 | }; 9 | 10 | const getUserProps = user => { 11 | return { 12 | id: user.id, 13 | username: user.username, 14 | email: user.email, 15 | createdAt: user.createdAt, 16 | updatedAt: user.updatedAt, 17 | }; 18 | }; 19 | 20 | module.exports = { 21 | async create(req, res) { 22 | const username = req.body.username; 23 | const password = req.body.password; 24 | const verify = req.body.verify; 25 | 26 | if ( 27 | isEmptyOrNull(username) || 28 | isEmptyOrNull(password) || 29 | isEmptyOrNull(verify) 30 | ) { 31 | return res.status(500).send({ 32 | message: 'Please fill out all fields.', 33 | }); 34 | } 35 | 36 | if (password !== verify) { 37 | return res.status(500).send({ 38 | message: 'Your passwords do not match.', 39 | }); 40 | } 41 | 42 | const salt = bcrypt.genSaltSync(10); 43 | const hash = bcrypt.hashSync(password, salt); 44 | 45 | try { 46 | const user = await User.create({ 47 | username: username.toLowerCase(), 48 | salt: salt, 49 | password: hash, 50 | }); 51 | 52 | return req.login(user, err => { 53 | if (!err) { 54 | return res.status(200).send(getUserProps(user)); 55 | } 56 | 57 | return res.status(500).send({ 58 | message: 'Auth error', 59 | }); 60 | }); 61 | } catch (err) { 62 | return res.status(500).send(err); 63 | } 64 | }, 65 | 66 | auth(req, res) { 67 | return passport.authenticate('local', (err, user, info) => { 68 | if (err) { 69 | return res.status(500).send({ 70 | message: '500: Authentication failed, try again.', 71 | }); 72 | } 73 | 74 | if (!user) { 75 | return res.status(404).send({ 76 | message: '404: Authentication failed, try again.', 77 | }); 78 | } 79 | 80 | req.login(user, err => { 81 | if (!err) { 82 | res.status(200).send(user); 83 | } 84 | }); 85 | })(req, res); 86 | }, 87 | 88 | logout(req, res) { 89 | req.logout(); 90 | return res.status(200).send({ 91 | message: 'You are successfully logged out', 92 | }); 93 | }, 94 | 95 | async list(req, res) { 96 | try { 97 | const users = await User.findAll( 98 | queries.users.list({ req, User, Post, Comment }) 99 | ); 100 | 101 | return res.status(200).send(users); 102 | } catch (err) { 103 | return res.status(500).send(err); 104 | } 105 | }, 106 | 107 | async get(req, res) { 108 | try { 109 | const user = await User.findById( 110 | req.params.userId, 111 | queries.users.get({ req, User, Post, Comment }) 112 | ); 113 | 114 | if (!user) { 115 | return res.status(404).send({ 116 | message: '404 on user get', 117 | }); 118 | } 119 | 120 | return res.status(200).send(getUserProps(user)); 121 | } catch (err) { 122 | return res.status(500).send(err); 123 | } 124 | }, 125 | 126 | async update(req, res) { 127 | if (isEmptyOrNull(req.body.password)) { 128 | return res.status(500).send({ 129 | message: 'You must provide a password.', 130 | }); 131 | } 132 | 133 | try { 134 | const user = await User.findById(req.params.userId); 135 | 136 | if (!user) { 137 | return res.status(404).send({ 138 | message: '404 no user on update', 139 | }); 140 | } 141 | 142 | const updatedUser = await user.update({ 143 | email: req.body.email || user.email, 144 | username: req.body.username || user.username, 145 | password: req.body.password || user.password, 146 | }); 147 | 148 | return res.status(200).send(getUserProps(updatedUser)); 149 | } catch (err) { 150 | return res.status(500).send(err); 151 | } 152 | }, 153 | 154 | async deleteViewer(req, res) { 155 | try { 156 | const user = await User.findById(req.user.id); 157 | 158 | if (!user) { 159 | return res.status(403).send({ 160 | message: 'Forbidden: User Not Found', 161 | }); 162 | } 163 | 164 | req.logout(); 165 | await user.destroy(); 166 | 167 | return res.status(200).send({ 168 | viewer: null, 169 | }); 170 | } catch (err) { 171 | return res.status(500).send(err); 172 | } 173 | }, 174 | }; 175 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | import user from './controllers/user'; 2 | import post from './controllers/post'; 3 | import comment from './controllers/comment'; 4 | import passport from 'passport'; 5 | 6 | const authMiddleware = (req, res, next) => { 7 | if (req.isAuthenticated()) { 8 | return next(); 9 | } else { 10 | return res.status(403).send({ 11 | message: 'not authenticated', 12 | }); 13 | } 14 | }; 15 | 16 | module.exports = app => { 17 | app.get('/api', (req, res) => { 18 | return res.status(200).send({ 19 | message: 'Welcome', 20 | }); 21 | }); 22 | 23 | app.get('/api/authenticated', (req, res) => { 24 | if (req.isAuthenticated()) { 25 | return res.status(200).send({ 26 | isAuthenticated: true, 27 | }); 28 | } 29 | 30 | return res.status(403).send({ 31 | isAuthenticated: false, 32 | }); 33 | }); 34 | 35 | app.post('/api/signup', user.create); 36 | app.post('/api/login', user.auth); 37 | app.post('/api/logout', authMiddleware, user.logout); 38 | 39 | app.post('/api/users', user.create); 40 | app.get('/api/users', user.list); 41 | app.get('/api/users/:userId', user.get); 42 | app.put('/api/users/:userId', authMiddleware, user.update); 43 | app.delete('/api/viewer/delete', authMiddleware, user.deleteViewer); 44 | 45 | app.post('/api/posts', authMiddleware, post.create); 46 | app.get('/api/posts', post.list); 47 | app.get('/api/posts/:postId', post.get); 48 | app.put('/api/posts/:postId', authMiddleware, post.update); 49 | app.delete('/api/posts/:postId', authMiddleware, post.delete); 50 | 51 | app.get('/api/comments', comment.list); 52 | app.post('/api/comments', authMiddleware, comment.create); 53 | app.get('/api/posts/:postId/comments', comment.getAll); 54 | app.get('/api/posts/:postId/comments/:commentId', comment.get); 55 | app.put( 56 | '/api/posts/:postId/comments/:commentId', 57 | authMiddleware, 58 | comment.update 59 | ); 60 | app.delete( 61 | '/api/posts/:postId/comments/:commentId', 62 | authMiddleware, 63 | comment.delete 64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /api/migrations/20170412220603-create-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: function(queryInterface, Sequelize) { 4 | return queryInterface.createTable('Users', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER, 10 | }, 11 | username: { 12 | type: Sequelize.STRING, 13 | allowNull: false, 14 | unique: true, 15 | }, 16 | email: { 17 | type: Sequelize.STRING, 18 | allowNull: true, 19 | validate: { 20 | isEmail: true, 21 | }, 22 | }, 23 | password: { 24 | type: Sequelize.STRING, 25 | allowNull: false, 26 | }, 27 | salt: { 28 | type: Sequelize.STRING, 29 | allowNull: false, 30 | }, 31 | createdAt: { 32 | allowNull: false, 33 | type: Sequelize.DATE, 34 | }, 35 | updatedAt: { 36 | allowNull: false, 37 | type: Sequelize.DATE, 38 | }, 39 | }); 40 | }, 41 | down: function(queryInterface, Sequelize) { 42 | return queryInterface.dropTable('Users'); 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /api/migrations/20170412220728-create-post.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: function(queryInterface, Sequelize) { 4 | return queryInterface.createTable('Posts', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER, 10 | }, 11 | title: { 12 | type: Sequelize.TEXT, 13 | }, 14 | content: { 15 | type: Sequelize.TEXT, 16 | }, 17 | userId: { 18 | allowNull: false, 19 | type: Sequelize.INTEGER, 20 | onDelete: 'CASCADE', 21 | references: { 22 | model: 'Users', 23 | key: 'id', 24 | as: 'userId', 25 | }, 26 | }, 27 | createdAt: { 28 | allowNull: false, 29 | type: Sequelize.DATE, 30 | }, 31 | updatedAt: { 32 | allowNull: false, 33 | type: Sequelize.DATE, 34 | }, 35 | }); 36 | }, 37 | down: function(queryInterface, Sequelize) { 38 | return queryInterface.dropTable('Posts'); 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /api/migrations/20170412235342-create-comment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: function(queryInterface, Sequelize) { 4 | return queryInterface.createTable('Comments', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER, 10 | }, 11 | postId: { 12 | allowNull: false, 13 | type: Sequelize.INTEGER, 14 | onDelete: 'CASCADE', 15 | references: { 16 | model: 'Posts', 17 | key: 'id', 18 | as: 'postId', 19 | }, 20 | }, 21 | userId: { 22 | allowNull: false, 23 | type: Sequelize.INTEGER, 24 | onDelete: 'CASCADE', 25 | references: { 26 | model: 'Users', 27 | key: 'id', 28 | as: 'userId', 29 | }, 30 | }, 31 | commentId: { 32 | allowNull: true, 33 | type: Sequelize.INTEGER, 34 | onDelete: 'CASCADE', 35 | referenes: { 36 | model: 'Comments', 37 | key: 'id', 38 | as: 'commentId', 39 | }, 40 | }, 41 | content: { 42 | type: Sequelize.TEXT, 43 | }, 44 | createdAt: { 45 | allowNull: false, 46 | type: Sequelize.DATE, 47 | }, 48 | updatedAt: { 49 | allowNull: false, 50 | type: Sequelize.DATE, 51 | }, 52 | }); 53 | }, 54 | down: function(queryInterface, Sequelize) { 55 | return queryInterface.dropTable('Comments'); 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /api/models/comment.js: -------------------------------------------------------------------------------- 1 | module.exports = (Sequelize, DataTypes) => { 2 | const Comment = Sequelize.define('Comment', { 3 | content: { 4 | type: DataTypes.TEXT, 5 | allowNull: false, 6 | }, 7 | }); 8 | 9 | Comment.associate = models => { 10 | Comment.hasMany(models.Comment, { 11 | foreignKey: 'commentId', 12 | as: 'replies', 13 | onDelete: 'CASCADE', 14 | }); 15 | 16 | Comment.belongsTo(models.Comment, { 17 | foreignKey: 'commentId', 18 | as: 'parent', 19 | onDelete: 'CASCADE', 20 | }); 21 | 22 | Comment.belongsTo(models.Post, { 23 | foreignKey: 'postId', 24 | as: 'post', 25 | onDelete: 'CASCADE', 26 | }); 27 | 28 | Comment.belongsTo(models.User, { 29 | foreignKey: 'userId', 30 | as: 'user', 31 | onDelete: 'CASCADE', 32 | }); 33 | }; 34 | 35 | return Comment; 36 | }; 37 | -------------------------------------------------------------------------------- /api/models/index.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import sequelize from 'sequelize'; 4 | 5 | const db = {}; 6 | const basename = path.basename(module.filename); 7 | const env = process.env.NODE_ENV || 'development'; 8 | const sequelizeConfig = require(`${__dirname}/../../config.js`)[env]; 9 | 10 | const ORM = new sequelize( 11 | sequelizeConfig.database, 12 | sequelizeConfig.username, 13 | sequelizeConfig.password, 14 | sequelizeConfig 15 | ); 16 | 17 | db.User = ORM.import(path.join(__dirname, 'user.js')); 18 | db.Post = ORM.import(path.join(__dirname, 'post.js')); 19 | db.Comment = ORM.import(path.join(__dirname, 'comment.js')); 20 | 21 | Object.keys(db).forEach(modelName => { 22 | if (db[modelName].associate) { 23 | db[modelName].associate(db); 24 | } 25 | }); 26 | 27 | db.ORM = ORM; 28 | db.sequelize = sequelize; 29 | 30 | module.exports = db; 31 | -------------------------------------------------------------------------------- /api/models/post.js: -------------------------------------------------------------------------------- 1 | module.exports = (Sequelize, DataTypes) => { 2 | const Post = Sequelize.define('Post', { 3 | title: { 4 | type: DataTypes.TEXT, 5 | allowNull: false, 6 | }, 7 | content: { 8 | type: DataTypes.TEXT, 9 | allowNull: true, 10 | }, 11 | }); 12 | 13 | Post.associate = models => { 14 | Post.hasMany(models.Comment, { 15 | foreignKey: 'postId', 16 | as: 'comments', 17 | onDelete: 'CASCADE', 18 | }); 19 | 20 | Post.belongsTo(models.User, { 21 | foreignKey: 'userId', 22 | as: 'user', 23 | onDelete: 'CASCADE', 24 | }); 25 | }; 26 | 27 | return Post; 28 | }; 29 | -------------------------------------------------------------------------------- /api/models/user.js: -------------------------------------------------------------------------------- 1 | module.exports = (Sequelize, DataTypes) => { 2 | const User = Sequelize.define('User', { 3 | username: { 4 | type: DataTypes.STRING, 5 | allowNull: false, 6 | unique: true, 7 | validate: { 8 | is: /^[a-z0-9\_\-]+$/i, 9 | }, 10 | }, 11 | email: { 12 | type: DataTypes.STRING, 13 | allowNull: true, 14 | validate: { 15 | isEmail: true, 16 | }, 17 | }, 18 | password: { 19 | type: DataTypes.STRING, 20 | allowNull: false, 21 | }, 22 | salt: { 23 | type: DataTypes.STRING, 24 | allowNull: false, 25 | }, 26 | }); 27 | 28 | User.associate = models => { 29 | User.hasMany(models.Post, { 30 | foreignKey: 'userId', 31 | as: 'posts', 32 | onDelete: 'CASCADE', 33 | }); 34 | 35 | User.hasMany(models.Comment, { 36 | foreignKey: 'userId', 37 | as: 'comments', 38 | onDelete: 'CASCADE', 39 | }); 40 | }; 41 | 42 | return User; 43 | }; 44 | -------------------------------------------------------------------------------- /common/actions.tsx: -------------------------------------------------------------------------------- 1 | import * as HTTP from './http'; 2 | 3 | const exception = (error: string) => { 4 | throw new Error(error); 5 | }; 6 | 7 | const redirect = (route: string) => { 8 | window.location.href = route; 9 | }; 10 | 11 | export const updateStoreKeys = (data: any) => { 12 | return { 13 | type: 'UPDATE_STORE_KEYS', 14 | data, 15 | }; 16 | }; 17 | 18 | export const viewerAuthenticated = (viewer: any) => { 19 | return { 20 | type: 'VIEWER_AUTHENTICATED', 21 | isAuthenticated: true, 22 | viewer, 23 | }; 24 | }; 25 | 26 | export const viewerLogout = () => { 27 | return { 28 | type: 'VIEWER_LOGOUT', 29 | }; 30 | }; 31 | 32 | export const viewerDelete = () => { 33 | return async (dispatch: any) => { 34 | const response = await HTTP.deleteViewer(); 35 | 36 | if (response.status !== 200) { 37 | return exception('error'); 38 | } 39 | 40 | return dispatch(requestLogout()); 41 | }; 42 | }; 43 | 44 | export const requestSaveComment = ({ 45 | postId, 46 | content, 47 | }: { 48 | postId: string; 49 | content: string; 50 | }) => { 51 | return async (dispatch: any) => { 52 | const response = await HTTP.saveComment({ postId, content }); 53 | 54 | if (response.status !== 200) { 55 | return exception('error'); 56 | } 57 | 58 | window.location.reload(); 59 | }; 60 | }; 61 | 62 | export const requestSaveReply = ({ 63 | postId, 64 | commentId, 65 | content, 66 | }: { 67 | postId: string; 68 | commentId: string; 69 | content: string; 70 | }) => { 71 | return async (dispatch: any) => { 72 | const response = await HTTP.saveReply({ postId, commentId, content }); 73 | 74 | if (response.status !== 200) { 75 | return exception('error'); 76 | } 77 | 78 | window.location.reload(); 79 | }; 80 | }; 81 | 82 | export const requestUpdateComment = ({ 83 | postId, 84 | commentId, 85 | content, 86 | }: { 87 | postId: string; 88 | commentId: string; 89 | content: string; 90 | }) => { 91 | return async (dispatch: any) => { 92 | const response = await HTTP.updateComment({ postId, commentId, content }); 93 | 94 | if (response.status !== 200) { 95 | return exception('error'); 96 | } 97 | 98 | window.location.reload(); 99 | }; 100 | }; 101 | 102 | export const requestDeleteComment = ({ 103 | postId, 104 | commentId, 105 | }: { 106 | postId: string; 107 | commentId: string; 108 | }) => { 109 | return async (dispatch: any) => { 110 | const response = await HTTP.deleteComment({ postId, commentId }); 111 | 112 | if (response.status !== 200) { 113 | return exception('error'); 114 | } 115 | 116 | window.location.reload(); 117 | }; 118 | }; 119 | 120 | export const requestDeletePost = (id: string) => { 121 | return async (dispatch: any) => { 122 | const response = await HTTP.deletePost(id); 123 | 124 | if (response.status !== 200) { 125 | return exception('error'); 126 | } 127 | 128 | window.location.reload(); 129 | }; 130 | }; 131 | 132 | export const requestUpdatePost = ({ 133 | content, 134 | title, 135 | postId, 136 | }: { 137 | content: string; 138 | title: string; 139 | postId: string; 140 | }) => { 141 | return async (dispatch: any) => { 142 | const response = await HTTP.updatePost({ content, title, postId }); 143 | 144 | if (response.status !== 200) { 145 | return exception('error'); 146 | } 147 | 148 | window.location.reload(); 149 | }; 150 | }; 151 | 152 | export const requestSavePost = ({ 153 | content, 154 | title, 155 | }: { 156 | content: string; 157 | title: string; 158 | }) => { 159 | return async (dispatch: any) => { 160 | const response = await HTTP.savePost({ content, title }); 161 | 162 | if (response.status === 200) { 163 | return redirect('/'); 164 | } 165 | 166 | if (response.status === 403) { 167 | return dispatch(requestLogout()); 168 | } 169 | 170 | return exception('error'); 171 | }; 172 | }; 173 | 174 | export const requestLogout = () => { 175 | return async (dispatch: any) => { 176 | const response = await HTTP.logout(); 177 | 178 | if (response.status === 200) { 179 | return redirect('/'); 180 | } 181 | 182 | if (response.status === 403) { 183 | return redirect('/'); 184 | } 185 | 186 | return exception('error'); 187 | }; 188 | }; 189 | 190 | export const requestLogin = ({ 191 | username, 192 | password, 193 | }: { 194 | username: string; 195 | password: string; 196 | }) => { 197 | return async (dispatch: any) => { 198 | const response = await HTTP.login({ username, password }); 199 | 200 | if (response.status !== 200) { 201 | return exception('error'); 202 | } 203 | 204 | return redirect('/'); 205 | }; 206 | }; 207 | 208 | export const requestSignup = ({ 209 | username, 210 | password, 211 | verify, 212 | }: { 213 | username: string; 214 | password: string; 215 | verify: string; 216 | }) => { 217 | return async (dispatch: any) => { 218 | const response = await HTTP.signup({ username, password, verify }); 219 | 220 | if (response.status !== 200) { 221 | return exception('error'); 222 | } 223 | 224 | return redirect('/'); 225 | }; 226 | }; 227 | -------------------------------------------------------------------------------- /common/http.tsx: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | 3 | // need to fix fetch's @ts-ignores 4 | 5 | const requestHeaders = { 6 | Accept: 'application/json', 7 | 'Content-Type': 'application/json', 8 | }; 9 | 10 | export const getAllComments = () => { 11 | const options = { 12 | method: 'GET', 13 | headers: requestHeaders, 14 | credentials: 'include', 15 | }; 16 | //@ts-ignore 17 | return fetch(`/api/comments`, options); 18 | }; 19 | 20 | export const saveComment = ({ 21 | postId, 22 | content, 23 | }: { 24 | postId: string; 25 | content: string; 26 | }) => { 27 | const options = { 28 | method: 'POST', 29 | headers: requestHeaders, 30 | credentials: 'include', 31 | body: JSON.stringify({ postId, content }), 32 | }; 33 | //@ts-ignore 34 | return fetch(`/api/comments`, options); 35 | }; 36 | 37 | export const saveReply = ({ 38 | postId, 39 | commentId, 40 | content, 41 | }: { 42 | postId: string; 43 | commentId: string; 44 | content: string; 45 | }) => { 46 | const options = { 47 | method: 'POST', 48 | headers: requestHeaders, 49 | credentials: 'include', 50 | body: JSON.stringify({ postId, commentId, content }), 51 | }; 52 | //@ts-ignore 53 | return fetch(`/api/comments`, options); 54 | }; 55 | 56 | export const updateComment = ({ 57 | postId, 58 | commentId, 59 | content, 60 | }: { 61 | postId: string; 62 | commentId: string; 63 | content: string; 64 | }) => { 65 | const options = { 66 | method: 'PUT', 67 | headers: requestHeaders, 68 | credentials: 'include', 69 | body: JSON.stringify({ commentId, content }), 70 | }; 71 | //@ts-ignore 72 | return fetch(`/api/posts/${postId}/comments/${commentId}`, options); 73 | }; 74 | 75 | export const deleteComment = ({ 76 | postId, 77 | commentId, 78 | }: { 79 | postId: string; 80 | commentId: string; 81 | }) => { 82 | const options = { 83 | method: 'DELETE', 84 | headers: requestHeaders, 85 | credentials: 'include', 86 | }; 87 | //@ts-ignore 88 | return fetch(`/api/posts/${postId}/comments/${commentId}`, options); 89 | }; 90 | 91 | export const getAllPosts = () => { 92 | const options = { 93 | method: 'GET', 94 | headers: requestHeaders, 95 | credentials: 'include', 96 | }; 97 | //@ts-ignore 98 | return fetch(`/api/posts`, options); 99 | }; 100 | 101 | export const getPostById = (id: string) => { 102 | const options = { 103 | method: 'GET', 104 | headers: requestHeaders, 105 | credentials: 'include', 106 | }; 107 | //@ts-ignore 108 | return fetch(`/api/posts/${id}`, options); 109 | }; 110 | 111 | export const deleteViewer = () => { 112 | const options = { 113 | method: 'DELETE', 114 | headers: requestHeaders, 115 | credentials: 'include', 116 | }; 117 | //@ts-ignore 118 | return fetch(`/api/viewer/delete`, options); 119 | }; 120 | 121 | export const deletePost = (id: string) => { 122 | const options = { 123 | method: 'DELETE', 124 | headers: requestHeaders, 125 | credentials: 'include', 126 | }; 127 | //@ts-ignore 128 | return fetch(`/api/posts/${id}`, options); 129 | }; 130 | 131 | export const updatePost = ({ 132 | content, 133 | title, 134 | postId, 135 | }: { 136 | content: string; 137 | title: string; 138 | postId: string; 139 | }) => { 140 | const options = { 141 | method: 'PUT', 142 | credentials: 'include', 143 | headers: requestHeaders, 144 | body: JSON.stringify({ content, title }), 145 | }; 146 | //@ts-ignore 147 | return fetch(`/api/posts/${postId}`, options); 148 | }; 149 | 150 | export const savePost = ({ 151 | content, 152 | title, 153 | }: { 154 | content: string; 155 | title: string; 156 | }) => { 157 | const options = { 158 | method: 'POST', 159 | credentials: 'include', 160 | headers: requestHeaders, 161 | body: JSON.stringify({ content, title }), 162 | }; 163 | 164 | //@ts-ignore 165 | return fetch(`/api/posts`, options); 166 | }; 167 | 168 | export const getAllUsers = () => { 169 | const options = { 170 | method: 'GET', 171 | headers: requestHeaders, 172 | credentials: 'include', 173 | }; 174 | //@ts-ignore 175 | return fetch(`/api/users`, options); 176 | }; 177 | 178 | export const login = ({ 179 | username, 180 | password, 181 | }: { 182 | username: string; 183 | password: string; 184 | }) => { 185 | const options = { 186 | method: 'POST', 187 | headers: requestHeaders, 188 | credentials: 'include', 189 | body: JSON.stringify({ username: username.toLowerCase(), password }), 190 | }; 191 | //@ts-ignore 192 | return fetch(`/api/login`, options); 193 | }; 194 | 195 | export const logout = () => { 196 | const options = { 197 | method: 'POST', 198 | credentials: 'include', 199 | headers: requestHeaders, 200 | }; 201 | //@ts-ignore 202 | return fetch(`/api/logout`, options); 203 | }; 204 | 205 | export const signup = ({ 206 | username, 207 | password, 208 | verify, 209 | }: { 210 | username: string; 211 | password: string; 212 | verify: string; 213 | }) => { 214 | const options = { 215 | method: 'POST', 216 | headers: requestHeaders, 217 | credentials: 'include', 218 | body: JSON.stringify({ 219 | username: username.toLowerCase(), 220 | password, 221 | verify, 222 | }), 223 | }; 224 | //@ts-ignore 225 | return fetch(`/api/signup`, options); 226 | }; 227 | -------------------------------------------------------------------------------- /common/store.tsx: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunkMiddleware from 'redux-thunk'; 3 | import { IUser, IPost, IComment } from '../data-models'; 4 | 5 | export interface IAppState { 6 | users: IUser[]; 7 | posts: IPost[]; 8 | comments: IComment[]; 9 | post: IPost; 10 | viewer: any; 11 | isAuthenticated: boolean; 12 | } 13 | 14 | const INITIAL_STATE: IAppState = { 15 | users: [], 16 | posts: [], 17 | comments: [], 18 | post: undefined, 19 | viewer: undefined, 20 | isAuthenticated: false, 21 | }; 22 | 23 | const mergeUpdatedKeys = (data: any, state: IAppState) => { 24 | return { ...state, ...data }; 25 | }; 26 | 27 | const mergeAuthState = ( 28 | { isAuthenticated, viewer }: { isAuthenticated: boolean; viewer: any }, 29 | state: IAppState 30 | ) => { 31 | return { ...state, isAuthenticated, viewer }; 32 | }; 33 | 34 | const mergeLogoutState = (state: IAppState) => { 35 | return { ...state, isAuthenticated: false, viewer: undefined }; 36 | }; 37 | 38 | export const reducer = (state = INITIAL_STATE, action: any) => { 39 | switch (action.type) { 40 | case 'UPDATE_STORE_KEYS': 41 | return mergeUpdatedKeys(action.data, state); 42 | case 'VIEWER_AUTHENTICATED': 43 | return mergeAuthState(action, state); 44 | case 'VIEWER_LOGOUT': 45 | return mergeLogoutState(state); 46 | default: 47 | return state; 48 | } 49 | }; 50 | 51 | export const initStore = (initialState: IAppState) => { 52 | return createStore(reducer, initialState, applyMiddleware(thunkMiddleware)); 53 | }; 54 | -------------------------------------------------------------------------------- /common/strings.tsx: -------------------------------------------------------------------------------- 1 | export const elide = (input: string, length = 140) => { 2 | if (isEmpty(input)) { 3 | return '...'; 4 | } 5 | 6 | if (input.length < length) { 7 | return input.trim(); 8 | } 9 | 10 | return `${input.substring(0, length)}...`; 11 | }; 12 | 13 | export const toDate = (input: string) => { 14 | const date = new Date(input); 15 | return `${date.getMonth() + 1}-${date.getDate()}-${date.getFullYear()}`; 16 | }; 17 | 18 | export const isEmpty = (input: string) => { 19 | return !input || input.length === 0; 20 | }; 21 | 22 | export const pluralize = (text: string, count: number) => { 23 | return count > 1 || count === 0 ? `${text}s` : text; 24 | }; 25 | -------------------------------------------------------------------------------- /components/AuthLoginForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Actions from '../common/actions'; 3 | import * as Strings from '../common/strings'; 4 | 5 | import Input from './Input'; 6 | import Button from './Button'; 7 | import Border from './Border'; 8 | 9 | import { connect } from 'react-redux'; 10 | 11 | interface IState {} 12 | 13 | interface IProps { 14 | dispatch: any; 15 | style?: any; 16 | } 17 | 18 | class AuthLoginForm extends React.Component { 19 | state = { 20 | username: '', 21 | password: '', 22 | }; 23 | 24 | _handleChange = (e: React.ChangeEvent) => { 25 | this.setState({ [e.target.name]: e.target.value }); 26 | }; 27 | 28 | _handleSubmit = (e: React.ChangeEvent) => { 29 | this.props.dispatch(Actions.requestLogin(this.state)); 30 | }; 31 | 32 | render() { 33 | return ( 34 |
35 | 42 | 50 | 53 |
54 | ); 55 | } 56 | } 57 | 58 | export default connect(state => state)(AuthLoginForm); 59 | -------------------------------------------------------------------------------- /components/AuthSignupForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Actions from '../common/actions'; 3 | 4 | import Input from './Input'; 5 | import Button from './Button'; 6 | import Border from './Border'; 7 | 8 | import { connect } from 'react-redux'; 9 | 10 | interface IState {} 11 | 12 | interface IProps { 13 | dispatch: any; 14 | style?: any; 15 | } 16 | 17 | class AuthSignupForm extends React.Component { 18 | state = { 19 | username: '', 20 | password: '', 21 | verify: '', 22 | }; 23 | 24 | _handleChange = (e: React.ChangeEvent) => { 25 | this.setState({ [e.target.name]: e.target.value }); 26 | }; 27 | 28 | _handleSubmit = (e: React.ChangeEvent) => { 29 | this.props.dispatch(Actions.requestSignup(this.state)); 30 | }; 31 | 32 | render() { 33 | return ( 34 |
35 | 41 | 48 | 56 | 59 |
60 | ); 61 | } 62 | } 63 | 64 | export default connect(state => state)(AuthSignupForm); 65 | -------------------------------------------------------------------------------- /components/Border.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'react-emotion'; 2 | 3 | export default styled('hr')` 4 | border: 0; 5 | margin: 0; 6 | height: 1px; 7 | border-bottom: 1px solid #ececec; 8 | `; 9 | -------------------------------------------------------------------------------- /components/BorderedItem.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'react-emotion'; 2 | 3 | export default styled('div')` 4 | display: inline-block; 5 | `; 6 | -------------------------------------------------------------------------------- /components/BoxHeaderLayout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import styled from 'react-emotion'; 4 | 5 | const Header = styled('header')` 6 | display: flex; 7 | align-items: center; 8 | justify-content: space-between; 9 | margin-bottom: 4px; 10 | `; 11 | 12 | const Left = styled('div')` 13 | min-width: 25%; 14 | width: 100%; 15 | `; 16 | 17 | const Right = styled('div')` 18 | flex-shrink: 0; 19 | `; 20 | 21 | interface IState {} 22 | 23 | interface IProps { 24 | style?: any; 25 | right?: any; 26 | } 27 | 28 | export default class BoxHeaderLayout extends React.Component { 29 | render() { 30 | return ( 31 |
32 | {this.props.children} 33 | {this.props.right} 34 |
35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { css } from 'react-emotion'; 2 | 3 | const buttonStyle = css``; 4 | 5 | interface IProps { 6 | children: React.ReactNode; 7 | onClick?: (...args: any) => any; 8 | style?: any; 9 | } 10 | 11 | export default (props: IProps) => ( 12 | 15 | ); 16 | -------------------------------------------------------------------------------- /components/ColumnLayout.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'react-emotion'; 2 | 3 | export default styled('div')` 4 | max-width: 672px; 5 | width: 100%; 6 | margin: 0 auto 0 auto; 7 | padding: 48px 16px 228px 16px; 8 | `; 9 | -------------------------------------------------------------------------------- /components/CommentForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Border from './Border'; 3 | import Textarea from './Textarea'; 4 | import Button from './Button'; 5 | import * as Actions from '../common/actions'; 6 | import * as Text from './Text'; 7 | import { connect } from 'react-redux'; 8 | 9 | interface IState { 10 | content: string; 11 | } 12 | 13 | interface IProps { 14 | postId: string; 15 | commentId: string; 16 | title: string; 17 | isReplying: any; 18 | dispatch: any; 19 | onCancel: (...args: any) => any; 20 | autoFocus: any; 21 | placeholder: string; 22 | } 23 | 24 | class CommentForm extends React.Component { 25 | state = { 26 | content: '', 27 | }; 28 | 29 | _handleContentChange = (e: React.ChangeEvent) => { 30 | this.setState({ content: e.target.value }); 31 | }; 32 | 33 | _handleSend = (e: React.ChangeEvent) => { 34 | const data = { 35 | commentId: this.props.commentId, 36 | content: this.state.content, 37 | postId: this.props.postId, 38 | }; 39 | 40 | if (this.props.commentId) { 41 | return this.props.dispatch(Actions.requestSaveReply(data)); 42 | } 43 | 44 | return this.props.dispatch(Actions.requestSaveComment(data)); 45 | }; 46 | 47 | render() { 48 | return ( 49 |
50 |
51 | {this.props.title} 52 |
53 | {this.props.isReplying ? ( 54 | 55 | ) : ( 56 | undefined 57 | )} 58 |
59 |
60 |
61 |