diff --git a/.gitignore b/.gitignore index 3538ffd3..f1d02ab6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ vendor app/cache/* app/logs/* -public/images/* public/js/cache/* +public/images/* +public/cache/* composer.lock *.sqlite *.db @@ -18,4 +19,4 @@ build/logs/* build/pdepend/* build/phpdox/* cache.properties -tests/test_data/cache/* \ No newline at end of file +tests/test_data/cache/* diff --git a/README.md b/README.md index 15c8c2de..135221c6 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ A self-hosted client that allows custom formatting of data from the hummingbird 2. Configure settings in `app/config/config.php` to your liking 3. Create the following directories if they don't exist, and make sure they are world writable * app/cache + * public/js/cache * public/images/manga * public/images/anime - * public/cache #### Anime Collection Additional Installation * Run `php /vendor/bin/phinx migrate -e development` to create the database tables diff --git a/app/config/minify_config.php b/app/config/minify_config.php index 20d9941a..9fa30251 100644 --- a/app/config/minify_config.php +++ b/app/config/minify_config.php @@ -56,4 +56,4 @@ $path_to = ''; | The folder where javascript files exist, in relation to the document root | */ -$js_root = $config['asset_dir'] . '/'; \ No newline at end of file +$js_root = $config['asset_dir'] . '/js/'; \ No newline at end of file diff --git a/app/config/minify_js_groups.php b/app/config/minify_js_groups.php index dd1b728f..549a3d19 100644 --- a/app/config/minify_js_groups.php +++ b/app/config/minify_js_groups.php @@ -13,92 +13,45 @@ // -------------------------------------------------------------------------- -$bower_packages = ['jquery', 'datatables', 'mustache.js']; -$bower_file_map = []; - -foreach($bower_packages as $package) -{ - $bower_file_map[$package] = []; - $json = json_decode(file_get_contents(__DIR__ . "/../../public/bower_components/{$package}/bower.json")); - - if ( ! is_array($json->main)) - { - $json->main = [$json->main]; - } - - foreach($json->main as $file) - { - if (stristr($file, '.js') !== FALSE) - { - array_push($bower_file_map[$package], "bower_components/{$package}/{$file}"); - } - } -} - -/** - * Merge together bower configs and local files - * - * @param string|array $bower - array of bower components to include - * @param string|array $local - array of local js files to include - * @return array - group array - */ -function create_group($bower, $local=[]) -{ - global $bower_file_map; - $group = []; - - foreach((array) $bower as $component) - { - $group = array_merge($group, $bower_file_map[$component]); - } - - foreach((array) $local as $file) - { - $group[] = $file; - } - - return $group; -} - /** * This is the config array for javascript files to concatenate and minify */ -$map = [ +return [ /* For each group create an array like so 'my_group' => array( - 'path/to/js/file1.js', - 'path/to/js/file2.js' + 'path/to/file1.js', + 'path/to/file2.js' ), */ - 'base' => create_group('jquery', [ - 'js/base.js', - ]), - 'event' => create_group([], 'js/events.js'), - 'table' => create_group([], 'js/sort_tables.js'), - 'table_edit' => create_group([], [ - 'js/sort_tables.js', - 'js/anime_edit.js', - 'js/manga_edit.js', - ]), - 'edit' => create_group([],[ - 'js/anime_edit.js', - 'js/manga_edit.js' - ]), - 'anime_collection' => create_group('mustache.js', [ - 'bower_components/jquery-throttle-debounce/jquery.ba-throttle-debounce.js', - 'js/anime_collection.js' - ]), - 'manga_collection' => create_group('mustache.js', [ - 'bower_components/jquery-throttle-debounce/jquery.ba-throttle-debounce.js', - 'js/manga_collection.js' - ]), + 'base' => [ + 'lib/zepto.js', + 'base/base.js', + ], + 'event' => [ + 'base/events.js', + ], + 'table' => [ + 'base/sort_tables.js', + ], + 'table_edit' => [ + 'sort_tables.js', + 'anime_edit.js', + 'manga_edit.js', + ], + 'edit' => [ + 'anime_edit.js', + 'manga_edit.js', + ], + 'anime_collection' => [ + 'lib/mustache.js', + 'anime_collection.js', + ], + 'manga_collection' => [ + 'lib/mustache.js', + 'manga_collection.js', + ], ]; -//print_r($map); -//die(); - -return $map; - // End of js_groups.php \ No newline at end of file diff --git a/app/config/routes.php b/app/config/routes.php index 0e636722..8108109d 100644 --- a/app/config/routes.php +++ b/app/config/routes.php @@ -56,6 +56,38 @@ return [ 'action' => 'add', 'verb' => 'post' ], + 'anime_detail' => [ + 'path' => '/anime/details/{id}', + 'action' => 'details', + 'tokens' => [ + 'id' => '[a-z0-9\-]+' + ] + ], + // --------------------------------------------------------------------- + // Manga Routes + // --------------------------------------------------------------------- + 'manga_search' => [ + 'path' => '/manga/search', + 'action' => 'search', + ], + 'manga_add_form' => [ + 'path' => '/manga/add', + 'action' => 'add_form', + 'verb' => 'get' + ], + 'manga_add' => [ + 'path' => '/manga/add', + 'action' => 'add', + 'verb' => 'post' + ], + 'manga_edit_form' => [ + 'path' => '/manga/edit/{id}{status}', + 'action' => 'edit', + 'tokens' => [ + 'id' => '[0-9a-z_]+', + 'status' => '[a-zA-z\- ]+', + ] + ], // --------------------------------------------------------------------- // Anime Collection Routes // --------------------------------------------------------------------- diff --git a/app/views/manga/add.php b/app/views/manga/add.php index e69de29b..a50dde2c 100644 --- a/app/views/manga/add.php +++ b/app/views/manga/add.php @@ -0,0 +1,39 @@ +is_authenticated()): ?> +
+

Add Manga to your List

+
+
+ + +
+
+
+
+ + + + + + + + + + + +
+ +
  + +
+
+
+ + \ No newline at end of file diff --git a/app/views/manga/cover.php b/app/views/manga/cover.php index f732f5fe..9afec5e4 100644 --- a/app/views/manga/cover.php +++ b/app/views/manga/cover.php @@ -1,4 +1,7 @@
+is_authenticated()): ?> +Add Item +

There's nothing here!

diff --git a/app/views/manga/list.php b/app/views/manga/list.php index c5da6164..5f90970b 100644 --- a/app/views/manga/list.php +++ b/app/views/manga/list.php @@ -1,4 +1,7 @@
+is_authenticated()): ?> +Add Item +

There's nothing here!

