├── views
├── footer.pug
├── 404.pug
├── editRecipe.pug
├── viewCookbooks.pug
├── viewRecipes.pug
├── mixins
│ ├── _recipeForm.pug
│ ├── _cookbook.pug
│ ├── _cookbookModal.pug
│ └── _recipe.pug
├── editCookbook.pug
├── login.pug
├── index.pug
└── layout.pug
├── public
├── js
│ ├── modules
│ │ ├── getUserCookbooks.js
│ │ ├── findDOMAncestor.js
│ │ ├── findDOMCousin.js
│ │ ├── searchResultsHTML.js
│ │ ├── bling.js
│ │ ├── closeCookbookMenu.js
│ │ ├── postNewCookbook.js
│ │ ├── addToCookbook.js
│ │ ├── openCookbookMenu.js
│ │ └── typeAhead.js
│ └── bundle.js
└── css
│ └── app.css
├── utils
├── authorize.js
└── dbConnect.js
├── controllers
├── landing.controller.js
├── helpers
│ ├── readAllObjects.js
│ ├── editObject.js
│ ├── updateObject.js
│ └── destroyObject.js
├── recipe.controller.js
└── cookbook.controller.js
├── test
└── authorize.test.js
├── models
├── cookbook.model.js
└── recipe.model.js
├── package.json
├── handlers
└── error.handler.js
├── routes
└── index.routes.js
├── app.js
└── README.md
/views/footer.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 |
4 | block footer
5 | footer.footer
6 | p.test
--------------------------------------------------------------------------------
/views/404.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block content
4 | h1.has-text-centered The page you're looking for was not found!
--------------------------------------------------------------------------------
/public/js/modules/getUserCookbooks.js:
--------------------------------------------------------------------------------
1 | function getUserCookbooks (user_id) {
2 | return fetch(`/cookbook/getAll/${user_id}`)
3 | .then(response => response.json())
4 | }
--------------------------------------------------------------------------------
/views/editRecipe.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | include mixins/_recipeForm
4 |
5 | block content
6 | section.section
7 | .container
8 | h1= title
9 | .card
10 | +recipeForm(data)
11 |
12 |
--------------------------------------------------------------------------------
/public/js/modules/findDOMAncestor.js:
--------------------------------------------------------------------------------
1 | function findDOMAncestorByClass (el, cls) {
2 | // finds the closest DOM ancestor with a given class
3 | return el.classList.contains(cls) ? el : findDOMAncestorByClass(el.parentElement, cls)
4 | }
--------------------------------------------------------------------------------
/public/js/modules/findDOMCousin.js:
--------------------------------------------------------------------------------
1 | function findDOMCousin (el, parentSelector, cousinSelector) {
2 | // finds the first DOM child with a given selector, given a DOM parent selector
3 | return el.closest(parentSelector).querySelector(cousinSelector)
4 | }
--------------------------------------------------------------------------------
/utils/authorize.js:
--------------------------------------------------------------------------------
1 | function authorize (user_id, author_id) {
2 | // verifies the current user is the author of a given object
3 | if (!author_id || !user_id) return false
4 | if (author_id != user_id) return false
5 | return true
6 | }
7 |
8 | module.exports = authorize
--------------------------------------------------------------------------------
/views/viewCookbooks.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | include mixins/_cookbook
4 |
5 | block content
6 | section.section
7 | .container
8 | h1= title
9 | if title
10 | hr
11 | each cookbook in data
12 | +cookbook(cookbook)
13 |
14 |
15 |
--------------------------------------------------------------------------------
/views/viewRecipes.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | include mixins/_recipe
4 |
5 | block content
6 | section.section
7 | .container
8 | h1= title
9 | if title
10 | hr
11 | each recipe in data
12 | +recipe(recipe, cookbook || {})
13 |
14 |
15 |
--------------------------------------------------------------------------------
/utils/dbConnect.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose')
2 |
3 | function dbConnect (db) {
4 | mongoose.connect(db)
5 | mongoose.Promise = global.Promise
6 | mongoose.connection.on('error', (err) => {
7 | console.error(`Ya dun goofed. ${err.message}`)
8 | })
9 | }
10 |
11 | module.exports = dbConnect
--------------------------------------------------------------------------------
/public/js/modules/searchResultsHTML.js:
--------------------------------------------------------------------------------
1 | function searchResultsHTML (searchResults) {
2 | // generate HTML for an array of search results
3 | return searchResults.map(result => {
4 | return `
5 |
6 | ${result.title}
7 |
8 | `
9 | }).join('')
10 | }
--------------------------------------------------------------------------------
/controllers/landing.controller.js:
--------------------------------------------------------------------------------
1 | exports.home = (req, res) => {
2 | let current_user = req.user
3 | res.render('index', { user: current_user })
4 | }
5 |
6 | exports.login = (req, res) => {
7 | res.render('login', {env: process.env})
8 | }
9 |
10 | exports.logout = async (req, res) => {
11 | await req.logout()
12 | req.flash('success', 'Logged out!')
13 | res.redirect('/')
14 | }
--------------------------------------------------------------------------------
/views/mixins/_recipeForm.pug:
--------------------------------------------------------------------------------
1 | mixin recipeForm(data = {})
2 | .card
3 | .card-content
4 | form(action=`/recipe/edit/${data.slug || ''}` method="POST")
5 | .field
6 | label.label Title
7 | p.control
8 | input.input(value=data.title name="title")
9 | .field
10 | label.label Content
11 | p.control
12 | textarea.textarea(name="content")= data.content
13 | .field
14 | p.control
15 | input.button.is-info(type="submit" value="Save")
--------------------------------------------------------------------------------
/views/editCookbook.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block content
4 | section.section
5 | .container
6 | h1= title
7 | if title
8 | hr
9 | .card
10 | .card-content
11 | form(action=`/cookbook/edit/${data.slug || ''}` method="POST")
12 | .field
13 | label.label Title
14 | p.control
15 | input.input(value=data.title name="title")
16 | .field
17 | p.control
18 | input.button.is-info(type="submit" value="Save")
--------------------------------------------------------------------------------
/public/js/modules/bling.js:
--------------------------------------------------------------------------------
1 | // based on https://gist.github.com/paulirish/12fb951a8b893a454b32
2 |
3 | const $ = document.querySelector.bind(document);
4 | const $$ = document.querySelectorAll.bind(document);
5 |
6 | Node.prototype.on = window.on = function (name, fn) {
7 | this.addEventListener(name, fn);
8 | };
9 |
10 | NodeList.prototype.__proto__ = Array.prototype; // eslint-disable-line
11 |
12 | NodeList.prototype.on = NodeList.prototype.addEventListener = function (name, fn) {
13 | this.forEach((elem) => {
14 | elem.on(name, fn);
15 | });
16 | };
--------------------------------------------------------------------------------
/controllers/helpers/readAllObjects.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose')
2 | const Cookbook = mongoose.model('Cookbook')
3 | const Recipe = mongoose.model('Recipe')
4 | const authorize = require('../../utils/authorize.js')
5 |
6 | readAllObjects = (objectType, objectName) =>
7 | async (req, res) => {
8 | let objects = await objectType.find({ 'author': req.user.id})
9 | res.render(`view${objectName}s`, {
10 | data: objects,
11 | authorized: true,
12 | title: `Your ${objectName}s`
13 | })
14 | }
15 |
16 | module.exports = readAllObjects
--------------------------------------------------------------------------------
/views/mixins/_cookbook.pug:
--------------------------------------------------------------------------------
1 | mixin cookbook(cookbook = {})
2 | .box
3 | h1
4 | a(href=`/cookbook/view/${cookbook.slug}`)= cookbook.title
5 | nav.level.is-mobile
6 | .level-left
7 | if authorized
8 | a.level-item(href=`/cookbook/edit/${cookbook.slug}`)
9 | span.icon.is-medium
10 | i.fa.fa-pencil-square-o
11 | a.level-item(onclick=`if (confirm('Are you sure you want to delete?')) window.location = "/cookbook/delete/${cookbook._id}"`)
12 | span.icon.is-medium
13 | i.fa.fa-trash
--------------------------------------------------------------------------------
/public/js/modules/closeCookbookMenu.js:
--------------------------------------------------------------------------------
1 | function closeCookbookMenu (tag, flashData) {
2 | tag.classList.add('is-loading')
3 | setTimeout(() => {
4 | document.querySelector('.is-active').classList.remove('is-active')
5 | tag.classList.remove('is-loading')
6 | document.getElementsByClassName('flashes__container')[0].innerHTML = `
7 |
8 |
${flashData.message}
9 |
10 |
11 | `
12 | }, 1000)
13 | }
--------------------------------------------------------------------------------
/test/authorize.test.js:
--------------------------------------------------------------------------------
1 | const authorize = require('../utils/authorize')
2 | const chai = require('chai')
3 | const expect = chai.expect
4 |
5 | describe('It should authorize a user', function () {
6 | it('returns false without a user_id and an author_id', function () {
7 | expect(authorize()).to.eql(false)
8 | })
9 |
10 | it('returns false with a mismatched user_id and an author_id', function () {
11 | expect(authorize('123', 'abc')).to.eql(false)
12 | })
13 |
14 | it('returns true with matching user_id and an author_id', function () {
15 | expect(authorize('123', '123')).to.eql(true)
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/controllers/helpers/editObject.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose')
2 | const Cookbook = mongoose.model('Cookbook')
3 | const Recipe = mongoose.model('Recipe')
4 | const authorize = require('../../utils/authorize.js')
5 |
6 | let editObject = (objectType, modelName) =>
7 | async (req, res) => {
8 | let object = await objectType.findOne({ 'slug': req.params.slug })
9 |
10 | if (!authorize(req.user.id, object.author)) {
11 | req.flash('warning', 'You must be the author to edit this!')
12 | res.redirect('/')
13 | return
14 | }
15 |
16 | res.render(`edit${modelName}`, {
17 | title: `Edit ${modelName}`,
18 | data: object
19 | })
20 | }
21 |
22 | module.exports = editObject
23 |
--------------------------------------------------------------------------------
/public/css/app.css:
--------------------------------------------------------------------------------
1 | body a {
2 | color: #3273dc;
3 | }
4 |
5 | footer.footer {
6 | padding: 0px;
7 | }
8 |
9 | .is-fixed {
10 | position: fixed;
11 | }
12 |
13 | .is-fixed-bottom {
14 | bottom: 0;
15 | }
16 |
17 | .is-full-width {
18 | /* .is-fullwidth should work in bulma, but doesn't */
19 | width: 100%;
20 | }
21 |
22 | .search__result p {
23 | padding-bottom: 10px;
24 | border-bottom: 1px solid rgba(10,10,10,.1);
25 | }
26 |
27 | .search__results {
28 | background-color: white;
29 | color: #3273dc;
30 | display: none;
31 | overflow: hidden;
32 | position: absolute;
33 | text-align: center;
34 | width: 100%;
35 | z-index: 99;
36 | }
37 |
38 | .search input.input {
39 | background-color: #363636;
40 | border: none;
41 | border-radius: 0px;
42 | color: #f5f5f5;
43 | outline: none;
44 | }
--------------------------------------------------------------------------------
/public/js/modules/postNewCookbook.js:
--------------------------------------------------------------------------------
1 | function postNewCookbook (btnTag) {
2 | let form = findDOMAncestorByClass(btnTag, 'cookbook__form')
3 | let inputs = form.getElementsByTagName('input')
4 | let payload = {
5 | recipes: []
6 | }
7 | // get recipe from form
8 | for (var i of inputs) {
9 | i.name == 'recipe' ?
10 | payload.recipes.push(i.value) :
11 | payload[i.name] = i.value
12 | }
13 |
14 | fetch(
15 | '/cookbook/new',
16 | {
17 | method: 'POST',
18 | body: JSON.stringify(payload),
19 | credentials: 'same-origin',
20 | headers: {
21 | 'Accept': 'application/json, text/plain, */*',
22 | 'Content-Type': 'application/json'
23 | },
24 | }
25 | )
26 | .then(res => res.json())
27 | .then(data => closeCookbookMenu(btnTag, data))
28 | return false
29 | }
--------------------------------------------------------------------------------
/models/cookbook.model.js:
--------------------------------------------------------------------------------
1 | const slug = require('slugs')
2 | const mongoose = require('mongoose'),
3 | Schema = mongoose.Schema
4 | mongoose.Promise = global.Promise
5 |
6 | const cookbookSchema = new mongoose.Schema({
7 | author: {
8 | required: true,
9 | type: String,
10 | },
11 | recipes: [{ type : Schema.Types.ObjectId, ref: 'Recipie' }],
12 | slug: String,
13 | title: {
14 | required: 'Title is required',
15 | type: String,
16 | trim: true,
17 | },
18 | })
19 |
20 | cookbookSchema.pre('save', function (next) {
21 | // if name hasn't changed, don't do anything
22 | if (!this.isModified('title')) { return next() }
23 | // add current time in ms to title to generate unique slugs
24 | this.slug = slug(`${this.title}-${new Date().getTime()}`)
25 | next()
26 | })
27 |
28 | module.exports = mongoose.model('Cookbook', cookbookSchema)
29 |
--------------------------------------------------------------------------------
/public/js/modules/addToCookbook.js:
--------------------------------------------------------------------------------
1 | function addToCookbook (cookbookID, btnTag) {
2 | let form = findDOMCousin(btnTag, '.box', '.cookbook__form' )
3 | let inputs = form.getElementsByTagName('input')
4 | let payload = {
5 | recipe: ''
6 | }
7 |
8 | for (var i of inputs) {
9 | if (i.name == 'recipe') payload.recipe = i.value
10 | }
11 |
12 | // assign author from form
13 | payload['author'] = form.querySelector("[name='author']").value
14 |
15 | fetch(
16 | `/cookbook/addRecipe/${cookbookID}`,
17 | {
18 | method: 'PUT',
19 | body: JSON.stringify(payload),
20 | credentials: 'same-origin',
21 | headers: {
22 | 'Accept': 'application/json, text/plain, */*',
23 | 'Content-Type': 'application/json'
24 | },
25 | }
26 | )
27 | .then(res => res.json())
28 | .then(data => closeCookbookMenu(btnTag, data))
29 | return false
30 | }
--------------------------------------------------------------------------------
/controllers/helpers/updateObject.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose')
2 | const Cookbook = mongoose.model('Cookbook')
3 | const Recipe = mongoose.model('Recipe')
4 | const authorize = require('../../utils/authorize.js')
5 |
6 | let updateObject = (objectType) =>
7 | async (req, res) => {
8 | let object_data = await objectType.findOne({ 'slug': req.params.slug })
9 |
10 | if (!authorize(req.user.id, object_data.author)) {
11 | req.flash('warning', 'You must be the author to update this!')
12 | // return to previous rotue or go home
13 | res.redirect(redirectRoute || '/')
14 | return
15 | }
16 |
17 | let object = await objectType.findOneAndUpdate(
18 | { 'slug': req.params.slug },
19 | req.body,
20 | { new: true, runValidators: true }
21 | ).exec()
22 | res.redirect(req.header('Referrer') || '/')
23 | }
24 |
25 | module.exports = updateObject
26 |
--------------------------------------------------------------------------------
/views/login.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block content
4 |
5 | script.
6 |
7 | var lock = new Auth0Lock(
8 | '#{env.AUTH0_CLIENT}',
9 | '#{env.AUTH0_DOMAIN}',
10 | {
11 | auth: {
12 | redirectUrl: '#{env.AUTH0_CALLBACK}',
13 | rememberLastLogin: true,
14 | },
15 | closable: false
16 | }
17 | )
18 |
19 | lock.on("authenticated", function (authResult) {
20 | // Use the token in authResult to getUserInfo() and save it to localStorage
21 | lock.getUserInfo(authResult.accessToken, function (error, profile) {
22 | if (error) {
23 | // Handle error
24 | console.warn(error)
25 | return;
26 | }
27 |
28 | localStorage.setItem('accessToken', authResult.accessToken)
29 | localStorage.setItem('profile', JSON.stringify(profile))
30 | })
31 | })
32 |
33 | lock.show()
--------------------------------------------------------------------------------
/models/recipe.model.js:
--------------------------------------------------------------------------------
1 | const slug = require('slugs')
2 | const mongoose = require('mongoose')
3 | mongoose.Promise = global.Promise
4 |
5 | const recipeSchema = new mongoose.Schema({
6 | title: {
7 | type: String,
8 | trim: true,
9 | required: 'Title is required'
10 | },
11 | content: {
12 | type: String,
13 | trim: true,
14 | required: 'Content is required'
15 | },
16 | slug: String,
17 | author: String
18 | })
19 |
20 | // index data for faster searching
21 | recipeSchema.index({
22 | title: 'text',
23 | content: 'text'
24 | })
25 |
26 | recipeSchema.pre('save', function (next) {
27 | // if name hasn't changed, don't do anything
28 | if (!this.isModified('title')) { return next() }
29 | // add current time in ms to title to generate unique slugs
30 | this.slug = slug(`${this.title}-${new Date().getTime()}`)
31 | next()
32 | })
33 |
34 | module.exports = mongoose.model('Recipe', recipeSchema)
--------------------------------------------------------------------------------
/views/mixins/_cookbookModal.pug:
--------------------------------------------------------------------------------
1 | mixin cookbookModal(recipe = {})
2 | if locals.user
3 | .modal.cookbook__modal
4 | .modal-background(onclick="this.parentElement.classList.remove('is-active')")
5 | .modal-content
6 | .box
7 | p
8 | strong Add to Cookbook
9 | form.form.cookbook__form
10 | .control
11 | input.input(type="hidden" value=recipe._id name="recipe")
12 | .control
13 | input.input(type="hidden" value=locals.user.id name="author")
14 | .field.has-addons
15 | .control.is-expanded
16 | input.input.cookbook__input(placeholder="New cookbook", name='title')
17 | .control
18 | button.button.is-info(onclick=`return postNewCookbook(this)`)
19 | span.icon
20 | i.fa.fa-check
21 | hr
22 | .cookbook__modal__list
23 |
--------------------------------------------------------------------------------
/controllers/helpers/destroyObject.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose')
2 | const Cookbook = mongoose.model('Cookbook')
3 | const Recipe = mongoose.model('Recipe')
4 | const authorize = require('../../utils/authorize.js')
5 |
6 |
7 | destroyObject = (objectType) =>
8 | async (req, res) => {
9 | let redirectRoute = req.header('Referrer')
10 | let object = await objectType.findOne({ _id: req.params.id })
11 |
12 | // user must be authorized before deletion
13 | if (!authorize(req.user.id, object.author)) {
14 | req.flash('warning', 'You must be the author to destroy this!')
15 | // return to previous rotue or go home
16 | res.redirect(redirectRoute || '/')
17 | return
18 | }
19 |
20 | await objectType.findOneAndRemove({ _id: req.params.id })
21 | req.flash('success', `${object.title} succesfully removed!`)
22 | // return to previous rotue or go home
23 | res.redirect( redirectRoute || '/')
24 | }
25 |
26 | module.exports = destroyObject
--------------------------------------------------------------------------------
/public/js/modules/openCookbookMenu.js:
--------------------------------------------------------------------------------
1 | function openCookbookMenu(user_id, linkTag) {
2 | let modalTag = findDOMCousin(linkTag, '.box', '.cookbook__modal')
3 | modalTag.classList.add('is-active')
4 | getUserCookbooks(user_id)
5 | .then(results => {
6 | let listDiv = modalTag.getElementsByClassName('cookbook__modal__list')[0]
7 | let listHTML = results.map(cookbook => {
8 | return `
9 |
10 |
11 |
12 | ${cookbook.title}
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | `
27 | }).join('')
28 | listDiv.innerHTML = listHTML
29 | })
30 | }
--------------------------------------------------------------------------------
/public/js/modules/typeAhead.js:
--------------------------------------------------------------------------------
1 | function typeAhead (search) {
2 | if (!search) { return }
3 |
4 | let searchInput = search.querySelector('.input')
5 | let searchResults = search.querySelector('.search__results')
6 |
7 | searchInput.on('input', function () {
8 | // prevent unneccessary queries
9 | if (this.value.length < 3) {
10 | searchResults.style.display = 'none'
11 | return
12 | }
13 | // get the search data
14 | let query = `/search?q=${this.value}`
15 | fetch(query)
16 | .then(response => response.json())
17 | .then(results => {
18 | // hide results div if there is no search result
19 | if (!results.length) {
20 | searchResults.style.display = 'none'
21 | return
22 | }
23 | // show results
24 | searchResults.style.display = 'block'
25 | // append html
26 | searchResults.innerHTML = searchResultsHTML(results)
27 |
28 | })
29 | })
30 | }
31 |
32 | // attach typeAhead script to the DOM node with class 'search'
33 | typeAhead($('.search'))
--------------------------------------------------------------------------------
/views/mixins/_recipe.pug:
--------------------------------------------------------------------------------
1 | mixin recipe(recipe = {}, cookbook = {})
2 | include _cookbookModal
3 | .box
4 | +cookbookModal(recipe)
5 | h4= recipe.title
6 | pre= recipe.content
7 | nav.level.is-mobile
8 | .level-left
9 | if authorized
10 | a.level-item(href=`/recipe/edit/${recipe.slug}`)
11 | span.icon.is-small
12 | i.fa.fa-pencil-square-o
13 | a.level-item(onclick=`if (confirm('Are you sure you want to delete?')) window.location = "/recipe/delete/${recipe._id}"`)
14 | span.icon.is-small
15 | i.fa.fa-trash
16 | if cookbook.id
17 | a.level-item(onclick=`if (confirm('Are you sure you want to remove this from the cookbook?')) window.location = "/cookbook/removeRecipe/${cookbook.id}/${recipe._id}"`)
18 | span.icon.is-small
19 | i.fa.fa-chain-broken
20 | if locals.user
21 | a.level-item
22 | span.icon.is-small(onclick=`openCookbookMenu('${locals.user.id}', this)`)
23 | i.fa.fa-plus
24 |
--------------------------------------------------------------------------------
/views/index.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block content
4 | section.section
5 | .container
6 | h1.title How Many Times Did You Visit Stack Overflow Today?
7 | p If you're like most software developers, the answer is...
8 | strong too many to count!
9 | p.
10 | Constant searching of Stack Overflow, digging through documentation, and scouring forums is all too common.
11 | Usually, the search is for something you already know - its on the tip of your tongue - but you can't remember the syntax.
12 | p.
13 | Code Cookbook aims to change that. Its a lightweight platform for developers to create, organize, and search for
14 | code snippets, bash commands, and other obscure bits of information.
15 | p.
16 | Code Cookbook calls these bits of information recipes , and any time you create one, it gets linked to your account for easy access.
17 | You can also organize recipes into cookbooks . Use cookbooks as guides for deploying to Heroku, a syntax reminder for a framework
18 | you use once in a blue moon, or whatever else you can think of!
19 | p.
20 | Recipes are visible to everyone and can be found via the search bar at the top. As more users create recipes it will be easier to
21 | find those pesky bits of code.
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "codecookbook",
3 | "version": "1.0.0",
4 | "description": "An online utility to save, search, and organize snippets of text, such as code, quotes, bash/command line entries.",
5 | "main": "app.js",
6 | "now": {
7 | "dotenv": "variables.env.now"
8 | },
9 | "scripts": {
10 | "test": "node ./node_modules/mocha/bin/mocha",
11 | "start": "node app.js",
12 | "monitor": "npm run bundle && nodemon app.js",
13 | "bundle": "cd public/js/modules && awk 'FNR==0{print ''}1' *.js > ../bundle.js"
14 | },
15 | "keywords": [
16 | "snippets",
17 | "code",
18 | "cookbook",
19 | "bash"
20 | ],
21 | "author": "khan",
22 | "license": "ISC",
23 | "dependencies": {
24 | "auth0": "^2.7.0",
25 | "auth0-lock": "^10.18.0",
26 | "axios": "^0.16.2",
27 | "body-parser": "^1.17.2",
28 | "connect-ensure-login": "^0.1.1",
29 | "connect-flash": "^0.1.1",
30 | "connect-mongo": "^1.3.2",
31 | "cookie-parser": "^1.4.3",
32 | "express": "^4.15.3",
33 | "express-session": "^1.15.3",
34 | "express-validator": "^3.2.0",
35 | "mongoose": "^4.10.6",
36 | "passport": "^0.3.2",
37 | "passport-auth0": "^0.6.0",
38 | "pug": "^2.0.0-rc.2",
39 | "slug": "^0.9.1",
40 | "slugs": "^0.1.3"
41 | },
42 | "devDependencies": {
43 | "chai": "^4.0.2",
44 | "dotenv": "^4.0.0",
45 | "mocha": "^3.4.2",
46 | "node-mocks-http": "^1.6.3",
47 | "sinon": "^2.3.4",
48 | "sinon-mongoose": "^2.0.2",
49 | "supertest": "^3.0.0",
50 | "tape": "^4.6.3"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/controllers/recipe.controller.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose')
2 | const Recipe = mongoose.model('Recipe')
3 | const authorize = require('../utils/authorize.js')
4 | const destroyObject = require('./helpers/destroyObject')
5 | const editObject = require('./helpers/editObject')
6 | const updateObject = require('./helpers/updateObject')
7 | const readAllObjects = require('./helpers/readAllObjects')
8 |
9 |
10 | exports.create = async (req, res) => {
11 | req.body.author = req.user.id
12 | let recipe = new Recipe(req.body)
13 | await recipe.save()
14 | res.redirect('/')
15 | }
16 |
17 | exports.destroy = destroyObject(Recipe)
18 | exports.edit = editObject(Recipe, 'Recipe')
19 |
20 | exports.new = (req, res) => {
21 | let recipe = new Recipe()
22 | res.render('editRecipe', {
23 | title: 'New Recipe'
24 | })
25 | }
26 |
27 | exports.read = async (req, res) => {
28 | let recipe = await Recipe.findOne({ 'slug': req.params.slug })
29 | let data = [recipe]
30 | let authorized = req.user && authorize(req.user.id, recipe.author)
31 | res.render('viewRecipes', {
32 | data,
33 | authorized
34 | })
35 | }
36 |
37 | exports.readAll = readAllObjects(Recipe, 'Recipe')
38 |
39 | exports.search = async (req, res) => {
40 | let results = []
41 | if (req.query.q) {
42 | results = await Recipe.find(
43 | { $text: { $search: req.query.q } },
44 | { score: { $meta: 'textScore'} }
45 | )
46 | .sort({ score: { $meta: 'textScore'} })
47 | .limit(5)
48 | }
49 |
50 | // search is accessible via json, so we hide the irrelevant info
51 | sanitized_results = results.map(result => {
52 | return { title: result.title, slug: result.slug, content: result.content }
53 | })
54 |
55 | res.json(sanitized_results)
56 | }
57 |
58 | exports.update = updateObject(Recipe)
--------------------------------------------------------------------------------
/handlers/error.handler.js:
--------------------------------------------------------------------------------
1 | /*
2 | Catch Errors Handler
3 | With async/await, you need some way to catch errors
4 | Instead of using try{} catch(e) {} in each controller, we wrap the function in
5 | catchErrors(), catch any errors they throw, and pass it along to our express middleware with next()
6 | */
7 |
8 | exports.catchErrors = (fn) => {
9 | return function(req, res, next) {
10 | return fn(req, res, next).catch(next);
11 | };
12 | };
13 |
14 | /*
15 | Not Found Error Handler
16 | If we hit a route that is not found, we mark it as 404 and pass it along to the next error handler to display
17 | */
18 | exports.notFound = (req, res, next) => {
19 | const err = new Error('Not Found');
20 | err.status = 404;
21 | next(err);
22 | };
23 |
24 | /*
25 | MongoDB Validation Error Handler
26 | Detect if there are mongodb validation errors that we can nicely show via flash messages
27 | */
28 |
29 | exports.flashValidationErrors = (err, req, res, next) => {
30 | if (!err.errors) return next(err);
31 | // validation errors look like
32 | const errorKeys = Object.keys(err.errors);
33 | errorKeys.forEach(key => req.flash('warning', err.errors[key].message));
34 | res.redirect('back');
35 | };
36 |
37 |
38 | /*
39 | Development Error Handler
40 | In development we show good error messages so if we hit a syntax error or any other previously un-handled error, we can show good info on what happened
41 | */
42 | exports.developmentErrors = (err, req, res, next) => {
43 | err.stack = err.stack || '';
44 | const errorDetails = {
45 | message: err.message,
46 | status: err.status,
47 | stackHighlighted: err.stack.replace(/[a-z_-\d]+.js:\d+:\d+/gi, '$& ')
48 | };
49 | res.status(err.status || 500);
50 | res.format({
51 | // Based on the `Accept` http header
52 | 'text/html': () => {
53 | res.render('error', errorDetails);
54 | }, // Form Submit, Reload the page
55 | 'application/json': () => res.json(errorDetails) // Ajax call, send JSON back
56 | });
57 | };
58 |
59 |
60 | /*
61 | Production Error Handler
62 | No stacktraces are leaked to user
63 | */
64 | exports.productionErrors = (err, req, res, next) => {
65 | res.status(err.status || 500);
66 | res.render('error', {
67 | message: err.message,
68 | error: {}
69 | });
70 | };
--------------------------------------------------------------------------------
/views/layout.pug:
--------------------------------------------------------------------------------
1 |
2 | html(lang="en")
3 | head
4 | meta(charset="UTF-8")
5 | meta(name="viewport", content="width=device-width, initial-scale=1.0")
6 | meta(http-equiv="X-UA-Compatible", content="ie=edge")
7 | link(href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.4.3/css/bulma.min.css" rel="stylesheet")
8 | link(href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet")
9 | script(src="https://cdn.auth0.com/js/auth0/8.0.0/auth0.min.js")
10 | script(src="https://cdn.auth0.com/js/lock/10.18.0/lock.min.js")
11 |
12 | link(href="/css/app.css" rel="stylesheet")
13 |
14 | title Code Cookbook
15 | body
16 | section.hero.is-dark.is-medium
17 | .hero-head
18 | header.nav
19 | .container
20 | .nav-left
21 | a.nav-item(href='/') Code Cookbook
22 | .nav-right
23 | .nav-item
24 | form.is-dark
25 | .container
26 | .control.search
27 | input.input(placeholder="Search recipes...")
28 | .search__results
29 |
30 | block messages
31 | if locals.flashes
32 | section.section
33 | .container.flashes__container
34 | - const categories = Object.keys(locals.flashes)
35 | each category in categories
36 | each message in flashes[category]
37 | .notification(class=`is-${category}`)
38 | p.flash__text!= message
39 | button.delete(onClick="this.parentElement.remove()")
40 | .content
41 | block content
42 |
43 | section.section
44 |
45 | footer.footer.is-fixed.is-fixed-bottom.is-full-width
46 | .card
47 | .card-footer
48 | a.card-footer-item(href="/recipe/edit")
49 | span.icon
50 | i.fa.fa-pencil
51 | p.is-hidden-mobile New Recipe
52 | a.card-footer-item(href=`/recipe/${locals.user ? locals.user.id : 'edit'}`)
53 | span.icon
54 | i.fa.fa-file-text
55 | p.is-hidden-mobile My Recipes
56 | a.card-footer-item(href=`/cookbook/${locals.user ? locals.user.id : 'edit'}`)
57 | span.icon
58 | i.fa.fa-book
59 | p.is-hidden-mobile My Cookbooks
60 |
61 | if !locals.user
62 | a.card-footer-item(href="/login")
63 | span.icon
64 | i.fa.fa-user
65 | span.is-hidden-mobile Login
66 | else
67 | a.card-footer-item(href="/logout")
68 | span.icon
69 | i.fa.fa-user
70 | span.is-hidden-mobile Logout
71 |
72 | script(src='/js/bundle.js' type="text/javascript")
73 |
--------------------------------------------------------------------------------
/routes/index.routes.js:
--------------------------------------------------------------------------------
1 | const { catchErrors, flashValidationErrors } = require('../handlers/error.handler')
2 | const cookbook = require('../controllers/cookbook.controller')
3 | const ensureLoggedIn = require('connect-ensure-login').ensureLoggedIn()
4 | const express = require('express')
5 | const landing = require('../controllers/landing.controller')
6 | const passport = require('passport')
7 | const router = express.Router()
8 | const recipe = require('../controllers/recipe.controller')
9 |
10 | // home
11 | router.get('/', landing.home)
12 |
13 | // authentication (via Auth0 & passport)
14 | router.get('/login', landing.login)
15 | router.get('/logout', landing.logout)
16 | router.get('/callback',
17 | passport.authenticate('auth0', {
18 | failureRedirect: '/',
19 | failureFlash: 'There was a problem logging in.',
20 | successFlash: 'You are now logged in!'
21 | }),
22 | function (req, res) {
23 | res.redirect(req.session.returnTo || '/')
24 | }
25 | )
26 |
27 |
28 | // search recipe
29 | router.get('/search', catchErrors(recipe.search))
30 | // view recipe
31 | router.get('/recipe/view/:slug', catchErrors(recipe.read))
32 | // new recipe
33 | router.get('/recipe/edit', ensureLoggedIn, recipe.new)
34 | router.post('/recipe/edit', ensureLoggedIn, catchErrors(recipe.create), flashValidationErrors)
35 | // edit recipe
36 | router.get('/recipe/edit/:slug', ensureLoggedIn, catchErrors(recipe.edit))
37 | router.post('/recipe/edit/:slug', ensureLoggedIn, catchErrors(recipe.update), flashValidationErrors)
38 | // destroy recipe
39 | router.get('/recipe/delete/:id', ensureLoggedIn, catchErrors(recipe.destroy))
40 | // view all recipe created by currently logged in account
41 | router.get('/recipe/:user_id', ensureLoggedIn, catchErrors(recipe.readAll))
42 |
43 |
44 | // view cookbooks
45 | router.get('/cookbook/view/:slug', cookbook.read)
46 | router.get('/cookbook/:user_id', ensureLoggedIn, cookbook.readAll)
47 | router.get('/cookbook/getAll/:user_id', cookbook.getAll)
48 |
49 | // save cookbook
50 | router.post('/cookbook/new', ensureLoggedIn, catchErrors(cookbook.create))
51 | // add to cookbook
52 | router.put('/cookbook/addRecipe/:cookbook_id', ensureLoggedIn, catchErrors(cookbook.addRecipe))
53 | // edit cookbook
54 | router.get('/cookbook/edit/:slug', ensureLoggedIn, catchErrors(cookbook.edit))
55 | router.post('/cookbook/edit/:slug', catchErrors(cookbook.update), flashValidationErrors)
56 | // destroy cookbook
57 | router.get('/cookbook/delete/:id', ensureLoggedIn, catchErrors(cookbook.destroy))
58 | //remove a recipe from a cookbook
59 | router.get('/cookbook/removeRecipe/:cookbook_id/:recipe_id', ensureLoggedIn, catchErrors(cookbook.removeRecipe))
60 |
61 | module.exports = router
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | const express = require('express'),
2 | bodyParser = require('body-parser'),
3 | cookieParser = require('cookie-parser'),
4 | expressValidator = require('express-validator'),
5 | app = express(),
6 | mongoose = require('mongoose'),
7 | dbConnect = require('./utils/dbConnect'),
8 | passport = require('passport'),
9 | Auth0Strategy = require('passport-auth0'),
10 | session = require('express-session'),
11 | flash = require('connect-flash'),
12 | MongoStore = require('connect-mongo')(session)
13 |
14 | // import enviornment variables
15 | require('dotenv').config({ path: 'variables.env' })
16 |
17 | // connect to db
18 | dbConnect(process.env.DATABASE)
19 |
20 | // import all models
21 | require('./models/cookbook.model')
22 | require('./models/recipe.model')
23 |
24 | // uses pug to render templates
25 | app.set('view engine', 'pug')
26 |
27 | // use the public folder to host static assets
28 | app.use(express.static('public'))
29 |
30 | // Takes the raw requests and turns them into usable properties on req.body
31 | app.use(bodyParser.json())
32 | app.use(bodyParser.urlencoded({ extended: true }))
33 |
34 | app.use(cookieParser())
35 | app.use(session({
36 | secret: 'shhhh',
37 | key: 'ohyeah',
38 | resave: true,
39 | saveUnitialized: true,
40 | store: new MongoStore({ mongooseConnection: mongoose.connection })
41 | }))
42 |
43 | // Exposes a bunch of methods for validating data.
44 | app.use(expressValidator())
45 |
46 | // // The flash middleware let's us use req.flash('error', 'Shit!'), which will then pass that message to the next page the user requests
47 | app.use(flash())
48 |
49 | // Use passport for authorization management
50 | app.use(passport.initialize())
51 | app.use(passport.session())
52 |
53 | // pass variables to our templates + all requests
54 | app.use((req, res, next) => {
55 | res.locals.flashes = req.flash()
56 | res.locals.user = req.user
57 | next()
58 | })
59 |
60 | const routes = require('./routes/index.routes')
61 | app.use('/', routes)
62 |
63 | // Configure Passport to use Auth0
64 | const strategy = new Auth0Strategy({
65 | domain: process.env.AUTH0_DOMAIN,
66 | clientID: process.env.AUTH0_CLIENT,
67 | clientSecret: process.env.AUTH0_SECRET,
68 | callbackURL: process.env.AUTH0_CALLBACK
69 | }, (accessToken, refreshToken, extraParams, profile, done) => {
70 | return done(null, profile)
71 | })
72 |
73 | passport.use(strategy)
74 |
75 | // This can be used to keep a smaller payload
76 | passport.serializeUser(function(user, done) {
77 | done(null, user);
78 | })
79 |
80 | passport.deserializeUser(function(user, done) {
81 | done(null, user)
82 | })
83 |
84 | // 404 handler
85 | app.use(function(req, res, next){
86 | res.status(404);
87 |
88 | res.format({
89 | html: function () {
90 | res.render('404', { url: req.url })
91 | },
92 | json: function () {
93 | res.json({ error: 'Not found' })
94 | },
95 | default: function () {
96 | res.type('txt').send('Not found')
97 | }
98 | })
99 | })
100 |
101 | // Start the app!
102 | app.listen(3000, function () {
103 | console.log('Example app listening on port 3000!')
104 | })
105 |
106 |
107 | module.exports = app
--------------------------------------------------------------------------------
/controllers/cookbook.controller.js:
--------------------------------------------------------------------------------
1 | const mongoose = require('mongoose')
2 | const Cookbook = mongoose.model('Cookbook')
3 | const authorize = require('../utils/authorize.js')
4 | const destroyObject = require('./helpers/destroyObject')
5 | const editObject = require('./helpers/editObject')
6 | const updateObject = require('./helpers/updateObject')
7 | const readAllObjects = require('./helpers/readAllObjects')
8 |
9 |
10 | exports.addRecipe = async (req, res) => {
11 | //res.header("Access-Control-Allow-Origin", "*")
12 | //res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
13 |
14 | let cookbook_data = await Cookbook.findOne({ "_id": req.params.cookbook_id })
15 | if (!authorize(req.user.id, cookbook_data.author)) {
16 | res.json({"message": "There was an issue adding that to the cookbook", "type": "warning"})
17 | return
18 | }
19 |
20 | let cookbook = await Cookbook.findOneAndUpdate(
21 | { '_id': req.params.cookbook_id },
22 | { $push: { recipes: req.body.recipe }
23 | }).exec()
24 | res.json({"message": "Recipe added to cookbook", "type": "success"})
25 | }
26 |
27 | exports.create = async (req, res) => {
28 | res.header("Access-Control-Allow-Origin", "*")
29 | res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
30 |
31 | if(!authorize(req.user.id, req.body.author)) {
32 | res.json({"message": "There was an issue creating cookbook", "type": "warning"})
33 | return
34 | }
35 |
36 | let cookbook = new Cookbook(req.body)
37 | await cookbook.save()
38 | res.json({"message": "New cookbook created and recipe added!", "type": "success"})
39 | }
40 |
41 | exports.destroy = destroyObject(Cookbook)
42 | exports.edit = editObject(Cookbook, 'Cookbook')
43 |
44 | exports.getAll = async (req, res) => {
45 | res.header("Access-Control-Allow-Origin", "*")
46 | res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
47 | let cookbooks = await Cookbook.find({ 'author': req.params.user_id})
48 | res.json(cookbooks)
49 | }
50 |
51 | exports.readAll = readAllObjects(Cookbook, 'Cookbook')
52 |
53 | exports.read = async (req, res) => {
54 | let cookbook = await Cookbook.findOne({ 'slug': req.params.slug})
55 | .populate({
56 | path: 'recipes',
57 | model: 'Recipe',
58 | })
59 | let authorized = authorize(req.user.id, cookbook.author)
60 | res.render('viewRecipes', {
61 | authorized,
62 | cookbook,
63 | data: cookbook.recipes,
64 | title: cookbook.title
65 | })
66 | }
67 |
68 | exports.removeRecipe = async (req, res) => {
69 | let cookbook = await Cookbook.findOne({ _id: req.params.cookbook_id })
70 |
71 | // user must be authorized to remove something from a cookbook
72 | if (!authorize(req.user.id, cookbook.author)) {
73 | req.flash('warning', 'You must be the author to remove this!')
74 | // return to previous route or go home
75 | res.redirect(req.header('Referrer') || '/')
76 | return
77 | }
78 |
79 | // remove recipe from cookbook
80 | let recipe_index = cookbook.recipes.indexOf(req.params.recipe_id)
81 | cookbook.recipes.splice(recipe_index, 1)
82 |
83 | await Cookbook.findOneAndUpdate({ _id: req.params.cookbook_id }, cookbook)
84 | req.flash('success', 'Recipe removed!')
85 | res.redirect(req.header('Referrer') || '/')
86 | }
87 |
88 | exports.update = updateObject(Cookbook)
--------------------------------------------------------------------------------
/public/js/bundle.js:
--------------------------------------------------------------------------------
1 | function addToCookbook (cookbookID, btnTag) {
2 | let form = findDOMCousin(btnTag, '.box', '.cookbook__form' )
3 | let inputs = form.getElementsByTagName('input')
4 | let payload = {
5 | recipe: ''
6 | }
7 |
8 | for (var i of inputs) {
9 | if (i.name == 'recipe') payload.recipe = i.value
10 | }
11 |
12 | // assign author from form
13 | payload['author'] = form.querySelector("[name='author']").value
14 |
15 | fetch(
16 | `/cookbook/addRecipe/${cookbookID}`,
17 | {
18 | method: 'PUT',
19 | body: JSON.stringify(payload),
20 | credentials: 'same-origin',
21 | headers: {
22 | 'Accept': 'application/json, text/plain, */*',
23 | 'Content-Type': 'application/json'
24 | },
25 | }
26 | )
27 | .then(res => res.json())
28 | .then(data => closeCookbookMenu(btnTag, data))
29 | return false
30 | }
31 | // based on https://gist.github.com/paulirish/12fb951a8b893a454b32
32 |
33 | const $ = document.querySelector.bind(document);
34 | const $$ = document.querySelectorAll.bind(document);
35 |
36 | Node.prototype.on = window.on = function (name, fn) {
37 | this.addEventListener(name, fn);
38 | };
39 |
40 | NodeList.prototype.__proto__ = Array.prototype; // eslint-disable-line
41 |
42 | NodeList.prototype.on = NodeList.prototype.addEventListener = function (name, fn) {
43 | this.forEach((elem) => {
44 | elem.on(name, fn);
45 | });
46 | };
47 | function closeCookbookMenu (tag, flashData) {
48 | tag.classList.add('is-loading')
49 | setTimeout(() => {
50 | document.querySelector('.is-active').classList.remove('is-active')
51 | tag.classList.remove('is-loading')
52 | document.getElementsByClassName('flashes__container')[0].innerHTML = `
53 |
54 |
${flashData.message}
55 |
56 |
57 | `
58 | }, 1000)
59 | }
60 | function findDOMAncestorByClass (el, cls) {
61 | // finds the closest DOM ancestor with a given class
62 | return el.classList.contains(cls) ? el : findDOMAncestorByClass(el.parentElement, cls)
63 | }
64 | function findDOMCousin (el, parentSelector, cousinSelector) {
65 | // finds the first DOM child with a given selector, given a DOM parent selector
66 | return el.closest(parentSelector).querySelector(cousinSelector)
67 | }
68 | function getUserCookbooks (user_id) {
69 | return fetch(`/cookbook/getAll/${user_id}`)
70 | .then(response => response.json())
71 | }
72 | function openCookbookMenu(user_id, linkTag) {
73 | let modalTag = findDOMCousin(linkTag, '.box', '.cookbook__modal')
74 | modalTag.classList.add('is-active')
75 | getUserCookbooks(user_id)
76 | .then(results => {
77 | let listDiv = modalTag.getElementsByClassName('cookbook__modal__list')[0]
78 | let listHTML = results.map(cookbook => {
79 | return `
80 |
81 |
82 |
83 | ${cookbook.title}
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | `
98 | }).join('')
99 | listDiv.innerHTML = listHTML
100 | })
101 | }
102 | function postNewCookbook (btnTag) {
103 | let form = findDOMAncestorByClass(btnTag, 'cookbook__form')
104 | let inputs = form.getElementsByTagName('input')
105 | let payload = {
106 | recipes: []
107 | }
108 | // get recipe from form
109 | for (var i of inputs) {
110 | i.name == 'recipe' ?
111 | payload.recipes.push(i.value) :
112 | payload[i.name] = i.value
113 | }
114 |
115 | fetch(
116 | '/cookbook/new',
117 | {
118 | method: 'POST',
119 | body: JSON.stringify(payload),
120 | credentials: 'same-origin',
121 | headers: {
122 | 'Accept': 'application/json, text/plain, */*',
123 | 'Content-Type': 'application/json'
124 | },
125 | }
126 | )
127 | .then(res => res.json())
128 | .then(data => closeCookbookMenu(btnTag, data))
129 | return false
130 | }
131 | function searchResultsHTML (searchResults) {
132 | // generate HTML for an array of search results
133 | return searchResults.map(result => {
134 | return `
135 |
136 | ${result.title}
137 |
138 | `
139 | }).join('')
140 | }
141 | function typeAhead (search) {
142 | if (!search) { return }
143 |
144 | let searchInput = search.querySelector('.input')
145 | let searchResults = search.querySelector('.search__results')
146 |
147 | searchInput.on('input', function () {
148 | // prevent unneccessary queries
149 | if (this.value.length < 3) {
150 | searchResults.style.display = 'none'
151 | return
152 | }
153 | // get the search data
154 | let query = `/search?q=${this.value}`
155 | fetch(query)
156 | .then(response => response.json())
157 | .then(results => {
158 | // hide results div if there is no search result
159 | if (!results.length) {
160 | searchResults.style.display = 'none'
161 | return
162 | }
163 | // show results
164 | searchResults.style.display = 'block'
165 | // append html
166 | searchResults.innerHTML = searchResultsHTML(results)
167 |
168 | })
169 | })
170 | }
171 |
172 | // attach typeAhead script to the DOM node with class 'search'
173 | typeAhead($('.search'))
174 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # code-cookbook
2 |
3 | URL: [https://codecookbook.now.sh](https://codecookbook.now.sh)
4 |
5 | Author:Taylor Khan
6 |
7 | Code: github.com/nelsonkhan/code-cookbook-public
8 |
9 | Date Started: June 30, 2017
10 |
11 | Date Completed: July 19, 2017
12 |
13 | **About**
14 |
15 | Code Cookbook is a lightweight platform for developers to create, organize, and search for code snippets, bash commands, and other obscure bits of information.
16 |
17 | **Why?**
18 |
19 | I created Code Cookbook (referred to as CC from here on out) because I write software for the web. I often find myself having to learn obscure commands for various CLI programs, bash, and web frameworks.
20 |
21 | When I couldn’t figure something out, I would find a quick answer in documentation, Stack Overflow, or forums.
22 |
23 | After a few months in a new framework, I would forget the commands.
24 |
25 | When I needed to use the commands again, I would go back to Google, search for a problem I had already solved, scour results until I found the right answer, and then get back to work.
26 |
27 | This seemed like a waste of time. I didn’t need to read through a whole Stack Overflow question, or browse the MDN again. I just needed the syntax - with no filler.
28 |
29 | I created CC to get rid of the filler, and to act as a search engine for people like me.
30 |
31 | I knew that it would be useful if I was the only one who ever used it, and it would be even better if other people did.
32 |
33 | Aside from removing filler, it also acts as a cloud store. If I save the commands to my local machine, or write them on a scrap of paper, then I won’t have access to the information I want when I’m out and about.
34 |
35 | I could save them in a Google Doc or something similar. But then I’m gathering all the info myself. It would be nice if others could collaborate with me, without managing permissions.
36 |
37 | Finally, I wanted to build a project to show that I know the basics of the Express.js framework, and to sharpen my skills as a Javascript developer.
38 |
39 | **Stack**
40 |
41 | Technology stack is a weird concept when working with Node.
42 |
43 | On the one hand your stack could be considered your framework, server, database, and host OS.
44 |
45 | On the other hand, there are a lot of libraries and services that get used which aren’t included in the common stack acronyms.
46 |
47 | So, I’m going to list most of the tech I directly used and explain why I chose them. I won’t go into the dependencies of each, as that would be incredibly tedious for me, and boring for you!
48 |
49 | **Express**
50 |
51 | I’ve written an API using Node. I’ve written some CLI utilities using Node. Using Nightmare.js and Node, I’ve written some hefty web scrapers targeting javascript rendered sites. I’ve used Angular and React.
52 |
53 | But never a backend framework.
54 |
55 | I noticed that Javascript was moving quite rapidly a few years back. The trend was similar to the Ruby on Rails hype that preceded it.
56 |
57 | I was a happy camper using RoR, and when the community around it started to die down - I was left disappointed. I didn’t want to make the same mistakes by hopping onto a JS framework early.
58 |
59 | I have been using javascript heavily for the last few years, and now that some of the dust started to clear, I felt comfortable picking up a framework.
60 |
61 | I also wanted to compare Express to RoR and see which I preferred.
62 |
63 | In essence, the choice was experimental, and for personal development.
64 |
65 | **Pug**
66 |
67 | Pug is sort of the default choice for Express. It’s definitely possible to use other view engines, but most devs prefer Pug. I’ve already had experience with Pug and overall, I think it is the best choice available for server-side rendering. It is far cleaner, and thus clearer - than standard HTML.
68 |
69 | Brevity and readability is a good thing for HTML. Pug brings both to the table. It makes maintenance tasks such as editing or styling a far simpler affair.
70 |
71 | **Auth0**
72 |
73 | This is my second application with Auth0. I may get some flak for trusting the security of my application to a third-party. If I was building a banking site, I might be inclined to agree. But I’m not, CC is a step up from a basic CRUD app.
74 |
75 | Is my hand-rolled authentication going to be significantly more secure than Auth0?
76 |
77 | Probably not.
78 |
79 | Auth0 charges for their higher end services, and are being adopted quickly by many devs. As far as I know, there haven’t been major security issues.
80 |
81 | My other apps have custom authentication, so it’s not like it would be a learning exercise for me.
82 |
83 | At this point, why reinvent the wheel?
84 |
85 | **Mongoose / MongoDB**
86 |
87 | I used this setup on one of my previous applications. The concept of using JSON as a data store always felt like a good idea for me.
88 |
89 | When I build an API, I usually deliver JSON. When I call an API I’m usually getting JSON.
90 |
91 | Why not store the data as JSON, and query in JSON?
92 |
93 | For Javascript development, MongoDB feels like a natural fit. Storing data in arrays and in objects inside of a record is a killer feature and eliminates a lot of tedious joins tables and querying.
94 |
95 | Mongoose has some shortcomings (no fuzzy search, I’m looking at you!) but so far I’ve found that they can all be overcome with some custom code.
96 |
97 | **Bash**
98 |
99 | This might be odd to include, but I actually used bash in place of a task runner on this project.
100 |
101 | My unminified CSS is a total of 44 lines, and I figured it would be. Using a task runner just to use SASS would be total overkill.
102 |
103 | The only task I really needed automated was bundling my frontend JS. Certainly I could use a task runner, but that sounds like needless configuration.
104 |
105 | I set up a bundle command in my package.json which concatenated my files nicely using a bash one liner. Then I set up my monitor script to rebundle everything and restart the app whenever new code was saved.
106 |
107 | Gary Bernhardt, of Destroy All Software fame says "Half-assed is OK when you only need half of an ass". [[https://www.youtube.com/watch?v=sCZJblyT_XM&list=PLxd96E9IxfZXubJCt_iX1hEdxqyQlKSjG](https://www.youtube.com/watch?v=sCZJblyT_XM&list=PLxd96E9IxfZXubJCt_iX1hEdxqyQlKSjG)]
108 |
109 | **Now**
110 |
111 | I’ve used Surge, Heroku, Digital Ocean, and more. None of them match the ease of use for quick deploying with Zeit’s Now CLI.
112 |
113 | **Misc**
114 |
115 | There’s a few others, and I’ve probably missed or forgotten more. But here is a quick overview of some other tools inside the code:
116 |
117 | * Passport (authorization)
118 |
119 | * Dotenv (environment variables)
120 |
121 | * Bling (lightweight jQuery alternative)
122 |
123 | * Chai / Mocha (unit testing)
124 |
125 | **Process**
126 |
127 | I started off with the idea, an ethereal concept for what the code might do.
128 |
129 | I let it simmer for a while, to make sure I was solving an actual problem and not just a temporary frustration.
130 |
131 | When I finally decided to make CC, I had just finished reading *The Pragmatic Programmer*, and gone through an ordeal building a web app where my team had failed to gather requirements in detail up front. So, I was very gung ho about putting the idea of a use case document into practice.
132 |
133 | Even though it is a simple app, I wanted to be very thorough, I figured this was good practice for future projects.
134 |
135 | After use cases, I started creating some interface mockups. I could have gone straight into design / architecture, but I find that a visual layout helps to really crystallize what is being built. The clearer the concept is before coding, the better.
136 |
137 | Finally, I put together some design elements. I created a basic database schema layout. I used pseudocode as a placeholder for the routing, the controllers, and their methods.
138 |
139 | Finally time to code. Because I did a lot of the work up front, most of the code was a breeze. I had a few hiccups, in trying to use TDD, and in AJAX requests.
140 |
141 | I still haven’t quite figured out TDD with Express. There isn't enough custom logic in this app to really require it. Most of it is just database queries, and since those are all handled by Mongoose, which is fairly battle-tested...what is there to test?
142 |
143 | AJAX requests were much simpler. AJAX requests are made using fetch, but I wasn’t sure how to authorize the user, as everything is running server side. This is not typically how Auth0 is used from what I can gather. The Express and Fetch docs were not very helpful here so I turned to reddit. Setting "credentials": true in the fetch request happily passed along the Auth0 JWT and authorized the user.
144 |
145 | **Final Thoughts**
146 |
147 | I think I could have had a massive benefit using something like Protractor for front end testing. I spent as much time doing manual testing as I did writing code. Automating more could really help deploy speed in the future.
148 |
149 | I discovered a handful of bash tricks in this project that I’m certain will be useful in the future.
150 |
151 | I’ve posted these to CC as the following:
152 |
153 | #### **Combine / bundle files using terminal with newline**
154 |
155 | awk 'FNR==0{print ''}1' *.[file-extension] > [output-file]
156 |
157 | #### **Find all files with specific text bash**
158 |
159 | grep -rnw '/path/to/somewhere/' -e 'pattern'
160 |
161 | #### **Find and replace using bash**
162 |
163 | sed -i 's/[original]/[new]/g' [some-file]
164 |
165 | These were all pretty useful.
166 |
167 | The bundler can let you modularize code without needing a separate task runner, which is good for light projects.
168 |
169 | Find all files with specific text is recursive so it will search a whole directory. Very nice for when you want to change the name of a function for clarity. It could also be useful if someone hardcoded in a bit of sensitive information directly instead of using environment variables.
170 |
171 | Find and replace only affect one document, but if that one document has a lot of instances of the original text - this can be a lot quicker than opening up an editor.
172 |
173 |
--------------------------------------------------------------------------------