From ea10c37dd0cf10799d4e2c3e265f5e0e108a8023 Mon Sep 17 00:00:00 2001 From: Chris Jaekl Date: Sat, 18 Nov 2017 22:06:38 +0900 Subject: [PATCH] Refactor JS into modules, and add basic MVC. --- app/index.html | 4 +- js/.eslintrc.js | 30 ++++++++++ js/BooksModel.js | 68 +++++++++++++++++++++++ js/BooksView.js | 67 ++++++++++++++++++++++ js/Gruntfile.js | 25 +++++++++ js/Main.js | 122 ++++++++++++++++++++++++++++++++++++++++ js/PagingController.js | 45 +++++++++++++++ js/ToolTip.js | 123 +++++++++++++++++++++++++++++++++++++++++ js/package.json | 11 ++++ 9 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 js/.eslintrc.js create mode 100644 js/BooksModel.js create mode 100644 js/BooksView.js create mode 100644 js/Gruntfile.js create mode 100644 js/Main.js create mode 100644 js/PagingController.js create mode 100644 js/ToolTip.js create mode 100644 js/package.json diff --git a/app/index.html b/app/index.html index 61f182d..76730a3 100644 --- a/app/index.html +++ b/app/index.html @@ -9,7 +9,7 @@
- Author: + Author: Title: Series:
@@ -25,6 +25,6 @@
(No information available)
- + diff --git a/js/.eslintrc.js b/js/.eslintrc.js new file mode 100644 index 0000000..e239734 --- /dev/null +++ b/js/.eslintrc.js @@ -0,0 +1,30 @@ +module.exports = { + "env": { + "browser": true, + "es6": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "sourceType": "module" + }, + "rules": { + "indent": [ + "error", + 4 + ], + "linebreak-style": [ + "error", + "unix" + ], + "no-console": "off", + "no-unused-vars": "off", + "quotes": [ + "error", + "single" + ], + "semi": [ + "error", + "always" + ] + } +}; diff --git a/js/BooksModel.js b/js/BooksModel.js new file mode 100644 index 0000000..0c0b915 --- /dev/null +++ b/js/BooksModel.js @@ -0,0 +1,68 @@ +// ========== +// BooksModel + +var BooksModel = (function() { + + var my = {}; + + // ================= + // Private variables + + var listeners = []; + + // ================ + // Public variables + + my.cache = []; + my.count = 0, + my.first = 0, + my.ids = [], + my.last = (-1), + my.map = {}, // map from book.Id to index into cache[] + my.pageSize = 20; + + // ============== + // Public methods + + my.listen = function(subscriber) { + listeners.push(subscriber); + }; + + my.refreshData = function () { + var i; + var url = '/info/?ids='; + my.map = {}; + for (i = my.first; i <= my.last; ++i) { + if (i > my.first) { + url += ','; + } + var id = my.ids[i]; + url += id; + my.map[id] = i - my.first; + } + + fetch(url, {method:'GET', cache:'default'}) + .then(response => response.json()) + .then((jsonValue) => { + console.log('JSON response for info: ', jsonValue); + my.cache = jsonValue; + notifyAll(); // inform all subscribers that the model has been updated + }) + .catch(err => { + var msg = 'Error fetching book details via URL: ' + url + ': ' + err; + console.log(msg, err.stack); + report(msg); + }); + }; + + // =============== + // Private methods + + function notifyAll() { + for (var i in listeners) { + listeners[i].notify(); + } + } + + return my; +})(); diff --git a/js/BooksView.js b/js/BooksView.js new file mode 100644 index 0000000..7af3e97 --- /dev/null +++ b/js/BooksView.js @@ -0,0 +1,67 @@ +// ========= +// BooksView + +var BooksView = (function() { + var my = {}; + + // ======================== + // Private member variables + var booksModel = undefined; + + // ============== + // Public Methods + + my.init = function(linkedBooksModel) { + booksModel = linkedBooksModel; + + booksModel.listen(my); + }; + + // Called when the model changes + my.notify = function () { + var i; + var html = ''; + var limit = BooksModel.last - BooksModel.first; + for (i = 0; i <= limit; ++i) { + var book = BooksModel.cache[i]; + html += bookHtml(book); + } + + document.getElementById('books').innerHTML = html; + document.getElementById('first').innerHTML = (BooksModel.first + 1); + document.getElementById('last').innerHTML = (BooksModel.last + 1); + document.getElementById('count').innerHTML = BooksModel.count; + }; + + // =============== + // Private methods + + function bookHtml(book) { + var result = '
' + + '' + + '' + + '' + + '' + + '' + + '
'; + if (0 == book.CoverId) { + result += '(No cover available)'; + } else { + result += ''; + } + result += '' + + '

