Refactor JS into modules, and add basic MVC.
authorChris Jaekl <cejaekl@yahoo.com>
Sat, 18 Nov 2017 13:06:38 +0000 (22:06 +0900)
committerChris Jaekl <cejaekl@yahoo.com>
Sat, 18 Nov 2017 13:06:38 +0000 (22:06 +0900)
app/index.html
js/.eslintrc.js [new file with mode: 0644]
js/BooksModel.js [new file with mode: 0644]
js/BooksView.js [new file with mode: 0644]
js/Gruntfile.js [new file with mode: 0644]
js/Main.js [new file with mode: 0644]
js/PagingController.js [new file with mode: 0644]
js/ToolTip.js [new file with mode: 0644]
js/package.json [new file with mode: 0644]

index 61f182d..76730a3 100644 (file)
@@ -9,7 +9,7 @@
   <body> 
     <form>
       <input id="search" onclick="onSearch();" type="button" value="Search"/> 
-      Author: <input id="aut" type="text"/> 
+      Author: <input id="aut" type="text"/>
       Title: <input id="tit" type="text"/>
       Series: <input id="ser" type="text"/>
     </form>
@@ -25,6 +25,6 @@
 
     <div id="details" class="tooltip" onclick="hideDetails();">(No information available)</div>
 
-    <script src="lib.js"></script>
+    <script src="lib.min.js"></script>
   </body>
 </html>
diff --git a/js/.eslintrc.js b/js/.eslintrc.js
new file mode 100644 (file)
index 0000000..e239734
--- /dev/null
@@ -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 (file)
index 0000000..0c0b915
--- /dev/null
@@ -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 (file)
index 0000000..7af3e97
--- /dev/null
@@ -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 = '<div class="book">'
+            +   '<table>'
+            +     '<tr>'
+            +       '<td><a href="/book/' + book.Id + '">';
+        if (0 == book.CoverId) {
+            result +=          '(No cover available)';
+        } else {
+            result +=          '<img class="cover-thumb" src="/download/' + book.CoverId + '"/>';
+        }
+        result     +=       '</a></td>'
+            +       '<td onclick="displayDetails(' + book.Id + ');" '
+            +          ' onmouseover="ToolTip.startTooltipTimer(' + book.Id + ');">'
+            +         '<p><b>' + book.Title + '</b></p>'
+            +         '<p>'
+            +           '<i>' + book.AuthorReading + '</i>';
+        if (typeof(book.SeriesName) !== 'undefined' && book.SeriesName.length > 0) {
+            result +=          '<br/><i>' + book.SeriesName + ' ' + book.Volume + '</i>';
+        }
+        result     +=         '</p>'
+            +       '</td>'
+            +     '</tr>'
+            +   '</table>'
+            + '</div>';
+        return result;
+    }
+    
+    return my;
+})();
diff --git a/js/Gruntfile.js b/js/Gruntfile.js
new file mode 100644 (file)
index 0000000..690a130
--- /dev/null
@@ -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 (file)
index 0000000..246044e
--- /dev/null
@@ -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 (file)
index 0000000..d5edea4
--- /dev/null
@@ -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 (file)
index 0000000..0311444
--- /dev/null
@@ -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 = '<div><p><b>' + book.Title + '</b></p>'
+        + '<p><i>' + ce(book.AuthorReading) + '<br/>' + ce(book.Series) + ' ' + ce(book.Volume) + '</i></p></div><div>'
+        + ce(book.Description)
+        + '</div>';
+
+        // 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 (file)
index 0000000..2cf9e61
--- /dev/null
@@ -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"
+  }
+}