diff --git a/Gemfile b/Gemfile index 1f478929..66526d43 100644 --- a/Gemfile +++ b/Gemfile @@ -2,4 +2,9 @@ source 'https://rubygems.org' ruby '2.0.0' gem 'rspec', '~> 2.14.1' +gem 'sinatra', '~> 1.4.5' +gem 'sinatra-contrib', '~> 1.4.2' +gem 'rest-client' + +# Testing gem 'pry-byebug' diff --git a/Gemfile.lock b/Gemfile.lock index a885a8c7..bbeebb1e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,7 @@ GEM remote: https://rubygems.org/ specs: + backports (3.6.3) byebug (3.5.1) columnize (~> 0.8) debugger-linecache (~> 1.2) @@ -10,6 +11,9 @@ GEM debugger-linecache (1.2.0) diff-lcs (1.2.5) method_source (0.8.2) + mime-types (1.25.1) + multi_json (1.10.1) + netrc (0.8.0) pry (0.10.1) coderay (~> 1.1.0) method_source (~> 0.8.1) @@ -17,6 +21,14 @@ GEM pry-byebug (2.0.0) byebug (~> 3.4) pry (~> 0.10) + rack (1.5.2) + rack-protection (1.5.3) + rack + rack-test (0.6.2) + rack (>= 1.0) + rest-client (1.7.2) + mime-types (>= 1.16, < 3.0) + netrc (~> 0.7) rspec (2.14.1) rspec-core (~> 2.14.0) rspec-expectations (~> 2.14.0) @@ -25,11 +37,26 @@ GEM rspec-expectations (2.14.5) diff-lcs (>= 1.1.3, < 2.0) rspec-mocks (2.14.5) + sinatra (1.4.5) + rack (~> 1.4) + rack-protection (~> 1.4) + tilt (~> 1.3, >= 1.3.4) + sinatra-contrib (1.4.2) + backports (>= 2.0) + multi_json + rack-protection + rack-test + sinatra (~> 1.4.0) + tilt (~> 1.3) slop (3.6.0) + tilt (1.4.1) PLATFORMS ruby DEPENDENCIES pry-byebug + rest-client rspec (~> 2.14.1) + sinatra (~> 1.4.5) + sinatra-contrib (~> 1.4.2) diff --git a/public/css/app.css b/public/css/app.css new file mode 100644 index 00000000..fc01777b --- /dev/null +++ b/public/css/app.css @@ -0,0 +1,15 @@ + +.tabs { + border: 2px solid #ccc; + float: left; + padding: 0.5em; +} + +.tabs a + a { + margin-left: 1em; +} + +.tab-content { + clear: both; + padding-top: 1em; +} diff --git a/public/css/pets.css b/public/css/pets.css new file mode 100644 index 00000000..cd77d504 --- /dev/null +++ b/public/css/pets.css @@ -0,0 +1,22 @@ + +.pets-view { + overflow: auto; +} + +.pet { + float: left; + margin-right: 1em; +} + +.pet .photo { + height: 150px; +} + +.pet img { + max-width: 200px; + max-height: 150px; +} + +.pet .info { + height: 100px; +} diff --git a/public/js/pet-container.js b/public/js/pet-container.js new file mode 100644 index 00000000..68e095ca --- /dev/null +++ b/public/js/pet-container.js @@ -0,0 +1,50 @@ +(function () { + + window.PetContainer = {} + + PetContainer.controller = function (appCtrl) { + var ctrl = { + user: appCtrl.user + } + + return ctrl + } + + PetContainer.view = function (ctrl) { + var content + if (ctrl.user()) { + content = [ + petsView(ctrl, 'cats', ctrl.user().cats), + petsView(ctrl, 'dogs', ctrl.user().dogs) + ] + } + else { + content = m('h3', "Please sign in first.") + } + + return m('.pet-container', [ + m('h1', "Your Pet Container"), + content + ]) + } + + var petsView = function (ctrl, type, pets) { + + var petDivs = pets.map(function(pet) { + return m('.pet', [ + m('.photo', + m('img', { src: pet.imageUrl }) + ), + m('.info', [ + m('h4', pet.name) + ]) + ]) + }) + + return m('.pets-view', { className: type }, [ + m('h2', "Your " + type.capitalize()), + petDivs + ]) + } + +})() \ No newline at end of file diff --git a/public/js/pet-shop.js b/public/js/pet-shop.js new file mode 100644 index 00000000..8aa6893b --- /dev/null +++ b/public/js/pet-shop.js @@ -0,0 +1,74 @@ +(function () { + + window.PetShop = {} + + PetShop.controller = function (appCtrl) { + var ctrl = { + activePetShop: appCtrl.activePetShop, + petShops: appCtrl.petShops + } + + ctrl.adopt = function (type, petId) { + if (!appCtrl.user()) { + return alert("You must log in first!") + } + var shop = ctrl.activePetShop() + var pets = shop[type] + var adoptUrl = "/shops/" + shop.id + "/" + type + "/" + petId + "/adopt" + + m.request({ method: 'put', url: adoptUrl }).then(function() { + var pet = pets().find(function(p){ return p.id == petId }) + pet.adopted = true + appCtrl.user()[type].push(pet) + }, genericError) + } + + return ctrl + } + + PetShop.view = function (ctrl) { + var shop = ctrl.activePetShop() + if (!shop) return null + + return m('.pet-shop', [ + m('h1', shop.name), + petsView(ctrl, 'cats', shop.cats()), + petsView(ctrl, 'dogs', shop.dogs()) + ]) + } + + + var petsView = function (ctrl, type, pets) { + if (!pets) return null + + var petDivs = pets.map(function(pet) { + var adoptLink = m('a', { + onclick: ctrl.adopt.coldCurry(type, pet.id), + href: '#' + }, 'Adopt this pet') + + return m('.pet', [ + m('.photo', + m('img', { src: pet.imageUrl }) + ), + m('.info', [ + m('h4', pet.name), + m('b', "Adopted: "), + m('span', pet.adopted ? "Yes!" : "No..."), + m('br'), + pet.adopted ? null : adoptLink + ]) + ]) + }) + + return m('.pets-view', { className: type }, [ + m('h2', type.capitalize()), + petDivs + ]) + } + + function genericError(e) { + console.log("An error happened:", e) + alert("Bad stuff happened (check the console)") + } +})() diff --git a/public/js/polyfill.js b/public/js/polyfill.js new file mode 100644 index 00000000..aaac8c4d --- /dev/null +++ b/public/js/polyfill.js @@ -0,0 +1,39 @@ +(function () { + + var slice = Array.prototype.slice + + if (!Array.prototype.find) { + Array.prototype.find = function (f) { + for (var i=0; i < this.length; i++) { + if ( f(this[i]) ) return this[i] + } + } + } + + if (!String.prototype.capitalize) { + String.prototype.capitalize = function () { + return this[0].toUpperCase() + this.substring(1).toLowerCase() + } + } + + Function.prototype.curry = function() { + var args, fn; + fn = this + args = slice.call(arguments) + return function() { + return fn.apply(this, args.concat(slice.call(arguments))) + } + } + + // Curry and prevent default in one go + Function.prototype.coldCurry = function() { + var args, fn; + fn = this; + args = slice.call(arguments); + return function(e) { + e.preventDefault(); + return fn.apply(this, args.concat(slice.call(arguments, 1))); + }; + }; + +})() diff --git a/public/js/signin-panel.js b/public/js/signin-panel.js new file mode 100644 index 00000000..42c600f9 --- /dev/null +++ b/public/js/signin-panel.js @@ -0,0 +1,55 @@ +(function () { + + window.SigninPanel = {} + + SigninPanel.controller = function (appCtrl) { + var ctrl = { + user: appCtrl.user, + formValues: { + username: '', + password: '' + } + } + + ctrl.signin = function (e) { + e.preventDefault() + m.request({ method: 'post', url: '/signin', data: ctrl.formValues }) + .then(ctrl.user, signinError) + } + + ctrl.updateFormVal = function (e) { + ctrl.formValues[e.target.name] = e.target.value + } + + return ctrl + } + + SigninPanel.view = function (ctrl) { + if (ctrl.user()) + return userInfo(ctrl) + else + return signinForm(ctrl) + } + + var userInfo = function (ctrl) { + return m('.user-info', [ + m('p', "Welcome, " + ctrl.user().username + "!") + ]) + } + + var signinForm = function (ctrl) { + return m('form', { onsubmit: ctrl.signin, onchange: ctrl.updateFormVal }, [ + m('label', 'Username:'), + m('input[name=username]', { value: ctrl.formValues.username }), + m('label', 'Password:'), + m('input[type=password][name=password]', { value: ctrl.formValues.password }), + m('button', 'Sign In') + ]) + } + + // Helpers + function signinError() { + alert("Invalid username / password") + } + +})() diff --git a/public/js/vendor/mithril.js b/public/js/vendor/mithril.js new file mode 100755 index 00000000..9da3eef3 --- /dev/null +++ b/public/js/vendor/mithril.js @@ -0,0 +1,989 @@ +var m = (function app(window, undefined) { + var OBJECT = "[object Object]", ARRAY = "[object Array]", STRING = "[object String]", FUNCTION = "function"; + var type = {}.toString; + var parser = /(?:(^|#|\.)([^#\.\[\]]+))|(\[.+?\])/g, attrParser = /\[(.+?)(?:=("|'|)(.*?)\2)?\]/; + var voidElements = /^(AREA|BASE|BR|COL|COMMAND|EMBED|HR|IMG|INPUT|KEYGEN|LINK|META|PARAM|SOURCE|TRACK|WBR)$/; + + // caching commonly used variables + var $document, $location, $requestAnimationFrame, $cancelAnimationFrame; + + // self invoking function needed because of the way mocks work + function initialize(window){ + $document = window.document; + $location = window.location; + $cancelAnimationFrame = window.cancelAnimationFrame || window.clearTimeout; + $requestAnimationFrame = window.requestAnimationFrame || window.setTimeout; + } + + initialize(window); + + + /* + * @typedef {String} Tag + * A string that looks like -> div.classname#id[param=one][param2=two] + * Which describes a DOM node + */ + + /* + * + * @param {Tag} The DOM node tag + * @param {Object=[]} optional key-value pairs to be mapped to DOM attrs + * @param {...mNode=[]} Zero or more Mithril child nodes. Can be an array, or splat (optional) + * + */ + function m() { + var args = [].slice.call(arguments); + var hasAttrs = args[1] != null && type.call(args[1]) == OBJECT && !("tag" in args[1]) && !("subtree" in args[1]); + var attrs = hasAttrs ? args[1] : {}; + var classAttrName = "class" in attrs ? "class" : "className"; + var cell = {tag: "div", attrs: {}}; + var match, classes = []; + if (type.call(args[0]) != STRING) throw new Error("selector in m(selector, attrs, children) should be a string") + while (match = parser.exec(args[0])) { + if (match[1] == "" && match[2]) cell.tag = match[2]; + else if (match[1] == "#") cell.attrs.id = match[2]; + else if (match[1] == ".") classes.push(match[2]); + else if (match[3][0] == "[") { + var pair = attrParser.exec(match[3]); + cell.attrs[pair[1]] = pair[3] || (pair[2] ? "" :true) + } + } + if (classes.length > 0) cell.attrs[classAttrName] = classes.join(" "); + + + var children = hasAttrs ? args[2] : args[1]; + if (type.call(children) == ARRAY) { + cell.children = children + } + else { + cell.children = hasAttrs ? args.slice(2) : args.slice(1) + } + + for (var attrName in attrs) { + if (attrName == classAttrName) cell.attrs[attrName] = (cell.attrs[attrName] || "") + " " + attrs[attrName]; + else cell.attrs[attrName] = attrs[attrName] + } + return cell + } + function build(parentElement, parentTag, parentCache, parentIndex, data, cached, shouldReattach, index, editable, namespace, configs) { + //`build` is a recursive function that manages creation/diffing/removal of DOM elements based on comparison between `data` and `cached` + //the diff algorithm can be summarized as this: + //1 - compare `data` and `cached` + //2 - if they are different, copy `data` to `cached` and update the DOM based on what the difference is + //3 - recursively apply this algorithm for every array and for the children of every virtual element + + //the `cached` data structure is essentially the same as the previous redraw's `data` data structure, with a few additions: + //- `cached` always has a property called `nodes`, which is a list of DOM elements that correspond to the data represented by the respective virtual element + //- in order to support attaching `nodes` as a property of `cached`, `cached` is *always* a non-primitive object, i.e. if the data was a string, then cached is a String instance. If data was `null` or `undefined`, cached is `new String("")` + //- `cached also has a `configContext` property, which is the state storage object exposed by config(element, isInitialized, context) + //- when `cached` is an Object, it represents a virtual element; when it's an Array, it represents a list of elements; when it's a String, Number or Boolean, it represents a text node + + //`parentElement` is a DOM element used for W3C DOM API calls + //`parentTag` is only used for handling a corner case for textarea values + //`parentCache` is used to remove nodes in some multi-node cases + //`parentIndex` and `index` are used to figure out the offset of nodes. They're artifacts from before arrays started being flattened and are likely refactorable + //`data` and `cached` are, respectively, the new and old nodes being diffed + //`shouldReattach` is a flag indicating whether a parent node was recreated (if so, and if this node is reused, then this node must reattach itself to the new parent) + //`editable` is a flag that indicates whether an ancestor is contenteditable + //`namespace` indicates the closest HTML namespace as it cascades down from an ancestor + //`configs` is a list of config functions to run after the topmost `build` call finishes running + + //there's logic that relies on the assumption that null and undefined data are equivalent to empty strings + //- this prevents lifecycle surprises from procedural helpers that mix implicit and explicit return statements (e.g. function foo() {if (cond) return m("div")} + //- it simplifies diffing code + if (data == null) data = ""; + if (data.subtree === "retain") return cached; + var cachedType = type.call(cached), dataType = type.call(data); + if (cached == null || cachedType != dataType) { + if (cached != null) { + if (parentCache && parentCache.nodes) { + var offset = index - parentIndex; + var end = offset + (dataType == ARRAY ? data : cached.nodes).length; + clear(parentCache.nodes.slice(offset, end), parentCache.slice(offset, end)) + } + else if (cached.nodes) clear(cached.nodes, cached) + } + cached = new data.constructor; + if (cached.tag) cached = {}; //if constructor creates a virtual dom element, use a blank object as the base cached node instead of copying the virtual el (#277) + cached.nodes = [] + } + + if (dataType == ARRAY) { + //recursively flatten array + for (var i = 0; i < data.length; i++) { + if (type.call(data[i]) == ARRAY) { + data = data.concat.apply([], data); + i-- //check current index again and flatten until there are no more nested arrays at that index + } + } + + var nodes = [], intact = cached.length === data.length, subArrayCount = 0; + + //keys algorithm: sort elements without recreating them if keys are present + //1) create a map of all existing keys, and mark all for deletion + //2) add new keys to map and mark them for addition + //3) if key exists in new list, change action from deletion to a move + //4) for each key, handle its corresponding action as marked in previous steps + //5) copy unkeyed items into their respective gaps + var DELETION = 1, INSERTION = 2 , MOVE = 3; + var existing = {}, unkeyed = [], shouldMaintainIdentities = false; + for (var i = 0; i < cached.length; i++) { + if (cached[i] && cached[i].attrs && cached[i].attrs.key != null) { + shouldMaintainIdentities = true; + existing[cached[i].attrs.key] = {action: DELETION, index: i} + } + } + if (shouldMaintainIdentities) { + for (var i = 0; i < data.length; i++) { + if (data[i] && data[i].attrs) { + if (data[i].attrs.key != null) { + var key = data[i].attrs.key; + if (!existing[key]) existing[key] = {action: INSERTION, index: i}; + else existing[key] = { + action: MOVE, + index: i, + from: existing[key].index, + element: parentElement.childNodes[existing[key].index] || $document.createElement("div") + } + } + else unkeyed.push({index: i, element: parentElement.childNodes[i] || $document.createElement("div")}) + } + } + var actions = Object.keys(existing).map(function(key) {return existing[key]}); + var changes = actions.sort(function(a, b) {return a.action - b.action || a.index - b.index}); + var newCached = cached.slice(); + + for (var i = 0, change; change = changes[i]; i++) { + if (change.action == DELETION) { + clear(cached[change.index].nodes, cached[change.index]); + newCached.splice(change.index, 1) + } + if (change.action == INSERTION) { + var dummy = $document.createElement("div"); + dummy.key = data[change.index].attrs.key; + parentElement.insertBefore(dummy, parentElement.childNodes[change.index] || null); + newCached.splice(change.index, 0, {attrs: {key: data[change.index].attrs.key}, nodes: [dummy]}) + } + + if (change.action == MOVE) { + if (parentElement.childNodes[change.index] !== change.element && change.element !== null) { + parentElement.insertBefore(change.element, parentElement.childNodes[change.index] || null) + } + newCached[change.index] = cached[change.from] + } + } + for (var i = 0; i < unkeyed.length; i++) { + var change = unkeyed[i]; + parentElement.insertBefore(change.element, parentElement.childNodes[change.index] || null); + newCached[change.index] = cached[change.index] + } + cached = newCached; + cached.nodes = []; + for (var i = 0, child; child = parentElement.childNodes[i]; i++) cached.nodes.push(child) + } + //end key algorithm + + for (var i = 0, cacheCount = 0; i < data.length; i++) { + //diff each item in the array + var item = build(parentElement, parentTag, cached, index, data[i], cached[cacheCount], shouldReattach, index + subArrayCount || subArrayCount, editable, namespace, configs); + if (item === undefined) continue; + if (!item.nodes.intact) intact = false; + if (item.$trusted) { + //fix offset of next element if item was a trusted string w/ more than one html element + //the first clause in the regexp matches elements + //the second clause (after the pipe) matches text nodes + subArrayCount += (item.match(/<[^\/]|\>\s*[^<]/g) || []).length + } + else subArrayCount += type.call(item) == ARRAY ? item.length : 1; + cached[cacheCount++] = item + } + if (!intact) { + //diff the array itself + + //update the list of DOM nodes by collecting the nodes from each item + for (var i = 0; i < data.length; i++) { + if (cached[i] != null) nodes.push.apply(nodes, cached[i].nodes) + } + //remove items from the end of the array if the new array is shorter than the old one + //if errors ever happen here, the issue is most likely a bug in the construction of the `cached` data structure somewhere earlier in the program + for (var i = 0, node; node = cached.nodes[i]; i++) { + if (node.parentNode != null && nodes.indexOf(node) < 0) clear([node], [cached[i]]) + } + if (data.length < cached.length) cached.length = data.length; + cached.nodes = nodes + } + } + else if (data != null && dataType == OBJECT) { + if (!data.attrs) data.attrs = {}; + if (!cached.attrs) cached.attrs = {}; + + var dataAttrKeys = Object.keys(data.attrs); + var hasKeys = dataAttrKeys.length > ("key" in data.attrs ? 1 : 0) + //if an element is different enough from the one in cache, recreate it + if (data.tag != cached.tag || dataAttrKeys.join() != Object.keys(cached.attrs).join() || data.attrs.id != cached.attrs.id) { + if (cached.nodes.length) clear(cached.nodes); + if (cached.configContext && typeof cached.configContext.onunload == FUNCTION) cached.configContext.onunload() + } + if (type.call(data.tag) != STRING) return; + + var node, isNew = cached.nodes.length === 0; + if (data.attrs.xmlns) namespace = data.attrs.xmlns; + else if (data.tag === "svg") namespace = "http://www.w3.org/2000/svg"; + else if (data.tag === "math") namespace = "http://www.w3.org/1998/Math/MathML"; + if (isNew) { + if (data.attrs.is) node = namespace === undefined ? $document.createElement(data.tag, data.attrs.is) : $document.createElementNS(namespace, data.tag, data.attrs.is); + else node = namespace === undefined ? $document.createElement(data.tag) : $document.createElementNS(namespace, data.tag); + cached = { + tag: data.tag, + //set attributes first, then create children + attrs: dataAttrKeys.length ? setAttributes(node, data.tag, data.attrs, {}, namespace) : {}, + children: data.children != null && data.children.length > 0 ? + build(node, data.tag, undefined, undefined, data.children, cached.children, true, 0, data.attrs.contenteditable ? node : editable, namespace, configs) : + data.children, + nodes: [node] + }; + if (cached.children && !cached.children.nodes) cached.children.nodes = []; + //edge case: setting value on