' + book.Title + '

' + + '

' + + '' + book.AuthorReading + ''; + if (typeof(book.SeriesName) !== 'undefined' && book.SeriesName.length > 0) { + result += '
' + book.SeriesName + ' ' + book.Volume + ''; + } + result += '

' + + '
' + + '
'; + return result; + } + + return my; +})(); diff --git a/js/Gruntfile.js b/js/Gruntfile.js new file mode 100644 index 0000000..690a130 --- /dev/null +++ b/js/Gruntfile.js @@ -0,0 +1,25 @@ +module.exports = function(grunt) { + + // Project configuration. + grunt.initConfig({ + pkg: grunt.file.readJSON('package.json'), + uglify: { + options: { + banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n', + mangle: false + }, + app: { + files: { + '../app/lib.min.js': ['BooksModel.js', 'BooksView.js', 'PagingController.js', 'ToolTip.js', 'Main.js'] + } + } + } + }); + + // Load the plugin that provides the "uglify" task. + grunt.loadNpmTasks('grunt-contrib-uglify'); + + // Default task(s). + grunt.registerTask('default', ['uglify']); + +}; diff --git a/js/Main.js b/js/Main.js new file mode 100644 index 0000000..246044e --- /dev/null +++ b/js/Main.js @@ -0,0 +1,122 @@ +//QuanLib: eBook Library +//(C) 2017 by Christian Jaekl (cejaekl@yahoo.com) + +'use strict'; + +// Global state information (yuck). TODO: refactor this to compartmentalize. +var g_state = { + mousePos: { // Last known position of the mouse cursor + x: undefined, + y: undefined + } +}; + +// ============== +// Initialization + +document.onmousemove = onMouseMove; + +BooksView.init(BooksModel); +PagingController.init(BooksModel); + +// ================ +// Global functions +// +// TODO: refactor this to compartmentalize more functionality. + +function report(message) { + document.getElementById('books').innerHTML = message; +} + +function constructSearchUrl() { + var url = window.location.protocol + '//' + window.location.host + '/search/'; + + var firstTime = true; + var terms = ['aut', 'tit', 'ser']; + + for (var idx in terms) { + var term = terms[idx]; + var elem = document.getElementById(term); + if (null === elem) { + console.log('Error: could not find form element for search term "' + term + '".'); + continue; + } + + var value = elem.value; + if (value.length > 0) { + if (firstTime) { + url += '?'; + firstTime = false; + } + else { + url += '&'; + } + url += term + '=' + encodeURIComponent('%' + value + '%'); + } + } + + return url; +} + +function onMouseMove(event) { + if (typeof event === 'undefined') { + return; + } + + var x = event.pageX; + var y = event.pageY; + + if ( x === g_state.mousePos.x + && y === g_state.mousePos.y) + { + // No change from previous known position. + // Nothing to see (or do) here, move along. + return; + } + + // Remember current mouse (x,y) position + g_state.mousePos.x = x; + g_state.mousePos.y = y; + + ToolTip.mouseMoved(x, y); +} + +function onNext() { + if (BooksModel.last < (BooksModel.count - 1)) { + PagingController.adjustPos(BooksModel.first + BooksModel.pageSize); + } +} + +function onPrev() { + if (BooksModel.first > 0) { + PagingController.adjustPos(BooksModel.first - BooksModel.pageSize); + } +} + +function onSlide(value) { + PagingController.adjustPos(value); +} + +function onSearch() { + var url = constructSearchUrl(); + + fetch(url, {method:'GET', cache:'default'}) + .then(response => response.json()) + .then((jsonValue) => { + // console.log('JSON response: ', jsonValue); + BooksModel.ids = jsonValue; + BooksModel.count = BooksModel.ids.length; + BooksModel.first = (-1); + + var elem = document.getElementById('slider'); + elem.max = BooksModel.count; + + PagingController.adjustPos(0); + }) + .catch(err => { + var msg = 'Error fetching JSON from URL: ' + url + ': ' + err + ':' + err.stack; + console.log(msg); + report(msg); + }); +} + diff --git a/js/PagingController.js b/js/PagingController.js new file mode 100644 index 0000000..d5edea4 --- /dev/null +++ b/js/PagingController.js @@ -0,0 +1,45 @@ +// ================ +// PagingController + +var PagingController = (function() { + var my = {}; + + var booksModel = undefined; + + // ============== + // Public Methods + + my.init = function(linkedBooksModel) { + booksModel = linkedBooksModel; + }; + + my.adjustPos = function (setting) { + var value = parseInt(setting); + + if (booksModel.first === value) { + // No change + return; + } + + var maxFirst = Math.max(0, booksModel.count - booksModel.pageSize); + + if (value < 0) { + booksModel.first = 0; + } else if (value > maxFirst) { + booksModel.first = maxFirst; + } else { + booksModel.first = value; + } + + booksModel.last = booksModel.first + booksModel.pageSize - 1; + if (booksModel.last >= booksModel.count) { + booksModel.last = booksModel.count - 1; + } + + document.getElementById('slider').value = setting; + + booksModel.refreshData(); + }; + + return my; +})(); diff --git a/js/ToolTip.js b/js/ToolTip.js new file mode 100644 index 0000000..0311444 --- /dev/null +++ b/js/ToolTip.js @@ -0,0 +1,123 @@ +// ======= +// ToolTip + +var ToolTip = (function () { + // ================= + // Private variables + var my = {}, + bookId = undefined, + milliSecs = 500, // time to wait before displaying tooltip + mousePos = { + x: undefined, + y: undefined + }, + threshold = 10, // number of pixels that mouse can move before tip is dismissed + timer = undefined; + + // ================ + // Public variables + my.booksModel = undefined; + + // ============== + // Public Methods + + // Set the book ID for the details pane, and then show it + my.displayDetails = function (newBookId) { + bookId = newBookId; + my.showDetails(); + }; + + // Hide the details pane, if it is currently visible + my.hideDetails = function () { + mousePos.x = undefined; + mousePos.y = undefined; + + var elem = document.getElementById('details'); + elem.innerHTML = ''; + elem.style.display = 'none'; + }; + + my.mouseMoved = function (x, y) { + // Is there an active tooltip? + if (typeof mousePos.x === 'undefined') { + // No active tooltip, so nothing further to do + return; + } + + var deltaX = Math.abs(x - mousePos.x); + var deltaY = Math.abs(y - mousePos.y); + + if ( deltaX > threshold + || deltaY > threshold ) + { + my.hideDetails(); + } + }; + + // Show the details pane + my.showDetails = function () { + var id = bookId; + var elem = document.getElementById('details'); + var index = BooksModel.map[id]; + var book = BooksModel.cache[index]; + var html = '