diff --git a/public/bower.json b/public/bower.json deleted file mode 100644 index 80f461c8..00000000 --- a/public/bower.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "HummingbirdAnimeClient", - "homepage": "https://github.com/timw4mail/HummingBirdAnimeClient", - "authors": [ - "Timothy J Warren " - ], - "description": "Frontend stuff for php app", - "main": "", - "moduleType": [ - "globals" - ], - "license": "MIT", - "private": true, - "dependencies": { - "jquery": "^2.2.0", - "datatables": "DataTables#^1.10.10", - "jquery-throttle-debounce": "*", - "mustache.js": "mustache#^2.2.1" - } -} diff --git a/public/css/base.css b/public/css/base.css index f700e521..356b8453 100644 --- a/public/css/base.css +++ b/public/css/base.css @@ -12,7 +12,7 @@ button { } table { - /*width:85%;*/ + min-width: 85%; margin: 0 auto; } diff --git a/public/css/base.myth.css b/public/css/base.myth.css index 49828697..95dc85b9 100644 --- a/public/css/base.myth.css +++ b/public/css/base.myth.css @@ -19,7 +19,7 @@ button { } table { - /*width:85%;*/ + min-width:85%; margin: 0 auto; } @@ -31,7 +31,6 @@ td { thead td, thead th { padding:0.5em; padding:0.5rem; - } input[type=number] { diff --git a/public/js.php b/public/js.php index c97c7ab5..eb74f0b9 100644 --- a/public/js.php +++ b/public/js.php @@ -89,7 +89,8 @@ function google_min($new_file) // Show error if exists if ( ! empty($error_obj->errors)) { - echo json_encode($error_obj, JSON_PRETTY_PRINT); + $error_json = json_encode($error_obj, JSON_PRETTY_PRINT); + echo "console.error(${error_json});"; die(); } diff --git a/public/js/anime_collection.js b/public/js/anime_collection.js index 9b4b5864..eecb382d 100755 --- a/public/js/anime_collection.js +++ b/public/js/anime_collection.js @@ -2,45 +2,28 @@ 'use strict'; - function search(query, callback) - { - var endpoint = '//' + document.location.host + '/collection/search'; - return $.get(AnimeClient.url('/collection/search'), {'query':query}, callback); - } + const search = (tempHtml, query) => { + $('.cssload-loader').removeAttr('hidden'); + $.get(AnimeClient.url('/collection/search'), {'query':query}, (searchResults, status) => { + $('.cssload-loader').attr('hidden', 'hidden'); - $.get('/public/templates/ajax-search-results.html').done(tempHtml => { - $('#search').on('keypress', $.throttle(750, function(e) { - $('.cssload-loader').removeAttr('hidden'); + // Give mustache a key to iterate over + searchResults = { + anime: searchResults + }; + + Mustache.parse(tempHtml); + $('#series_list').html(Mustache.render(tempHtml, searchResults)); + }); + }; + + $.get('/public/templates/anime-ajax-search-results.html', tempHtml => { + $('#search').on('keypress', AnimeClient.throttle(250, function(e) { let query = encodeURIComponent($(this).val()); - $.get(AnimeClient.url('/collection/search'), {'query':query}).done((searchResults) => { - $('.cssload-loader').attr('hidden', 'hidden'); + console.log($(this).val()); - // Give mustache a key to iterate over - searchResults = { - anime: searchResults - }; - - Mustache.parse(tempHtml); - $('#series_list').html(Mustache.render(tempHtml, searchResults)); - }).fail(() => { - $('.cssload-loader').attr('hidden', 'hidden'); - }); + search(tempHtml, query); })); }); - /*$.get('/public/templates/ajax-search-results.html', tempHtml => { - $('#search').on('keypress', $.throttle(750, function(e) { - var query = encodeURIComponent($(this).val()); - search(query, function(searchResults) { - // Give mustache a key to iterate over - searchResults = { - anime: searchResults - }; - - Mustache.parse(tempHtml); - var rendered = Mustache.render(tempHtml, searchResults); - $('#series_list').html(rendered); - }); - })); - });*/ -})(jQuery, AnimeClient); \ No newline at end of file +})(Zepto, AnimeClient); \ No newline at end of file diff --git a/public/js/anime_edit.js b/public/js/anime_edit.js index c7ab73ee..70d5dfbc 100755 --- a/public/js/anime_edit.js +++ b/public/js/anime_edit.js @@ -1,14 +1,12 @@ /** * Javascript for editing anime, if logged in */ -(($, AnimeClient, w) => { +(($, AnimeClient) => { 'use strict'; // Action to increment episode count $('body.anime.list').on('click', '.plus_one', function(e) { - //e.stopPropagation(); - let self = this; let this_sel = $(this); let parent_sel = $(this).closest('article, td'); @@ -39,22 +37,24 @@ $.ajax({ data: data, dataType: 'json', - method: 'POST', + type: 'POST', mimeType: 'application/json', url: AnimeClient.url('/anime/update'), - }).done((res) => { - if (res.status === 'completed') { - $(this).closest('article, tr').hide(); + success: (res) => { + if (res.status === 'completed') { + $(this).closest('article, tr').hide(); + } + + AnimeClient.showMessage('success', `Sucessfully updated ${title}`); + parent_sel.find('.completed_number').text(++watched_count); + AnimeClient.scrollToTop(); + }, + error: (xhr, errorType, error) => { + console.error(error); + AnimeClient.showMessage('error', `Failed to updated ${title}. `); + AnimeClient.scrollToTop(); } - - AnimeClient.showMessage('success', `Sucessfully updated ${title}`); - parent_sel.find('.completed_number').text(++watched_count); - - // scroll to top - w.scroll(0,0); - }).fail(() => { - AnimeClient.showMessage('error', `Failed to updated ${title}`); }); }); -})(jQuery, AnimeClient, window); \ No newline at end of file +})(Zepto, AnimeClient); \ No newline at end of file diff --git a/public/js/base.js b/public/js/base.js deleted file mode 100644 index e645292a..00000000 --- a/public/js/base.js +++ /dev/null @@ -1,45 +0,0 @@ -const AnimeClient = (function($) { - - 'use strict'; - - return { - /** - * Display a message box - * - * @param {String} type - message type: info, error, success - * @param {String} message - the message itself - * @return {void} - */ - showMessage(type, message) { - let template = ` -
- - ${message} - -
`; - - if ($(".message").length > 0) - { - $(".message").replaceWith(template); - $(".message").show(); - } - else - { - $("header").append(template); - } - }, - /** - * Generate a full url from a relative path - * - * @param {String} path - url path - * @return {String} - full url - */ - url(path) { - let uri = `//${document.location.host}`; - uri += (path.charAt(0) === '/') ? path : `/${path}`; - - return uri; - }, - }; - -})(jQuery); \ No newline at end of file diff --git a/public/js/base/base.js b/public/js/base/base.js new file mode 100644 index 00000000..ea74dd7f --- /dev/null +++ b/public/js/base/base.js @@ -0,0 +1,74 @@ +const AnimeClient = (function($, w) { + + 'use strict'; + + return { + /** + * Scroll to the top of the Page + * + * @return {void} + */ + scrollToTop() { + w.scroll(0,0); + }, + /** + * Display a message box + * + * @param {String} type - message type: info, error, success + * @param {String} message - the message itself + * @return {void} + */ + showMessage(type, message) { + let template = ` +
+ + ${message} + +
`; + + if ($(".message").length > 0) { + $(".message").replaceWith(template); + $(".message").show(); + } else { + $("header").append(template); + } + }, + /** + * Generate a full url from a relative path + * + * @param {String} path - url path + * @return {String} - full url + */ + url(path) { + let uri = `//${document.location.host}`; + uri += (path.charAt(0) === '/') ? path : `/${path}`; + + return uri; + }, + /** + * Throttle execution of a function + * + * @see https://remysharp.com/2010/07/21/throttling-function-calls + * @see https://jsfiddle.net/jonathansampson/m7G64/ + * @param {Number} interval - the minimum throttle time in ms + * @param {Function} fn - the function to throttle + * @param {Object} scope - the 'this' object for the function + * @return {void} + */ + throttle(interval, fn, scope) { + var wait = false; + return function () { + var context = scope || this; + var args = arguments; + + if ( ! wait) { + fn.apply(context, args); + wait = true; + setTimeout(function() { + wait = false; + }, interval); + } + }; + }, + }; +})(Zepto, window); \ No newline at end of file diff --git a/public/js/events.js b/public/js/base/events.js similarity index 79% rename from public/js/events.js rename to public/js/base/events.js index 3b8ace39..080855de 100644 --- a/public/js/events.js +++ b/public/js/base/events.js @@ -1,5 +1,5 @@ /** - * jQuery event handlers + * Event handlers */ (($) => { @@ -10,4 +10,4 @@ $(this).hide(); }); -})(jQuery); \ No newline at end of file +})(Zepto); \ No newline at end of file diff --git a/public/js/sort_tables.js b/public/js/base/sort_tables.js similarity index 100% rename from public/js/sort_tables.js rename to public/js/base/sort_tables.js diff --git a/public/js/lib/mustache.js b/public/js/lib/mustache.js new file mode 100644 index 00000000..53ec2723 --- /dev/null +++ b/public/js/lib/mustache.js @@ -0,0 +1,629 @@ +/*! + * mustache.js - Logic-less {{mustache}} templates with JavaScript + * http://github.com/janl/mustache.js + */ + +/*global define: false Mustache: true*/ + +(function defineMustache (global, factory) { + if (typeof exports === 'object' && exports && typeof exports.nodeName !== 'string') { + factory(exports); // CommonJS + } else if (typeof define === 'function' && define.amd) { + define(['exports'], factory); // AMD + } else { + global.Mustache = {}; + factory(global.Mustache); // script, wsh, asp + } +}(this, function mustacheFactory (mustache) { + + var objectToString = Object.prototype.toString; + var isArray = Array.isArray || function isArrayPolyfill (object) { + return objectToString.call(object) === '[object Array]'; + }; + + function isFunction (object) { + return typeof object === 'function'; + } + + /** + * More correct typeof string handling array + * which normally returns typeof 'object' + */ + function typeStr (obj) { + return isArray(obj) ? 'array' : typeof obj; + } + + function escapeRegExp (string) { + return string.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&'); + } + + /** + * Null safe way of checking whether or not an object, + * including its prototype, has a given property + */ + function hasProperty (obj, propName) { + return obj != null && typeof obj === 'object' && (propName in obj); + } + + // Workaround for https://issues.apache.org/jira/browse/COUCHDB-577 + // See https://github.com/janl/mustache.js/issues/189 + var regExpTest = RegExp.prototype.test; + function testRegExp (re, string) { + return regExpTest.call(re, string); + } + + var nonSpaceRe = /\S/; + function isWhitespace (string) { + return !testRegExp(nonSpaceRe, string); + } + + var entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/', + '`': '`', + '=': '=' + }; + + function escapeHtml (string) { + return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) { + return entityMap[s]; + }); + } + + var whiteRe = /\s*/; + var spaceRe = /\s+/; + var equalsRe = /\s*=/; + var curlyRe = /\s*\}/; + var tagRe = /#|\^|\/|>|\{|&|=|!/; + + /** + * Breaks up the given `template` string into a tree of tokens. If the `tags` + * argument is given here it must be an array with two string values: the + * opening and closing tags used in the template (e.g. [ "<%", "%>" ]). Of + * course, the default is to use mustaches (i.e. mustache.tags). + * + * A token is an array with at least 4 elements. The first element is the + * mustache symbol that was used inside the tag, e.g. "#" or "&". If the tag + * did not contain a symbol (i.e. {{myValue}}) this element is "name". For + * all text that appears outside a symbol this element is "text". + * + * The second element of a token is its "value". For mustache tags this is + * whatever else was inside the tag besides the opening symbol. For text tokens + * this is the text itself. + * + * The third and fourth elements of the token are the start and end indices, + * respectively, of the token in the original template. + * + * Tokens that are the root node of a subtree contain two more elements: 1) an + * array of tokens in the subtree and 2) the index in the original template at + * which the closing tag for that section begins. + */ + function parseTemplate (template, tags) { + if (!template) + return []; + + var sections = []; // Stack to hold section tokens + var tokens = []; // Buffer to hold the tokens + var spaces = []; // Indices of whitespace tokens on the current line + var hasTag = false; // Is there a {{tag}} on the current line? + var nonSpace = false; // Is there a non-space char on the current line? + + // Strips all whitespace tokens array for the current line + // if there was a {{#tag}} on it and otherwise only space. + function stripSpace () { + if (hasTag && !nonSpace) { + while (spaces.length) + delete tokens[spaces.pop()]; + } else { + spaces = []; + } + + hasTag = false; + nonSpace = false; + } + + var openingTagRe, closingTagRe, closingCurlyRe; + function compileTags (tagsToCompile) { + if (typeof tagsToCompile === 'string') + tagsToCompile = tagsToCompile.split(spaceRe, 2); + + if (!isArray(tagsToCompile) || tagsToCompile.length !== 2) + throw new Error('Invalid tags: ' + tagsToCompile); + + openingTagRe = new RegExp(escapeRegExp(tagsToCompile[0]) + '\\s*'); + closingTagRe = new RegExp('\\s*' + escapeRegExp(tagsToCompile[1])); + closingCurlyRe = new RegExp('\\s*' + escapeRegExp('}' + tagsToCompile[1])); + } + + compileTags(tags || mustache.tags); + + var scanner = new Scanner(template); + + var start, type, value, chr, token, openSection; + while (!scanner.eos()) { + start = scanner.pos; + + // Match any text between tags. + value = scanner.scanUntil(openingTagRe); + + if (value) { + for (var i = 0, valueLength = value.length; i < valueLength; ++i) { + chr = value.charAt(i); + + if (isWhitespace(chr)) { + spaces.push(tokens.length); + } else { + nonSpace = true; + } + + tokens.push([ 'text', chr, start, start + 1 ]); + start += 1; + + // Check for whitespace on the current line. + if (chr === '\n') + stripSpace(); + } + } + + // Match the opening tag. + if (!scanner.scan(openingTagRe)) + break; + + hasTag = true; + + // Get the tag type. + type = scanner.scan(tagRe) || 'name'; + scanner.scan(whiteRe); + + // Get the tag value. + if (type === '=') { + value = scanner.scanUntil(equalsRe); + scanner.scan(equalsRe); + scanner.scanUntil(closingTagRe); + } else if (type === '{') { + value = scanner.scanUntil(closingCurlyRe); + scanner.scan(curlyRe); + scanner.scanUntil(closingTagRe); + type = '&'; + } else { + value = scanner.scanUntil(closingTagRe); + } + + // Match the closing tag. + if (!scanner.scan(closingTagRe)) + throw new Error('Unclosed tag at ' + scanner.pos); + + token = [ type, value, start, scanner.pos ]; + tokens.push(token); + + if (type === '#' || type === '^') { + sections.push(token); + } else if (type === '/') { + // Check section nesting. + openSection = sections.pop(); + + if (!openSection) + throw new Error('Unopened section "' + value + '" at ' + start); + + if (openSection[1] !== value) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + start); + } else if (type === 'name' || type === '{' || type === '&') { + nonSpace = true; + } else if (type === '=') { + // Set the tags for the next time around. + compileTags(value); + } + } + + // Make sure there are no open sections when we're done. + openSection = sections.pop(); + + if (openSection) + throw new Error('Unclosed section "' + openSection[1] + '" at ' + scanner.pos); + + return nestTokens(squashTokens(tokens)); + } + + /** + * Combines the values of consecutive text tokens in the given `tokens` array + * to a single token. + */ + function squashTokens (tokens) { + var squashedTokens = []; + + var token, lastToken; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + if (token) { + if (token[0] === 'text' && lastToken && lastToken[0] === 'text') { + lastToken[1] += token[1]; + lastToken[3] = token[3]; + } else { + squashedTokens.push(token); + lastToken = token; + } + } + } + + return squashedTokens; + } + + /** + * Forms the given array of `tokens` into a nested tree structure where + * tokens that represent a section have two additional items: 1) an array of + * all tokens that appear in that section and 2) the index in the original + * template that represents the end of that section. + */ + function nestTokens (tokens) { + var nestedTokens = []; + var collector = nestedTokens; + var sections = []; + + var token, section; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + token = tokens[i]; + + switch (token[0]) { + case '#': + case '^': + collector.push(token); + sections.push(token); + collector = token[4] = []; + break; + case '/': + section = sections.pop(); + section[5] = token[2]; + collector = sections.length > 0 ? sections[sections.length - 1][4] : nestedTokens; + break; + default: + collector.push(token); + } + } + + return nestedTokens; + } + + /** + * A simple string scanner that is used by the template parser to find + * tokens in template strings. + */ + function Scanner (string) { + this.string = string; + this.tail = string; + this.pos = 0; + } + + /** + * Returns `true` if the tail is empty (end of string). + */ + Scanner.prototype.eos = function eos () { + return this.tail === ''; + }; + + /** + * Tries to match the given regular expression at the current position. + * Returns the matched text if it can match, the empty string otherwise. + */ + Scanner.prototype.scan = function scan (re) { + var match = this.tail.match(re); + + if (!match || match.index !== 0) + return ''; + + var string = match[0]; + + this.tail = this.tail.substring(string.length); + this.pos += string.length; + + return string; + }; + + /** + * Skips all text until the given regular expression can be matched. Returns + * the skipped string, which is the entire tail if no match can be made. + */ + Scanner.prototype.scanUntil = function scanUntil (re) { + var index = this.tail.search(re), match; + + switch (index) { + case -1: + match = this.tail; + this.tail = ''; + break; + case 0: + match = ''; + break; + default: + match = this.tail.substring(0, index); + this.tail = this.tail.substring(index); + } + + this.pos += match.length; + + return match; + }; + + /** + * Represents a rendering context by wrapping a view object and + * maintaining a reference to the parent context. + */ + function Context (view, parentContext) { + this.view = view; + this.cache = { '.': this.view }; + this.parent = parentContext; + } + + /** + * Creates a new context using the given view with this context + * as the parent. + */ + Context.prototype.push = function push (view) { + return new Context(view, this); + }; + + /** + * Returns the value of the given name in this context, traversing + * up the context hierarchy if the value is absent in this context's view. + */ + Context.prototype.lookup = function lookup (name) { + var cache = this.cache; + + var value; + if (cache.hasOwnProperty(name)) { + value = cache[name]; + } else { + var context = this, names, index, lookupHit = false; + + while (context) { + if (name.indexOf('.') > 0) { + value = context.view; + names = name.split('.'); + index = 0; + + /** + * Using the dot notion path in `name`, we descend through the + * nested objects. + * + * To be certain that the lookup has been successful, we have to + * check if the last object in the path actually has the property + * we are looking for. We store the result in `lookupHit`. + * + * This is specially necessary for when the value has been set to + * `undefined` and we want to avoid looking up parent contexts. + **/ + while (value != null && index < names.length) { + if (index === names.length - 1) + lookupHit = hasProperty(value, names[index]); + + value = value[names[index++]]; + } + } else { + value = context.view[name]; + lookupHit = hasProperty(context.view, name); + } + + if (lookupHit) + break; + + context = context.parent; + } + + cache[name] = value; + } + + if (isFunction(value)) + value = value.call(this.view); + + return value; + }; + + /** + * A Writer knows how to take a stream of tokens and render them to a + * string, given a context. It also maintains a cache of templates to + * avoid the need to parse the same template twice. + */ + function Writer () { + this.cache = {}; + } + + /** + * Clears all cached templates in this writer. + */ + Writer.prototype.clearCache = function clearCache () { + this.cache = {}; + }; + + /** + * Parses and caches the given `template` and returns the array of tokens + * that is generated from the parse. + */ + Writer.prototype.parse = function parse (template, tags) { + var cache = this.cache; + var tokens = cache[template]; + + if (tokens == null) + tokens = cache[template] = parseTemplate(template, tags); + + return tokens; + }; + + /** + * High-level method that is used to render the given `template` with + * the given `view`. + * + * The optional `partials` argument may be an object that contains the + * names and templates of partials that are used in the template. It may + * also be a function that is used to load partial templates on the fly + * that takes a single argument: the name of the partial. + */ + Writer.prototype.render = function render (template, view, partials) { + var tokens = this.parse(template); + var context = (view instanceof Context) ? view : new Context(view); + return this.renderTokens(tokens, context, partials, template); + }; + + /** + * Low-level method that renders the given array of `tokens` using + * the given `context` and `partials`. + * + * Note: The `originalTemplate` is only ever used to extract the portion + * of the original template that was contained in a higher-order section. + * If the template doesn't use higher-order sections, this argument may + * be omitted. + */ + Writer.prototype.renderTokens = function renderTokens (tokens, context, partials, originalTemplate) { + var buffer = ''; + + var token, symbol, value; + for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) { + value = undefined; + token = tokens[i]; + symbol = token[0]; + + if (symbol === '#') value = this.renderSection(token, context, partials, originalTemplate); + else if (symbol === '^') value = this.renderInverted(token, context, partials, originalTemplate); + else if (symbol === '>') value = this.renderPartial(token, context, partials, originalTemplate); + else if (symbol === '&') value = this.unescapedValue(token, context); + else if (symbol === 'name') value = this.escapedValue(token, context); + else if (symbol === 'text') value = this.rawValue(token); + + if (value !== undefined) + buffer += value; + } + + return buffer; + }; + + Writer.prototype.renderSection = function renderSection (token, context, partials, originalTemplate) { + var self = this; + var buffer = ''; + var value = context.lookup(token[1]); + + // This function is used to render an arbitrary template + // in the current context by higher-order sections. + function subRender (template) { + return self.render(template, context, partials); + } + + if (!value) return; + + if (isArray(value)) { + for (var j = 0, valueLength = value.length; j < valueLength; ++j) { + buffer += this.renderTokens(token[4], context.push(value[j]), partials, originalTemplate); + } + } else if (typeof value === 'object' || typeof value === 'string' || typeof value === 'number') { + buffer += this.renderTokens(token[4], context.push(value), partials, originalTemplate); + } else if (isFunction(value)) { + if (typeof originalTemplate !== 'string') + throw new Error('Cannot use higher-order sections without the original template'); + + // Extract the portion of the original template that the section contains. + value = value.call(context.view, originalTemplate.slice(token[3], token[5]), subRender); + + if (value != null) + buffer += value; + } else { + buffer += this.renderTokens(token[4], context, partials, originalTemplate); + } + return buffer; + }; + + Writer.prototype.renderInverted = function renderInverted (token, context, partials, originalTemplate) { + var value = context.lookup(token[1]); + + // Use JavaScript's definition of falsy. Include empty arrays. + // See https://github.com/janl/mustache.js/issues/186 + if (!value || (isArray(value) && value.length === 0)) + return this.renderTokens(token[4], context, partials, originalTemplate); + }; + + Writer.prototype.renderPartial = function renderPartial (token, context, partials) { + if (!partials) return; + + var value = isFunction(partials) ? partials(token[1]) : partials[token[1]]; + if (value != null) + return this.renderTokens(this.parse(value), context, partials, value); + }; + + Writer.prototype.unescapedValue = function unescapedValue (token, context) { + var value = context.lookup(token[1]); + if (value != null) + return value; + }; + + Writer.prototype.escapedValue = function escapedValue (token, context) { + var value = context.lookup(token[1]); + if (value != null) + return mustache.escape(value); + }; + + Writer.prototype.rawValue = function rawValue (token) { + return token[1]; + }; + + mustache.name = 'mustache.js'; + mustache.version = '2.2.1'; + mustache.tags = [ '{{', '}}' ]; + + // All high-level mustache.* functions use this writer. + var defaultWriter = new Writer(); + + /** + * Clears all cached templates in the default writer. + */ + mustache.clearCache = function clearCache () { + return defaultWriter.clearCache(); + }; + + /** + * Parses and caches the given template in the default writer and returns the + * array of tokens it contains. Doing this ahead of time avoids the need to + * parse templates on the fly as they are rendered. + */ + mustache.parse = function parse (template, tags) { + return defaultWriter.parse(template, tags); + }; + + /** + * Renders the `template` with the given `view` and `partials` using the + * default writer. + */ + mustache.render = function render (template, view, partials) { + if (typeof template !== 'string') { + throw new TypeError('Invalid template! Template should be a "string" ' + + 'but "' + typeStr(template) + '" was given as the first ' + + 'argument for mustache#render(template, view, partials)'); + } + + return defaultWriter.render(template, view, partials); + }; + + // This is here for backwards compatibility with 0.4.x., + /*eslint-disable */ // eslint wants camel cased function name + mustache.to_html = function to_html (template, view, partials, send) { + /*eslint-enable*/ + + var result = mustache.render(template, view, partials); + + if (isFunction(send)) { + send(result); + } else { + return result; + } + }; + + // Export the escaping function so that the user may override it. + // See https://github.com/janl/mustache.js/issues/244 + mustache.escape = escapeHtml; + + // Export these mainly for testing, but also for advanced usage. + mustache.Scanner = Scanner; + mustache.Context = Context; + mustache.Writer = Writer; + +})); diff --git a/public/js/lib/zepto.js b/public/js/lib/zepto.js new file mode 100644 index 00000000..e1912535 --- /dev/null +++ b/public/js/lib/zepto.js @@ -0,0 +1,1577 @@ +/* Zepto v1.1.4-95-g601372a - zepto event ajax form - zeptojs.com/license */ + +var Zepto = (function() { + var undefined, key, $, classList, emptyArray = [], concat = emptyArray.concat, filter = emptyArray.filter, slice = emptyArray.slice, + document = window.document, + elementDisplay = {}, classCache = {}, + cssNumber = { 'column-count': 1, 'columns': 1, 'font-weight': 1, 'line-height': 1,'opacity': 1, 'z-index': 1, 'zoom': 1 }, + fragmentRE = /^\s*<(\w+|!)[^>]*>/, + singleTagRE = /^<(\w+)\s*\/?>(?:<\/\1>|)$/, + tagExpanderRE = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/ig, + rootNodeRE = /^(?:body|html)$/i, + capitalRE = /([A-Z])/g, + + // special attributes that should be get/set via method calls + methodAttributes = ['val', 'css', 'html', 'text', 'data', 'width', 'height', 'offset'], + + adjacencyOperators = [ 'after', 'prepend', 'before', 'append' ], + table = document.createElement('table'), + tableRow = document.createElement('tr'), + containers = { + 'tr': document.createElement('tbody'), + 'tbody': table, 'thead': table, 'tfoot': table, + 'td': tableRow, 'th': tableRow, + '*': document.createElement('div') + }, + readyRE = /complete|loaded|interactive/, + simpleSelectorRE = /^[\w-]*$/, + class2type = {}, + toString = class2type.toString, + zepto = {}, + camelize, uniq, + tempParent = document.createElement('div'), + propMap = { + 'tabindex': 'tabIndex', + 'readonly': 'readOnly', + 'for': 'htmlFor', + 'class': 'className', + 'maxlength': 'maxLength', + 'cellspacing': 'cellSpacing', + 'cellpadding': 'cellPadding', + 'rowspan': 'rowSpan', + 'colspan': 'colSpan', + 'usemap': 'useMap', + 'frameborder': 'frameBorder', + 'contenteditable': 'contentEditable' + }, + isArray = Array.isArray || + function(object){ return object instanceof Array } + + zepto.matches = function(element, selector) { + if (!selector || !element || element.nodeType !== 1) return false + var matchesSelector = element.webkitMatchesSelector || element.mozMatchesSelector || + element.oMatchesSelector || element.matchesSelector + if (matchesSelector) return matchesSelector.call(element, selector) + // fall back to performing a selector: + var match, parent = element.parentNode, temp = !parent + if (temp) (parent = tempParent).appendChild(element) + match = ~zepto.qsa(parent, selector).indexOf(element) + temp && tempParent.removeChild(element) + return match + } + + function type(obj) { + return obj == null ? String(obj) : + class2type[toString.call(obj)] || "object" + } + + function isFunction(value) { return type(value) == "function" } + function isWindow(obj) { return obj != null && obj == obj.window } + function isDocument(obj) { return obj != null && obj.nodeType == obj.DOCUMENT_NODE } + function isObject(obj) { return type(obj) == "object" } + function isPlainObject(obj) { + return isObject(obj) && !isWindow(obj) && Object.getPrototypeOf(obj) == Object.prototype + } + function likeArray(obj) { return typeof obj.length == 'number' } + + function compact(array) { return filter.call(array, function(item){ return item != null }) } + function flatten(array) { return array.length > 0 ? $.fn.concat.apply([], array) : array } + camelize = function(str){ return str.replace(/-+(.)?/g, function(match, chr){ return chr ? chr.toUpperCase() : '' }) } + function dasherize(str) { + return str.replace(/::/g, '/') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2') + .replace(/([a-z\d])([A-Z])/g, '$1_$2') + .replace(/_/g, '-') + .toLowerCase() + } + uniq = function(array){ return filter.call(array, function(item, idx){ return array.indexOf(item) == idx }) } + + function classRE(name) { + return name in classCache ? + classCache[name] : (classCache[name] = new RegExp('(^|\\s)' + name + '(\\s|$)')) + } + + function maybeAddPx(name, value) { + return (typeof value == "number" && !cssNumber[dasherize(name)]) ? value + "px" : value + } + + function defaultDisplay(nodeName) { + var element, display + if (!elementDisplay[nodeName]) { + element = document.createElement(nodeName) + document.body.appendChild(element) + display = getComputedStyle(element, '').getPropertyValue("display") + element.parentNode.removeChild(element) + display == "none" && (display = "block") + elementDisplay[nodeName] = display + } + return elementDisplay[nodeName] + } + + function children(element) { + return 'children' in element ? + slice.call(element.children) : + $.map(element.childNodes, function(node){ if (node.nodeType == 1) return node }) + } + + function Z(dom, selector) { + var i, len = dom ? dom.length : 0 + for (i = 0; i < len; i++) this[i] = dom[i] + this.length = len + this.selector = selector || '' + } + + // `$.zepto.fragment` takes a html string and an optional tag name + // to generate DOM nodes from the given html string. + // The generated DOM nodes are returned as an array. + // This function can be overridden in plugins for example to make + // it compatible with browsers that don't support the DOM fully. + zepto.fragment = function(html, name, properties) { + var dom, nodes, container + + // A special case optimization for a single tag + if (singleTagRE.test(html)) dom = $(document.createElement(RegExp.$1)) + + if (!dom) { + if (html.replace) html = html.replace(tagExpanderRE, "<$1>") + if (name === undefined) name = fragmentRE.test(html) && RegExp.$1 + if (!(name in containers)) name = '*' + + container = containers[name] + container.innerHTML = '' + html + dom = $.each(slice.call(container.childNodes), function(){ + container.removeChild(this) + }) + } + + if (isPlainObject(properties)) { + nodes = $(dom) + $.each(properties, function(key, value) { + if (methodAttributes.indexOf(key) > -1) nodes[key](value) + else nodes.attr(key, value) + }) + } + + return dom + } + + // `$.zepto.Z` swaps out the prototype of the given `dom` array + // of nodes with `$.fn` and thus supplying all the Zepto functions + // to the array. This method can be overridden in plugins. + zepto.Z = function(dom, selector) { + return new Z(dom, selector) + } + + // `$.zepto.isZ` should return `true` if the given object is a Zepto + // collection. This method can be overridden in plugins. + zepto.isZ = function(object) { + return object instanceof zepto.Z + } + + // `$.zepto.init` is Zepto's counterpart to jQuery's `$.fn.init` and + // takes a CSS selector and an optional context (and handles various + // special cases). + // This method can be overridden in plugins. + zepto.init = function(selector, context) { + var dom + // If nothing given, return an empty Zepto collection + if (!selector) return zepto.Z() + // Optimize for string selectors + else if (typeof selector == 'string') { + selector = selector.trim() + // If it's a html fragment, create nodes from it + // Note: In both Chrome 21 and Firefox 15, DOM error 12 + // is thrown if the fragment doesn't begin with < + if (selector[0] == '<' && fragmentRE.test(selector)) + dom = zepto.fragment(selector, RegExp.$1, context), selector = null + // If there's a context, create a collection on that context first, and select + // nodes from there + else if (context !== undefined) return $(context).find(selector) + // If it's a CSS selector, use it to select nodes. + else dom = zepto.qsa(document, selector) + } + // If a function is given, call it when the DOM is ready + else if (isFunction(selector)) return $(document).ready(selector) + // If a Zepto collection is given, just return it + else if (zepto.isZ(selector)) return selector + else { + // normalize array if an array of nodes is given + if (isArray(selector)) dom = compact(selector) + // Wrap DOM nodes. + else if (isObject(selector)) + dom = [selector], selector = null + // If it's a html fragment, create nodes from it + else if (fragmentRE.test(selector)) + dom = zepto.fragment(selector.trim(), RegExp.$1, context), selector = null + // If there's a context, create a collection on that context first, and select + // nodes from there + else if (context !== undefined) return $(context).find(selector) + // And last but no least, if it's a CSS selector, use it to select nodes. + else dom = zepto.qsa(document, selector) + } + // create a new Zepto collection from the nodes found + return zepto.Z(dom, selector) + } + + // `$` will be the base `Zepto` object. When calling this + // function just call `$.zepto.init, which makes the implementation + // details of selecting nodes and creating Zepto collections + // patchable in plugins. + $ = function(selector, context){ + return zepto.init(selector, context) + } + + function extend(target, source, deep) { + for (key in source) + if (deep && (isPlainObject(source[key]) || isArray(source[key]))) { + if (isPlainObject(source[key]) && !isPlainObject(target[key])) + target[key] = {} + if (isArray(source[key]) && !isArray(target[key])) + target[key] = [] + extend(target[key], source[key], deep) + } + else if (source[key] !== undefined) target[key] = source[key] + } + + // Copy all but undefined properties from one or more + // objects to the `target` object. + $.extend = function(target){ + var deep, args = slice.call(arguments, 1) + if (typeof target == 'boolean') { + deep = target + target = args.shift() + } + args.forEach(function(arg){ extend(target, arg, deep) }) + return target + } + + // `$.zepto.qsa` is Zepto's CSS selector implementation which + // uses `document.querySelectorAll` and optimizes for some special cases, like `#id`. + // This method can be overridden in plugins. + zepto.qsa = function(element, selector){ + var found, + maybeID = selector[0] == '#', + maybeClass = !maybeID && selector[0] == '.', + nameOnly = maybeID || maybeClass ? selector.slice(1) : selector, // Ensure that a 1 char tag name still gets checked + isSimple = simpleSelectorRE.test(nameOnly) + return (element.getElementById && isSimple && maybeID) ? // Safari DocumentFragment doesn't have getElementById + ( (found = element.getElementById(nameOnly)) ? [found] : [] ) : + (element.nodeType !== 1 && element.nodeType !== 9 && element.nodeType !== 11) ? [] : + slice.call( + isSimple && !maybeID && element.getElementsByClassName ? // DocumentFragment doesn't have getElementsByClassName/TagName + maybeClass ? element.getElementsByClassName(nameOnly) : // If it's simple, it could be a class + element.getElementsByTagName(selector) : // Or a tag + element.querySelectorAll(selector) // Or it's not simple, and we need to query all + ) + } + + function filtered(nodes, selector) { + return selector == null ? $(nodes) : $(nodes).filter(selector) + } + + $.contains = document.documentElement.contains ? + function(parent, node) { + return parent !== node && parent.contains(node) + } : + function(parent, node) { + while (node && (node = node.parentNode)) + if (node === parent) return true + return false + } + + function funcArg(context, arg, idx, payload) { + return isFunction(arg) ? arg.call(context, idx, payload) : arg + } + + function setAttribute(node, name, value) { + value == null ? node.removeAttribute(name) : node.setAttribute(name, value) + } + + // access className property while respecting SVGAnimatedString + function className(node, value){ + var klass = node.className || '', + svg = klass && klass.baseVal !== undefined + + if (value === undefined) return svg ? klass.baseVal : klass + svg ? (klass.baseVal = value) : (node.className = value) + } + + // "true" => true + // "false" => false + // "null" => null + // "42" => 42 + // "42.5" => 42.5 + // "08" => "08" + // JSON => parse if valid + // String => self + function deserializeValue(value) { + try { + return value ? + value == "true" || + ( value == "false" ? false : + value == "null" ? null : + +value + "" == value ? +value : + /^[\[\{]/.test(value) ? $.parseJSON(value) : + value ) + : value + } catch(e) { + return value + } + } + + $.type = type + $.isFunction = isFunction + $.isWindow = isWindow + $.isArray = isArray + $.isPlainObject = isPlainObject + + $.isEmptyObject = function(obj) { + var name + for (name in obj) return false + return true + } + + $.inArray = function(elem, array, i){ + return emptyArray.indexOf.call(array, elem, i) + } + + $.camelCase = camelize + $.trim = function(str) { + return str == null ? "" : String.prototype.trim.call(str) + } + + // plugin compatibility + $.uuid = 0 + $.support = { } + $.expr = { } + $.noop = function() {} + + $.map = function(elements, callback){ + var value, values = [], i, key + if (likeArray(elements)) + for (i = 0; i < elements.length; i++) { + value = callback(elements[i], i) + if (value != null) values.push(value) + } + else + for (key in elements) { + value = callback(elements[key], key) + if (value != null) values.push(value) + } + return flatten(values) + } + + $.each = function(elements, callback){ + var i, key + if (likeArray(elements)) { + for (i = 0; i < elements.length; i++) + if (callback.call(elements[i], i, elements[i]) === false) return elements + } else { + for (key in elements) + if (callback.call(elements[key], key, elements[key]) === false) return elements + } + + return elements + } + + $.grep = function(elements, callback){ + return filter.call(elements, callback) + } + + if (window.JSON) $.parseJSON = JSON.parse + + // Populate the class2type map + $.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(i, name) { + class2type[ "[object " + name + "]" ] = name.toLowerCase() + }) + + // Define methods that will be available on all + // Zepto collections + $.fn = { + constructor: zepto.Z, + length: 0, + + // Because a collection acts like an array + // copy over these useful array functions. + forEach: emptyArray.forEach, + reduce: emptyArray.reduce, + push: emptyArray.push, + sort: emptyArray.sort, + splice: emptyArray.splice, + indexOf: emptyArray.indexOf, + concat: function(){ + var i, value, args = [] + for (i = 0; i < arguments.length; i++) { + value = arguments[i] + args[i] = zepto.isZ(value) ? value.toArray() : value + } + return concat.apply(zepto.isZ(this) ? this.toArray() : this, args) + }, + + // `map` and `slice` in the jQuery API work differently + // from their array counterparts + map: function(fn){ + return $($.map(this, function(el, i){ return fn.call(el, i, el) })) + }, + slice: function(){ + return $(slice.apply(this, arguments)) + }, + + ready: function(callback){ + // need to check if document.body exists for IE as that browser reports + // document ready when it hasn't yet created the body element + if (readyRE.test(document.readyState) && document.body) callback($) + else document.addEventListener('DOMContentLoaded', function(){ callback($) }, false) + return this + }, + get: function(idx){ + return idx === undefined ? slice.call(this) : this[idx >= 0 ? idx : idx + this.length] + }, + toArray: function(){ return this.get() }, + size: function(){ + return this.length + }, + remove: function(){ + return this.each(function(){ + if (this.parentNode != null) + this.parentNode.removeChild(this) + }) + }, + each: function(callback){ + emptyArray.every.call(this, function(el, idx){ + return callback.call(el, idx, el) !== false + }) + return this + }, + filter: function(selector){ + if (isFunction(selector)) return this.not(this.not(selector)) + return $(filter.call(this, function(element){ + return zepto.matches(element, selector) + })) + }, + add: function(selector,context){ + return $(uniq(this.concat($(selector,context)))) + }, + is: function(selector){ + return this.length > 0 && zepto.matches(this[0], selector) + }, + not: function(selector){ + var nodes=[] + if (isFunction(selector) && selector.call !== undefined) + this.each(function(idx){ + if (!selector.call(this,idx)) nodes.push(this) + }) + else { + var excludes = typeof selector == 'string' ? this.filter(selector) : + (likeArray(selector) && isFunction(selector.item)) ? slice.call(selector) : $(selector) + this.forEach(function(el){ + if (excludes.indexOf(el) < 0) nodes.push(el) + }) + } + return $(nodes) + }, + has: function(selector){ + return this.filter(function(){ + return isObject(selector) ? + $.contains(this, selector) : + $(this).find(selector).size() + }) + }, + eq: function(idx){ + return idx === -1 ? this.slice(idx) : this.slice(idx, + idx + 1) + }, + first: function(){ + var el = this[0] + return el && !isObject(el) ? el : $(el) + }, + last: function(){ + var el = this[this.length - 1] + return el && !isObject(el) ? el : $(el) + }, + find: function(selector){ + var result, $this = this + if (!selector) result = $() + else if (typeof selector == 'object') + result = $(selector).filter(function(){ + var node = this + return emptyArray.some.call($this, function(parent){ + return $.contains(parent, node) + }) + }) + else if (this.length == 1) result = $(zepto.qsa(this[0], selector)) + else result = this.map(function(){ return zepto.qsa(this, selector) }) + return result + }, + closest: function(selector, context){ + var node = this[0], collection = false + if (typeof selector == 'object') collection = $(selector) + while (node && !(collection ? collection.indexOf(node) >= 0 : zepto.matches(node, selector))) + node = node !== context && !isDocument(node) && node.parentNode + return $(node) + }, + parents: function(selector){ + var ancestors = [], nodes = this + while (nodes.length > 0) + nodes = $.map(nodes, function(node){ + if ((node = node.parentNode) && !isDocument(node) && ancestors.indexOf(node) < 0) { + ancestors.push(node) + return node + } + }) + return filtered(ancestors, selector) + }, + parent: function(selector){ + return filtered(uniq(this.pluck('parentNode')), selector) + }, + children: function(selector){ + return filtered(this.map(function(){ return children(this) }), selector) + }, + contents: function() { + return this.map(function() { return this.contentDocument || slice.call(this.childNodes) }) + }, + siblings: function(selector){ + return filtered(this.map(function(i, el){ + return filter.call(children(el.parentNode), function(child){ return child!==el }) + }), selector) + }, + empty: function(){ + return this.each(function(){ this.innerHTML = '' }) + }, + // `pluck` is borrowed from Prototype.js + pluck: function(property){ + return $.map(this, function(el){ return el[property] }) + }, + show: function(){ + return this.each(function(){ + this.style.display == "none" && (this.style.display = '') + if (getComputedStyle(this, '').getPropertyValue("display") == "none") + this.style.display = defaultDisplay(this.nodeName) + }) + }, + replaceWith: function(newContent){ + return this.before(newContent).remove() + }, + wrap: function(structure){ + var func = isFunction(structure) + if (this[0] && !func) + var dom = $(structure).get(0), + clone = dom.parentNode || this.length > 1 + + return this.each(function(index){ + $(this).wrapAll( + func ? structure.call(this, index) : + clone ? dom.cloneNode(true) : dom + ) + }) + }, + wrapAll: function(structure){ + if (this[0]) { + $(this[0]).before(structure = $(structure)) + var children + // drill down to the inmost element + while ((children = structure.children()).length) structure = children.first() + $(structure).append(this) + } + return this + }, + wrapInner: function(structure){ + var func = isFunction(structure) + return this.each(function(index){ + var self = $(this), contents = self.contents(), + dom = func ? structure.call(this, index) : structure + contents.length ? contents.wrapAll(dom) : self.append(dom) + }) + }, + unwrap: function(){ + this.parent().each(function(){ + $(this).replaceWith($(this).children()) + }) + return this + }, + clone: function(){ + return this.map(function(){ return this.cloneNode(true) }) + }, + hide: function(){ + return this.css("display", "none") + }, + toggle: function(setting){ + return this.each(function(){ + var el = $(this) + ;(setting === undefined ? el.css("display") == "none" : setting) ? el.show() : el.hide() + }) + }, + prev: function(selector){ return $(this.pluck('previousElementSibling')).filter(selector || '*') }, + next: function(selector){ return $(this.pluck('nextElementSibling')).filter(selector || '*') }, + html: function(html){ + return 0 in arguments ? + this.each(function(idx){ + var originHtml = this.innerHTML + $(this).empty().append( funcArg(this, html, idx, originHtml) ) + }) : + (0 in this ? this[0].innerHTML : null) + }, + text: function(text){ + return 0 in arguments ? + this.each(function(idx){ + var newText = funcArg(this, text, idx, this.textContent) + this.textContent = newText == null ? '' : ''+newText + }) : + (0 in this ? this.pluck('textContent').join("") : null) + }, + attr: function(name, value){ + var result + return (typeof name == 'string' && !(1 in arguments)) ? + (!this.length || this[0].nodeType !== 1 ? undefined : + (!(result = this[0].getAttribute(name)) && name in this[0]) ? this[0][name] : result + ) : + this.each(function(idx){ + if (this.nodeType !== 1) return + if (isObject(name)) for (key in name) setAttribute(this, key, name[key]) + else setAttribute(this, name, funcArg(this, value, idx, this.getAttribute(name))) + }) + }, + removeAttr: function(name){ + return this.each(function(){ this.nodeType === 1 && name.split(' ').forEach(function(attribute){ + setAttribute(this, attribute) + }, this)}) + }, + prop: function(name, value){ + name = propMap[name] || name + return (1 in arguments) ? + this.each(function(idx){ + this[name] = funcArg(this, value, idx, this[name]) + }) : + (this[0] && this[0][name]) + }, + data: function(name, value){ + var attrName = 'data-' + name.replace(capitalRE, '-$1').toLowerCase() + + var data = (1 in arguments) ? + this.attr(attrName, value) : + this.attr(attrName) + + return data !== null ? deserializeValue(data) : undefined + }, + val: function(value){ + return 0 in arguments ? + this.each(function(idx){ + this.value = funcArg(this, value, idx, this.value) + }) : + (this[0] && (this[0].multiple ? + $(this[0]).find('option').filter(function(){ return this.selected }).pluck('value') : + this[0].value) + ) + }, + offset: function(coordinates){ + if (coordinates) return this.each(function(index){ + var $this = $(this), + coords = funcArg(this, coordinates, index, $this.offset()), + parentOffset = $this.offsetParent().offset(), + props = { + top: coords.top - parentOffset.top, + left: coords.left - parentOffset.left + } + + if ($this.css('position') == 'static') props['position'] = 'relative' + $this.css(props) + }) + if (!this.length) return null + if (!$.contains(document.documentElement, this[0])) + return {top: 0, left: 0} + var obj = this[0].getBoundingClientRect() + return { + left: obj.left + window.pageXOffset, + top: obj.top + window.pageYOffset, + width: Math.round(obj.width), + height: Math.round(obj.height) + } + }, + css: function(property, value){ + if (arguments.length < 2) { + var computedStyle, element = this[0] + if(!element) return + computedStyle = getComputedStyle(element, '') + if (typeof property == 'string') + return element.style[camelize(property)] || computedStyle.getPropertyValue(property) + else if (isArray(property)) { + var props = {} + $.each(property, function(_, prop){ + props[prop] = (element.style[camelize(prop)] || computedStyle.getPropertyValue(prop)) + }) + return props + } + } + + var css = '' + if (type(property) == 'string') { + if (!value && value !== 0) + this.each(function(){ this.style.removeProperty(dasherize(property)) }) + else + css = dasherize(property) + ":" + maybeAddPx(property, value) + } else { + for (key in property) + if (!property[key] && property[key] !== 0) + this.each(function(){ this.style.removeProperty(dasherize(key)) }) + else + css += dasherize(key) + ':' + maybeAddPx(key, property[key]) + ';' + } + + return this.each(function(){ this.style.cssText += ';' + css }) + }, + index: function(element){ + return element ? this.indexOf($(element)[0]) : this.parent().children().indexOf(this[0]) + }, + hasClass: function(name){ + if (!name) return false + return emptyArray.some.call(this, function(el){ + return this.test(className(el)) + }, classRE(name)) + }, + addClass: function(name){ + if (!name) return this + return this.each(function(idx){ + if (!('className' in this)) return + classList = [] + var cls = className(this), newName = funcArg(this, name, idx, cls) + newName.split(/\s+/g).forEach(function(klass){ + if (!$(this).hasClass(klass)) classList.push(klass) + }, this) + classList.length && className(this, cls + (cls ? " " : "") + classList.join(" ")) + }) + }, + removeClass: function(name){ + return this.each(function(idx){ + if (!('className' in this)) return + if (name === undefined) return className(this, '') + classList = className(this) + funcArg(this, name, idx, classList).split(/\s+/g).forEach(function(klass){ + classList = classList.replace(classRE(klass), " ") + }) + className(this, classList.trim()) + }) + }, + toggleClass: function(name, when){ + if (!name) return this + return this.each(function(idx){ + var $this = $(this), names = funcArg(this, name, idx, className(this)) + names.split(/\s+/g).forEach(function(klass){ + (when === undefined ? !$this.hasClass(klass) : when) ? + $this.addClass(klass) : $this.removeClass(klass) + }) + }) + }, + scrollTop: function(value){ + if (!this.length) return + var hasScrollTop = 'scrollTop' in this[0] + if (value === undefined) return hasScrollTop ? this[0].scrollTop : this[0].pageYOffset + return this.each(hasScrollTop ? + function(){ this.scrollTop = value } : + function(){ this.scrollTo(this.scrollX, value) }) + }, + scrollLeft: function(value){ + if (!this.length) return + var hasScrollLeft = 'scrollLeft' in this[0] + if (value === undefined) return hasScrollLeft ? this[0].scrollLeft : this[0].pageXOffset + return this.each(hasScrollLeft ? + function(){ this.scrollLeft = value } : + function(){ this.scrollTo(value, this.scrollY) }) + }, + position: function() { + if (!this.length) return + + var elem = this[0], + // Get *real* offsetParent + offsetParent = this.offsetParent(), + // Get correct offsets + offset = this.offset(), + parentOffset = rootNodeRE.test(offsetParent[0].nodeName) ? { top: 0, left: 0 } : offsetParent.offset() + + // Subtract element margins + // note: when an element has margin: auto the offsetLeft and marginLeft + // are the same in Safari causing offset.left to incorrectly be 0 + offset.top -= parseFloat( $(elem).css('margin-top') ) || 0 + offset.left -= parseFloat( $(elem).css('margin-left') ) || 0 + + // Add offsetParent borders + parentOffset.top += parseFloat( $(offsetParent[0]).css('border-top-width') ) || 0 + parentOffset.left += parseFloat( $(offsetParent[0]).css('border-left-width') ) || 0 + + // Subtract the two offsets + return { + top: offset.top - parentOffset.top, + left: offset.left - parentOffset.left + } + }, + offsetParent: function() { + return this.map(function(){ + var parent = this.offsetParent || document.body + while (parent && !rootNodeRE.test(parent.nodeName) && $(parent).css("position") == "static") + parent = parent.offsetParent + return parent + }) + } + } + + // for now + $.fn.detach = $.fn.remove + + // Generate the `width` and `height` functions + ;['width', 'height'].forEach(function(dimension){ + var dimensionProperty = + dimension.replace(/./, function(m){ return m[0].toUpperCase() }) + + $.fn[dimension] = function(value){ + var offset, el = this[0] + if (value === undefined) return isWindow(el) ? el['inner' + dimensionProperty] : + isDocument(el) ? el.documentElement['scroll' + dimensionProperty] : + (offset = this.offset()) && offset[dimension] + else return this.each(function(idx){ + el = $(this) + el.css(dimension, funcArg(this, value, idx, el[dimension]())) + }) + } + }) + + function traverseNode(node, fun) { + fun(node) + for (var i = 0, len = node.childNodes.length; i < len; i++) + traverseNode(node.childNodes[i], fun) + } + + // Generate the `after`, `prepend`, `before`, `append`, + // `insertAfter`, `insertBefore`, `appendTo`, and `prependTo` methods. + adjacencyOperators.forEach(function(operator, operatorIndex) { + var inside = operatorIndex % 2 //=> prepend, append + + $.fn[operator] = function(){ + // arguments can be nodes, arrays of nodes, Zepto objects and HTML strings + var argType, nodes = $.map(arguments, function(arg) { + argType = type(arg) + return argType == "object" || argType == "array" || arg == null ? + arg : zepto.fragment(arg) + }), + parent, copyByClone = this.length > 1 + if (nodes.length < 1) return this + + return this.each(function(_, target){ + parent = inside ? target : target.parentNode + + // convert all methods to a "before" operation + target = operatorIndex == 0 ? target.nextSibling : + operatorIndex == 1 ? target.firstChild : + operatorIndex == 2 ? target : + null + + var parentInDocument = $.contains(document.documentElement, parent) + + nodes.forEach(function(node){ + if (copyByClone) node = node.cloneNode(true) + else if (!parent) return $(node).remove() + + parent.insertBefore(node, target) + if (parentInDocument) traverseNode(node, function(el){ + if (el.nodeName != null && el.nodeName.toUpperCase() === 'SCRIPT' && + (!el.type || el.type === 'text/javascript') && !el.src) + window['eval'].call(window, el.innerHTML) + }) + }) + }) + } + + // after => insertAfter + // prepend => prependTo + // before => insertBefore + // append => appendTo + $.fn[inside ? operator+'To' : 'insert'+(operatorIndex ? 'Before' : 'After')] = function(html){ + $(html)[operator](this) + return this + } + }) + + zepto.Z.prototype = Z.prototype = $.fn + + // Export internal API functions in the `$.zepto` namespace + zepto.uniq = uniq + zepto.deserializeValue = deserializeValue + $.zepto = zepto + + return $ +})() + +window.Zepto = Zepto +window.$ === undefined && (window.$ = Zepto) + +;(function($){ + var _zid = 1, undefined, + slice = Array.prototype.slice, + isFunction = $.isFunction, + isString = function(obj){ return typeof obj == 'string' }, + handlers = {}, + specialEvents={}, + focusinSupported = 'onfocusin' in window, + focus = { focus: 'focusin', blur: 'focusout' }, + hover = { mouseenter: 'mouseover', mouseleave: 'mouseout' } + + specialEvents.click = specialEvents.mousedown = specialEvents.mouseup = specialEvents.mousemove = 'MouseEvents' + + function zid(element) { + return element._zid || (element._zid = _zid++) + } + function findHandlers(element, event, fn, selector) { + event = parse(event) + if (event.ns) var matcher = matcherFor(event.ns) + return (handlers[zid(element)] || []).filter(function(handler) { + return handler + && (!event.e || handler.e == event.e) + && (!event.ns || matcher.test(handler.ns)) + && (!fn || zid(handler.fn) === zid(fn)) + && (!selector || handler.sel == selector) + }) + } + function parse(event) { + var parts = ('' + event).split('.') + return {e: parts[0], ns: parts.slice(1).sort().join(' ')} + } + function matcherFor(ns) { + return new RegExp('(?:^| )' + ns.replace(' ', ' .* ?') + '(?: |$)') + } + + function eventCapture(handler, captureSetting) { + return handler.del && + (!focusinSupported && (handler.e in focus)) || + !!captureSetting + } + + function realEvent(type) { + return hover[type] || (focusinSupported && focus[type]) || type + } + + function add(element, events, fn, data, selector, delegator, capture){ + var id = zid(element), set = (handlers[id] || (handlers[id] = [])) + events.split(/\s/).forEach(function(event){ + if (event == 'ready') return $(document).ready(fn) + var handler = parse(event) + handler.fn = fn + handler.sel = selector + // emulate mouseenter, mouseleave + if (handler.e in hover) fn = function(e){ + var related = e.relatedTarget + if (!related || (related !== this && !$.contains(this, related))) + return handler.fn.apply(this, arguments) + } + handler.del = delegator + var callback = delegator || fn + handler.proxy = function(e){ + e = compatible(e) + if (e.isImmediatePropagationStopped()) return + e.data = data + var result = callback.apply(element, e._args == undefined ? [e] : [e].concat(e._args)) + if (result === false) e.preventDefault(), e.stopPropagation() + return result + } + handler.i = set.length + set.push(handler) + if ('addEventListener' in element) + element.addEventListener(realEvent(handler.e), handler.proxy, eventCapture(handler, capture)) + }) + } + function remove(element, events, fn, selector, capture){ + var id = zid(element) + ;(events || '').split(/\s/).forEach(function(event){ + findHandlers(element, event, fn, selector).forEach(function(handler){ + delete handlers[id][handler.i] + if ('removeEventListener' in element) + element.removeEventListener(realEvent(handler.e), handler.proxy, eventCapture(handler, capture)) + }) + }) + } + + $.event = { add: add, remove: remove } + + $.proxy = function(fn, context) { + var args = (2 in arguments) && slice.call(arguments, 2) + if (isFunction(fn)) { + var proxyFn = function(){ return fn.apply(context, args ? args.concat(slice.call(arguments)) : arguments) } + proxyFn._zid = zid(fn) + return proxyFn + } else if (isString(context)) { + if (args) { + args.unshift(fn[context], fn) + return $.proxy.apply(null, args) + } else { + return $.proxy(fn[context], fn) + } + } else { + throw new TypeError("expected function") + } + } + + $.fn.bind = function(event, data, callback){ + return this.on(event, data, callback) + } + $.fn.unbind = function(event, callback){ + return this.off(event, callback) + } + $.fn.one = function(event, selector, data, callback){ + return this.on(event, selector, data, callback, 1) + } + + var returnTrue = function(){return true}, + returnFalse = function(){return false}, + ignoreProperties = /^([A-Z]|returnValue$|layer[XY]$)/, + eventMethods = { + preventDefault: 'isDefaultPrevented', + stopImmediatePropagation: 'isImmediatePropagationStopped', + stopPropagation: 'isPropagationStopped' + } + + function compatible(event, source) { + if (source || !event.isDefaultPrevented) { + source || (source = event) + + $.each(eventMethods, function(name, predicate) { + var sourceMethod = source[name] + event[name] = function(){ + this[predicate] = returnTrue + return sourceMethod && sourceMethod.apply(source, arguments) + } + event[predicate] = returnFalse + }) + + if (source.defaultPrevented !== undefined ? source.defaultPrevented : + 'returnValue' in source ? source.returnValue === false : + source.getPreventDefault && source.getPreventDefault()) + event.isDefaultPrevented = returnTrue + } + return event + } + + function createProxy(event) { + var key, proxy = { originalEvent: event } + for (key in event) + if (!ignoreProperties.test(key) && event[key] !== undefined) proxy[key] = event[key] + + return compatible(proxy, event) + } + + $.fn.delegate = function(selector, event, callback){ + return this.on(event, selector, callback) + } + $.fn.undelegate = function(selector, event, callback){ + return this.off(event, selector, callback) + } + + $.fn.live = function(event, callback){ + $(document.body).delegate(this.selector, event, callback) + return this + } + $.fn.die = function(event, callback){ + $(document.body).undelegate(this.selector, event, callback) + return this + } + + $.fn.on = function(event, selector, data, callback, one){ + var autoRemove, delegator, $this = this + if (event && !isString(event)) { + $.each(event, function(type, fn){ + $this.on(type, selector, data, fn, one) + }) + return $this + } + + if (!isString(selector) && !isFunction(callback) && callback !== false) + callback = data, data = selector, selector = undefined + if (callback === undefined || data === false) + callback = data, data = undefined + + if (callback === false) callback = returnFalse + + return $this.each(function(_, element){ + if (one) autoRemove = function(e){ + remove(element, e.type, callback) + return callback.apply(this, arguments) + } + + if (selector) delegator = function(e){ + var evt, match = $(e.target).closest(selector, element).get(0) + if (match && match !== element) { + evt = $.extend(createProxy(e), {currentTarget: match, liveFired: element}) + return (autoRemove || callback).apply(match, [evt].concat(slice.call(arguments, 1))) + } + } + + add(element, event, callback, data, selector, delegator || autoRemove) + }) + } + $.fn.off = function(event, selector, callback){ + var $this = this + if (event && !isString(event)) { + $.each(event, function(type, fn){ + $this.off(type, selector, fn) + }) + return $this + } + + if (!isString(selector) && !isFunction(callback) && callback !== false) + callback = selector, selector = undefined + + if (callback === false) callback = returnFalse + + return $this.each(function(){ + remove(this, event, callback, selector) + }) + } + + $.fn.trigger = function(event, args){ + event = (isString(event) || $.isPlainObject(event)) ? $.Event(event) : compatible(event) + event._args = args + return this.each(function(){ + // handle focus(), blur() by calling them directly + if (event.type in focus && typeof this[event.type] == "function") this[event.type]() + // items in the collection might not be DOM elements + else if ('dispatchEvent' in this) this.dispatchEvent(event) + else $(this).triggerHandler(event, args) + }) + } + + // triggers event handlers on current element just as if an event occurred, + // doesn't trigger an actual event, doesn't bubble + $.fn.triggerHandler = function(event, args){ + var e, result + this.each(function(i, element){ + e = createProxy(isString(event) ? $.Event(event) : event) + e._args = args + e.target = element + $.each(findHandlers(element, event.type || event), function(i, handler){ + result = handler.proxy(e) + if (e.isImmediatePropagationStopped()) return false + }) + }) + return result + } + + // shortcut methods for `.bind(event, fn)` for each event type + ;('focusin focusout focus blur load resize scroll unload click dblclick '+ + 'mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave '+ + 'change select keydown keypress keyup error').split(' ').forEach(function(event) { + $.fn[event] = function(callback) { + return (0 in arguments) ? + this.bind(event, callback) : + this.trigger(event) + } + }) + + $.Event = function(type, props) { + if (!isString(type)) props = type, type = props.type + var event = document.createEvent(specialEvents[type] || 'Events'), bubbles = true + if (props) for (var name in props) (name == 'bubbles') ? (bubbles = !!props[name]) : (event[name] = props[name]) + event.initEvent(type, bubbles, true) + return compatible(event) + } + +})(Zepto) + +;(function($){ + var jsonpID = 0, + document = window.document, + key, + name, + rscript = /)<[^<]*)*<\/script>/gi, + scriptTypeRE = /^(?:text|application)\/javascript/i, + xmlTypeRE = /^(?:text|application)\/xml/i, + jsonType = 'application/json', + htmlType = 'text/html', + blankRE = /^\s*$/, + originAnchor = document.createElement('a') + + originAnchor.href = window.location.href + + // trigger a custom event and return false if it was cancelled + function triggerAndReturn(context, eventName, data) { + var event = $.Event(eventName) + $(context).trigger(event, data) + return !event.isDefaultPrevented() + } + + // trigger an Ajax "global" event + function triggerGlobal(settings, context, eventName, data) { + if (settings.global) return triggerAndReturn(context || document, eventName, data) + } + + // Number of active Ajax requests + $.active = 0 + + function ajaxStart(settings) { + if (settings.global && $.active++ === 0) triggerGlobal(settings, null, 'ajaxStart') + } + function ajaxStop(settings) { + if (settings.global && !(--$.active)) triggerGlobal(settings, null, 'ajaxStop') + } + + // triggers an extra global event "ajaxBeforeSend" that's like "ajaxSend" but cancelable + function ajaxBeforeSend(xhr, settings) { + var context = settings.context + if (settings.beforeSend.call(context, xhr, settings) === false || + triggerGlobal(settings, context, 'ajaxBeforeSend', [xhr, settings]) === false) + return false + + triggerGlobal(settings, context, 'ajaxSend', [xhr, settings]) + } + function ajaxSuccess(data, xhr, settings, deferred) { + var context = settings.context, status = 'success' + settings.success.call(context, data, status, xhr) + if (deferred) deferred.resolveWith(context, [data, status, xhr]) + triggerGlobal(settings, context, 'ajaxSuccess', [xhr, settings, data]) + ajaxComplete(status, xhr, settings) + } + // type: "timeout", "error", "abort", "parsererror" + function ajaxError(error, type, xhr, settings, deferred) { + var context = settings.context + settings.error.call(context, xhr, type, error) + if (deferred) deferred.rejectWith(context, [xhr, type, error]) + triggerGlobal(settings, context, 'ajaxError', [xhr, settings, error || type]) + ajaxComplete(type, xhr, settings) + } + // status: "success", "notmodified", "error", "timeout", "abort", "parsererror" + function ajaxComplete(status, xhr, settings) { + var context = settings.context + settings.complete.call(context, xhr, status) + triggerGlobal(settings, context, 'ajaxComplete', [xhr, settings]) + ajaxStop(settings) + } + + // Empty function, used as default callback + function empty() {} + + $.ajaxJSONP = function(options, deferred){ + if (!('type' in options)) return $.ajax(options) + + var _callbackName = options.jsonpCallback, + callbackName = ($.isFunction(_callbackName) ? + _callbackName() : _callbackName) || ('jsonp' + (++jsonpID)), + script = document.createElement('script'), + originalCallback = window[callbackName], + responseData, + abort = function(errorType) { + $(script).triggerHandler('error', errorType || 'abort') + }, + xhr = { abort: abort }, abortTimeout + + if (deferred) deferred.promise(xhr) + + $(script).on('load error', function(e, errorType){ + clearTimeout(abortTimeout) + $(script).off().remove() + + if (e.type == 'error' || !responseData) { + ajaxError(null, errorType || 'error', xhr, options, deferred) + } else { + ajaxSuccess(responseData[0], xhr, options, deferred) + } + + window[callbackName] = originalCallback + if (responseData && $.isFunction(originalCallback)) + originalCallback(responseData[0]) + + originalCallback = responseData = undefined + }) + + if (ajaxBeforeSend(xhr, options) === false) { + abort('abort') + return xhr + } + + window[callbackName] = function(){ + responseData = arguments + } + + script.src = options.url.replace(/\?(.+)=\?/, '?$1=' + callbackName) + document.head.appendChild(script) + + if (options.timeout > 0) abortTimeout = setTimeout(function(){ + abort('timeout') + }, options.timeout) + + return xhr + } + + $.ajaxSettings = { + // Default type of request + type: 'GET', + // Callback that is executed before request + beforeSend: empty, + // Callback that is executed if the request succeeds + success: empty, + // Callback that is executed the the server drops error + error: empty, + // Callback that is executed on request complete (both: error and success) + complete: empty, + // The context for the callbacks + context: null, + // Whether to trigger "global" Ajax events + global: true, + // Transport + xhr: function () { + return new window.XMLHttpRequest() + }, + // MIME types mapping + // IIS returns Javascript as "application/x-javascript" + accepts: { + script: 'text/javascript, application/javascript, application/x-javascript', + json: jsonType, + xml: 'application/xml, text/xml', + html: htmlType, + text: 'text/plain' + }, + // Whether the request is to another domain + crossDomain: false, + // Default timeout + timeout: 0, + // Whether data should be serialized to string + processData: true, + // Whether the browser should be allowed to cache GET responses + cache: true + } + + function mimeToDataType(mime) { + if (mime) mime = mime.split(';', 2)[0] + return mime && ( mime == htmlType ? 'html' : + mime == jsonType ? 'json' : + scriptTypeRE.test(mime) ? 'script' : + xmlTypeRE.test(mime) && 'xml' ) || 'text' + } + + function appendQuery(url, query) { + if (query == '') return url + return (url + '&' + query).replace(/[&?]{1,2}/, '?') + } + + // serialize payload and append it to the URL for GET requests + function serializeData(options) { + if (options.processData && options.data && $.type(options.data) != "string") + options.data = $.param(options.data, options.traditional) + if (options.data && (!options.type || options.type.toUpperCase() == 'GET')) + options.url = appendQuery(options.url, options.data), options.data = undefined + } + + $.ajax = function(options){ + var settings = $.extend({}, options || {}), + deferred = $.Deferred && $.Deferred(), + urlAnchor, hashIndex + for (key in $.ajaxSettings) if (settings[key] === undefined) settings[key] = $.ajaxSettings[key] + + ajaxStart(settings) + + if (!settings.crossDomain) { + urlAnchor = document.createElement('a') + urlAnchor.href = settings.url + // cleans up URL for .href (IE only), see https://github.com/madrobby/zepto/pull/1049 + urlAnchor.href = urlAnchor.href + settings.crossDomain = (originAnchor.protocol + '//' + originAnchor.host) !== (urlAnchor.protocol + '//' + urlAnchor.host) + } + + if (!settings.url) settings.url = window.location.toString() + if ((hashIndex = settings.url.indexOf('#')) > -1) settings.url = settings.url.slice(0, hashIndex) + serializeData(settings) + + var dataType = settings.dataType, hasPlaceholder = /\?.+=\?/.test(settings.url) + if (hasPlaceholder) dataType = 'jsonp' + + if (settings.cache === false || ( + (!options || options.cache !== true) && + ('script' == dataType || 'jsonp' == dataType) + )) + settings.url = appendQuery(settings.url, '_=' + Date.now()) + + if ('jsonp' == dataType) { + if (!hasPlaceholder) + settings.url = appendQuery(settings.url, + settings.jsonp ? (settings.jsonp + '=?') : settings.jsonp === false ? '' : 'callback=?') + return $.ajaxJSONP(settings, deferred) + } + + var mime = settings.accepts[dataType], + headers = { }, + setHeader = function(name, value) { headers[name.toLowerCase()] = [name, value] }, + protocol = /^([\w-]+:)\/\//.test(settings.url) ? RegExp.$1 : window.location.protocol, + xhr = settings.xhr(), + nativeSetHeader = xhr.setRequestHeader, + abortTimeout + + if (deferred) deferred.promise(xhr) + + if (!settings.crossDomain) setHeader('X-Requested-With', 'XMLHttpRequest') + setHeader('Accept', mime || '*/*') + if (mime = settings.mimeType || mime) { + if (mime.indexOf(',') > -1) mime = mime.split(',', 2)[0] + xhr.overrideMimeType && xhr.overrideMimeType(mime) + } + if (settings.contentType || (settings.contentType !== false && settings.data && settings.type.toUpperCase() != 'GET')) + setHeader('Content-Type', settings.contentType || 'application/x-www-form-urlencoded') + + if (settings.headers) for (name in settings.headers) setHeader(name, settings.headers[name]) + xhr.setRequestHeader = setHeader + + xhr.onreadystatechange = function(){ + if (xhr.readyState == 4) { + xhr.onreadystatechange = empty + clearTimeout(abortTimeout) + var result, error = false + if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304 || (xhr.status == 0 && protocol == 'file:')) { + dataType = dataType || mimeToDataType(settings.mimeType || xhr.getResponseHeader('content-type')) + + if (xhr.responseType == 'arraybuffer' || xhr.responseType == 'blob') + result = xhr.response + else { + result = xhr.responseText + + try { + // http://perfectionkills.com/global-eval-what-are-the-options/ + if (dataType == 'script') (1,eval)(result) + else if (dataType == 'xml') result = xhr.responseXML + else if (dataType == 'json') result = blankRE.test(result) ? null : $.parseJSON(result) + } catch (e) { error = e } + + if (error) return ajaxError(error, 'parsererror', xhr, settings, deferred) + } + + ajaxSuccess(result, xhr, settings, deferred) + } else { + ajaxError(xhr.statusText || null, xhr.status ? 'error' : 'abort', xhr, settings, deferred) + } + } + } + + if (ajaxBeforeSend(xhr, settings) === false) { + xhr.abort() + ajaxError(null, 'abort', xhr, settings, deferred) + return xhr + } + + if (settings.xhrFields) for (name in settings.xhrFields) xhr[name] = settings.xhrFields[name] + + var async = 'async' in settings ? settings.async : true + xhr.open(settings.type, settings.url, async, settings.username, settings.password) + + for (name in headers) nativeSetHeader.apply(xhr, headers[name]) + + if (settings.timeout > 0) abortTimeout = setTimeout(function(){ + xhr.onreadystatechange = empty + xhr.abort() + ajaxError(null, 'timeout', xhr, settings, deferred) + }, settings.timeout) + + // avoid sending empty string (#319) + xhr.send(settings.data ? settings.data : null) + return xhr + } + + // handle optional data/success arguments + function parseArguments(url, data, success, dataType) { + if ($.isFunction(data)) dataType = success, success = data, data = undefined + if (!$.isFunction(success)) dataType = success, success = undefined + return { + url: url + , data: data + , success: success + , dataType: dataType + } + } + + $.get = function(/* url, data, success, dataType */){ + return $.ajax(parseArguments.apply(null, arguments)) + } + + $.post = function(/* url, data, success, dataType */){ + var options = parseArguments.apply(null, arguments) + options.type = 'POST' + return $.ajax(options) + } + + $.getJSON = function(/* url, data, success */){ + var options = parseArguments.apply(null, arguments) + options.dataType = 'json' + return $.ajax(options) + } + + $.fn.load = function(url, data, success){ + if (!this.length) return this + var self = this, parts = url.split(/\s/), selector, + options = parseArguments(url, data, success), + callback = options.success + if (parts.length > 1) options.url = parts[0], selector = parts[1] + options.success = function(response){ + self.html(selector ? + $('
').html(response.replace(rscript, "")).find(selector) + : response) + callback && callback.apply(self, arguments) + } + $.ajax(options) + return this + } + + var escape = encodeURIComponent + + function serialize(params, obj, traditional, scope){ + var type, array = $.isArray(obj), hash = $.isPlainObject(obj) + $.each(obj, function(key, value) { + type = $.type(value) + if (scope) key = traditional ? scope : + scope + '[' + (hash || type == 'object' || type == 'array' ? key : '') + ']' + // handle data in serializeArray() format + if (!scope && array) params.add(value.name, value.value) + // recurse into nested objects + else if (type == "array" || (!traditional && type == "object")) + serialize(params, value, traditional, key) + else params.add(key, value) + }) + } + + $.param = function(obj, traditional){ + var params = [] + params.add = function(key, value) { + if ($.isFunction(value)) value = value() + if (value == null) value = "" + this.push(escape(key) + '=' + escape(value)) + } + serialize(params, obj, traditional) + return params.join('&').replace(/%20/g, '+') + } +})(Zepto) + +;(function($){ + $.fn.serializeArray = function() { + var name, type, result = [], + add = function(value) { + if (value.forEach) return value.forEach(add) + result.push({ name: name, value: value }) + } + if (this[0]) $.each(this[0].elements, function(_, field){ + type = field.type, name = field.name + if (name && field.nodeName.toLowerCase() != 'fieldset' && + !field.disabled && type != 'submit' && type != 'reset' && type != 'button' && type != 'file' && + ((type != 'radio' && type != 'checkbox') || field.checked)) + add($(field).val()) + }) + return result + } + + $.fn.serialize = function(){ + var result = [] + this.serializeArray().forEach(function(elm){ + result.push(encodeURIComponent(elm.name) + '=' + encodeURIComponent(elm.value)) + }) + return result.join('&') + } + + $.fn.submit = function(callback) { + if (0 in arguments) this.bind('submit', callback) + else if (this.length) { + var event = $.Event('submit') + this.eq(0).trigger(event) + if (!event.isDefaultPrevented()) this.get(0).submit() + } + return this + } + +})(Zepto) diff --git a/public/js/manga_collection.js b/public/js/manga_collection.js new file mode 100644 index 00000000..94614874 --- /dev/null +++ b/public/js/manga_collection.js @@ -0,0 +1,26 @@ +(($, AnimeClient) => { + + 'use strict'; + + const search = (tempHtml, query) => { + $('.cssload-loader').removeAttr('hidden'); + $.get(AnimeClient.url('/manga/search'), {'query':query,}, (searchResults, status) => { + $('.cssload-loader').attr('hidden', 'hidden'); + + Mustache.parse(tempHtml); + $('#series_list').html(Mustache.render(tempHtml, searchResults)); + }); + }; + + $.get('/public/templates/manga-ajax-search-results.html', tempHtml => { + $('#search').on('keyup', AnimeClient.throttle(250, function(e) { + let query = encodeURIComponent($(this).val()); + if (query === '') { + return; + } + + search(tempHtml, query); + })); + }); + +})(Zepto, AnimeClient); \ No newline at end of file diff --git a/public/js/manga_edit.js b/public/js/manga_edit.js index bd67089e..e943b1f9 100755 --- a/public/js/manga_edit.js +++ b/public/js/manga_edit.js @@ -1,20 +1,20 @@ /** * Javascript for editing manga, if logged in */ -(($, AnimeClient, w) => { +(($, AnimeClient) => { 'use strict'; - $('body.manga.list').on('click', '.edit_buttons button', function(e) { + $('.manga.list').on('click', '.edit_buttons button', function(e) { let this_sel = $(this); let parent_sel = $(this).closest("article"); let manga_id = parent_sel.attr("id").replace("manga-", ""); let type = this_sel.is(".plus_one_chapter") ? 'chapter' : 'volume'; let completed = parseInt(parent_sel.find(`.${type}s_read`).text(), 10); let total = parseInt(parent_sel.find(`.${type}_count`).text(), 10); + let manga_name = parent_sel.find('.name').html(); - if (isNaN(completed)) - { + if (isNaN(completed)) { completed = 0; } @@ -28,18 +28,20 @@ $.ajax({ data: data, dataType: 'json', - method: 'POST', + type: 'POST', mimeType: 'application/json', url: AnimeClient.url('/manga/update'), - }).done((res) => { - parent_sel.find(`.${type}s_read`).text(completed); - AnimeClient.showMessage('success', `Sucessfully updated ${res.body.manga[0].romaji_title}`); - - // scroll to top - w.scroll(0,0); - }).fail(() => { - AnimeClient.showMessage('error', `Failed to updated ${res.body.manga[0].romaji_title}`); + success: (res, status) => { + parent_sel.find(`.${type}s_read`).text(completed); + AnimeClient.showMessage('success', `Sucessfully updated ${manga_name}`); + AnimeClient.scrollToTop(); + }, + error: (xhr, errorType, error) => { + console.error(error); + AnimeClient.showMessage('error', `Failed to updated ${manga_name}`); + AnimeClient.scrollToTop(); + } }); }); -})(jQuery, AnimeClient, window); \ No newline at end of file +})(Zepto, AnimeClient); \ No newline at end of file diff --git a/public/templates/ajax-search-results.html b/public/templates/anime-ajax-search-results.html similarity index 100% rename from public/templates/ajax-search-results.html rename to public/templates/anime-ajax-search-results.html diff --git a/public/templates/manga-ajax-search-results.html b/public/templates/manga-ajax-search-results.html new file mode 100644 index 00000000..25594c2b --- /dev/null +++ b/public/templates/manga-ajax-search-results.html @@ -0,0 +1,10 @@ +{{#search}} + +{{/search}} \ No newline at end of file diff --git a/src/Aviat/AnimeClient/Controller.php b/src/Aviat/AnimeClient/Controller.php index 5344e75b..3651d0e0 100644 --- a/src/Aviat/AnimeClient/Controller.php +++ b/src/Aviat/AnimeClient/Controller.php @@ -382,11 +382,13 @@ class Controller { * Output a JSON Response * * @param mixed $data + * @param int $code - the http status code * @return void */ - protected function outputJSON($data = []) + protected function outputJSON($data = [], $code = 200) { $view = new JsonView($this->container); + $view->setStatusCode($code); $view->setOutput($data); } diff --git a/src/Aviat/AnimeClient/Controller/Anime.php b/src/Aviat/AnimeClient/Controller/Anime.php index 9ac478df..c1084185 100644 --- a/src/Aviat/AnimeClient/Controller/Anime.php +++ b/src/Aviat/AnimeClient/Controller/Anime.php @@ -249,11 +249,8 @@ class Anime extends BaseController { */ public function update() { - $this->outputJSON( - $this->model->update( - $this->request->post->get() - ) - ); + $response = $this->model->update($this->request->post->get()); + $this->outputJSON($response['body'], $response['statusCode']); } /** diff --git a/src/Aviat/AnimeClient/Controller/Manga.php b/src/Aviat/AnimeClient/Controller/Manga.php index 8ddfb69d..c893ca8c 100644 --- a/src/Aviat/AnimeClient/Controller/Manga.php +++ b/src/Aviat/AnimeClient/Controller/Manga.php @@ -91,6 +91,61 @@ class Manga extends Controller { ]); } + /** + * Form to add an manga + * + * @return void + */ + public function add_form() + { + $raw_status_list = MangaReadingStatus::getConstList(); + + $statuses = []; + + foreach ($raw_status_list as $status_item) + { + $statuses[$status_item] = (string)$this->string($status_item) + ->underscored() + ->humanize() + ->titleize(); + } + + $this->set_session_redirect(); + $this->outputHTML('manga/add', [ + 'title' => $this->config->get('whose_list') . + "'s Manga List · Add", + 'action_url' => $this->urlGenerator->url('manga/add'), + 'status_list' => $statuses + ]); + } + + /** + * Add an manga to the list + * + * @return void + */ + public function add() + { + $data = $this->request->post->get(); + if ( ! array_key_exists('id', $data)) + { + $this->redirect("manga/add", 303); + } + + $result = $this->model->add($data); + + if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) + { + $this->set_flash_message('Added new manga to list', 'success'); + } + else + { + $this->set_flash_message('Failed to add new manga to list' . $result['body'] , 'error'); + } + + $this->session_redirect(); + } + /** * Show the manga edit form * @@ -113,6 +168,18 @@ class Manga extends Controller { ]); } + /** + * Search for a manga to add to the list + * + * @param string $query + * @return void + */ + public function search() + { + $query = $this->request->query->get('query'); + $this->outputJSON($this->model->search($query)); + } + /** * Update an anime item via a form submission * @@ -141,7 +208,7 @@ class Manga extends Controller { } else { - $this->set_flash_message('Failed to update anime.', 'error'); + $this->set_flash_message('Failed to update manga.', 'error'); } $this->session_redirect(); @@ -154,7 +221,8 @@ class Manga extends Controller { */ public function update() { - $this->outputJSON($this->model->update($this->request->post->get())); + $result = $this->model->update($this->request->post->get()); + $this->outputJSON($result['body'], $result['statusCode']); } } // End of MangaController.php \ No newline at end of file diff --git a/src/Aviat/AnimeClient/Model/Manga.php b/src/Aviat/AnimeClient/Model/Manga.php index a34475c1..7f6f44fe 100644 --- a/src/Aviat/AnimeClient/Model/Manga.php +++ b/src/Aviat/AnimeClient/Model/Manga.php @@ -50,6 +50,49 @@ class Manga extends API { */ protected $base_url = "https://hummingbird.me/"; + protected function _auth_json_call($type, $url, $json) + { + $token = $this->container->get('auth') + ->get_auth_token(); + + // Set the token cookie, with the authentication token + // from the auth class. + $cookieJar = $this->cookieJar; + $cookie_data = new SetCookie([ + 'Name' => 'token', + 'Value' => $token, + 'Domain' => 'hummingbird.me' + ]); + $cookieJar->setCookie($cookie_data); + + $result = $this->client->request(strtoupper($type), $url, [ + 'cookies' => $cookieJar, + 'json' => $json + ]); + + return [ + 'statusCode' => $result->getStatusCode(), + 'body' => $result->getBody() + ]; + } + + /** + * Add a manga to the list + * + * @param array $data + */ + public function add($data) + { + $object = [ + 'manga_library_entry' => [ + 'status' => $data['status'], + 'manga_id' => $data['id'] + ] + ]; + + return $this->_auth_json_call('post', 'manga_library_entries', $object); + } + /** * Update the selected manga * @@ -60,6 +103,23 @@ class Manga extends API { { $id = $data['id']; + return $this->_auth_json_call( + 'put', + "manga_library_entries/{$id}", + ['manga_library_entry' => $data] + ); + } + + /** + * Delete a manga entry + * + * @param array $data + * @return array + */ + public function delete($data) + { + $id = $data['id']; + $token = $this->container->get('auth') ->get_auth_token(); @@ -73,17 +133,47 @@ class Manga extends API { ]); $cookieJar->setCookie($cookie_data); - $result = $this->put("manga_library_entries/{$id}", [ + $result = $this->delete("manga_library_entries/{$id}", [ 'cookies' => $cookieJar, - 'json' => ['manga_library_entry' => $data] ]); return [ 'statusCode' => $result->getStatusCode(), - 'body' => Json::decode($result->getBody(), TRUE) + 'body' => $result->getBody() ]; } + /** + * Search for manga by name + * + * @param string $name + * @return array + */ + public function search($name) + { + $logger = $this->container->getLogger('default'); + + $config = [ + 'query' => [ + 'scope' => 'manga', + 'depth' => 'full', + 'query' => $name + ] + ]; + + $response = $this->get('search.json', $config); + + if ($response->getStatusCode() != 200) + { + $logger->warning("Non 200 response for search api call"); + $logger->warning($response->getBody()); + + throw new RuntimeException($response->getEffectiveUrl()); + } + + return Json::decode($response->getBody(), TRUE); + } + /** * Get the full set of anime lists *