' + book.Title + '

' + + '

' + ce(book.AuthorReading) + '
' + ce(book.Series) + ' ' + ce(book.Volume) + '

' + + ce(book.Description) + + '
'; + + // Remember the current mouse (x,y). + // If we move the mouse too far from this point, that will trigger hiding the tooltip. + mousePos.x = g_state.mousePos.x; + mousePos.y = g_state.mousePos.y; + + elem.innerHTML = html; + + elem.style.display = 'block'; // show, and calculate size, so that we can query it below + + var x = mousePos.x; + var y = mousePos.y; + + var bcr = elem.getBoundingClientRect(); + + var width = bcr.width; + var height = bcr.height; + + x = Math.max(x - (width / 2), 0); + y = Math.max(y - (height / 2), 0); + + elem.style.left = x + 'px'; + elem.style.top = y + 'px'; + }; + + my.startTooltipTimer = function (newBookId) { + if (typeof timer !== 'undefined') { + clearTimeout(timer); + } + bookId = newBookId; + timer = setTimeout(my.showDetails, milliSecs); + }; + + my.stopTooltipTimer = function () { + if (typeof timer === 'undefined') { + return; + } + + clearTimeout(timer); + timer = undefined; + my.hideDetails(); + }; + + // =============== + // Private methods + + // ce(s): "clear if empty()" + // return s, unless it's undefined, in which case return an empty ("clear") string + function ce(s) { + if (typeof s !== 'undefined') { + return s; + } + return ''; + } + + return my; +})(); diff --git a/js/package.json b/js/package.json new file mode 100644 index 0000000..2cf9e61 --- /dev/null +++ b/js/package.json @@ -0,0 +1,11 @@ +{ + "name": "quanweb", + "version": "0.0.1", + "license": "GPL-3.0+", + "devDependencies": { + "grunt": "^1.0.1", + "grunt-contrib-jshint": "~0.10.0", + "grunt-contrib-nodeunit": "~0.4.1", + "grunt-contrib-uglify": "git://github.com/gruntjs/grunt-contrib-uglify.git#harmony" + } +} -- 2.30.2