From d6daf84da0fb8a5c16b240e478f04a2554eea562 Mon Sep 17 00:00:00 2001
From: rr- <rr-@sakuya.pl>
Date: Sun, 10 Apr 2016 22:13:01 +0200
Subject: [PATCH] client/users: add basic users listing

---
 client/css/main.css                       | 23 ++++++-
 client/css/users.css                      | 32 ++++++++-
 client/html/manual_pager.hbs              |  5 ++
 client/html/manual_pager_nav.hbs          | 39 +++++++++++
 client/html/user_list.hbs                 | 17 +++++
 client/js/controllers/page_controller.js  | 16 +++++
 client/js/controllers/users_controller.js | 23 ++++++-
 client/js/util/handlebars-helpers.js      |  8 ++-
 client/js/util/misc.js                    | 18 +++++
 client/js/views/manual_page_view.js       | 81 +++++++++++++++++++++++
 client/js/views/user_list_view.js         | 18 +++++
 11 files changed, 274 insertions(+), 6 deletions(-)
 create mode 100644 client/html/manual_pager.hbs
 create mode 100644 client/html/manual_pager_nav.hbs
 create mode 100644 client/html/user_list.hbs
 create mode 100644 client/js/controllers/page_controller.js
 create mode 100644 client/js/views/manual_page_view.js
 create mode 100644 client/js/views/user_list_view.js

diff --git a/client/css/main.css b/client/css/main.css
index 7ff46206..0b339667 100644
--- a/client/css/main.css
+++ b/client/css/main.css
@@ -47,7 +47,7 @@ a {
 }
 
 #content-holder {
-    margin-top: 2em;
+    margin: 2em;
     text-align: center;
 }
 #content-holder>.content-wrapper {
@@ -154,9 +154,30 @@ nav.text-nav ul li.active a {
     background-repeat: no-repeat;
     background-size: cover;
     background-position: center;
+    display: inline-block;
 }
 .thumbnail img {
     opacity: 0;
     width: 100%;
     height: 100%;
 }
+
+.flexbox-dummy {
+    height: 0 !important;
+    padding-top: 0 !important;
+    padding-bottom: 0 !important;
+    margin-top: 0 !important;
+    margin-bottom: 0 !important;
+}
+
+.pager nav .disabled {
+    opacity: .5;
+}
+.pager nav .prev span,
+.pager nav .next span {
+    opacity: 0;
+    position: absolute;
+    display: block;
+    width: 0;
+    height: 0;
+}
diff --git a/client/css/users.css b/client/css/users.css
index 01b6d651..85e0e2f8 100644
--- a/client/css/users.css
+++ b/client/css/users.css
@@ -65,8 +65,6 @@
 }
 #user-edit form {
     width: 100%;
-}
-#user-edit form {
     display: flex;
 }
 #user-edit .left {
@@ -87,3 +85,33 @@
 #user-delete form {
     width: 100%;
 }
+
+.user-list ul {
+    list-style-type: none;
+    margin: 0;
+    padding: 0;
+    display: flex;
+    justify-content: center;
+    align-content: flex-end;
+    flex-wrap: wrap;
+}
+.user-list li {
+    width: 20em;
+    margin: 0.5em;
+    padding: 0.75em;
+    vertical-align: top;
+    background: var(--top-nav-color);
+    text-align: left;
+}
+.user-list .wrapper {
+    display: flex;
+}
+.user-list .details {
+    font-size: 90%;
+    line-height: 130%;
+}
+.user-list .thumbnail {
+    width: 3em;
+    height: 3em;
+    margin: 0 0.6em 0 0;
+}
diff --git a/client/html/manual_pager.hbs b/client/html/manual_pager.hbs
new file mode 100644
index 00000000..4bef2437
--- /dev/null
+++ b/client/html/manual_pager.hbs
@@ -0,0 +1,5 @@
+<div class='pager'>
+    <div class='messages'></div>
+    <div class='page-content-holder'></div>
+    <div class='page-nav'></div>
+</div>
diff --git a/client/html/manual_pager_nav.hbs b/client/html/manual_pager_nav.hbs
new file mode 100644
index 00000000..7586526d
--- /dev/null
+++ b/client/html/manual_pager_nav.hbs
@@ -0,0 +1,39 @@
+<nav class='text-nav'>
+    <ul>
+        <li>
+        {{#if this.prevLinkActive}}
+            <a class='prev' href='{{this.prevLink}}'>
+        {{else}}
+            <a class='prev disabled'>
+        {{/if}}
+                <i class='fa fa-chevron-left'></i>
+                <span>Previous page</span>
+            </a>
+        </li>
+
+        {{#each this.pages}}
+            {{#if this.ellipsis}}
+                <li>&hellip;</li>
+            {{else}}
+                {{#if this.active}}
+                    <li class='active'>
+                {{else}}
+                    <li>
+                {{/if}}
+                    <a href='{{this.link}}'>{{this.number}}</a>
+                </li>
+            {{/if}}
+        {{/each}}
+
+        <li>
+        {{#if this.nextLinkActive}}
+            <a class='next' href='{{this.nextLink}}'>
+        {{else}}
+            <a class='next disabled'>
+        {{/if}}
+                <i class='fa fa-chevron-right'></i>
+                <span>Next page</span>
+            </a>
+        </li>
+    </ul>
+</nav>
diff --git a/client/html/user_list.hbs b/client/html/user_list.hbs
new file mode 100644
index 00000000..c6baa34d
--- /dev/null
+++ b/client/html/user_list.hbs
@@ -0,0 +1,17 @@
+<div class='user-list'>
+    <ul><!--
+        -->{{#each this.users}}<!--
+            --><li>
+                <div class='wrapper'>
+                    <a class='image' href='/user/{{this.name}}'>{{thumbnail this.avatarUrl}}</a>
+                    <div class='details'>
+                        <a href='/user/{{this.name}}'>{{this.name}}</a><br/>
+                        Registered: {{reltime this.creationTime}}<br/>
+                        Last seen: {{reltime this.lastLoginTime}}
+                    </div>
+                </div>
+            </li><!--
+        -->{{/each}}<!--
+        -->{{alignFlexbox}}<!--
+    --></ul>
+</div>
diff --git a/client/js/controllers/page_controller.js b/client/js/controllers/page_controller.js
new file mode 100644
index 00000000..783f70b3
--- /dev/null
+++ b/client/js/controllers/page_controller.js
@@ -0,0 +1,16 @@
+'use strict';
+
+const api = require('../api.js');
+const ManualPageView = require('../views/manual_page_view.js');
+
+class PageController {
+    constructor() {
+        this.pageView = new ManualPageView();
+    }
+
+    run(ctx) {
+        this.pageView.render(ctx);
+    }
+}
+
+module.exports = new PageController();
diff --git a/client/js/controllers/users_controller.js b/client/js/controllers/users_controller.js
index 0e22fe20..c5046b0b 100644
--- a/client/js/controllers/users_controller.js
+++ b/client/js/controllers/users_controller.js
@@ -7,20 +7,26 @@ const events = require('../events.js');
 const misc = require('../util/misc.js');
 const views = require('../util/views.js');
 const topNavController = require('../controllers/top_nav_controller.js');
+const pageController = require('../controllers/page_controller.js');
 const RegistrationView = require('../views/registration_view.js');
 const UserView = require('../views/user_view.js');
+const UserListView = require('../views/user_list_view.js');
 const EmptyView = require('../views/empty_view.js');
 
 class UsersController {
     constructor() {
         this.registrationView = new RegistrationView();
         this.userView = new UserView();
+        this.userListView = new UserListView();
         this.emptyView = new EmptyView();
     }
 
     registerRoutes() {
         page('/register', () => { this.createUserRoute(); });
-        page('/users', () => { this.listUsersRoute(); });
+        page(
+            '/users/:query?',
+            (ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
+            (ctx, next) => { this.listUsersRoute(ctx, next); });
         page(
             '/user/:name',
             (ctx, next) => { this.loadUserRoute(ctx, next); },
@@ -36,8 +42,21 @@ class UsersController {
         page.exit('/user/', (ctx, next) => { this.user = null; });
     }
 
-    listUsersRoute() {
+    listUsersRoute(ctx, next) {
         topNavController.activate('users');
+
+        pageController.run({
+            requestPage: page => {
+                return api.get(
+                    '/users/?query={text}&page={page}&pageSize=30'.format({
+                        text: ctx.searchQuery.text,
+                        page: page}));
+            },
+            clientUrl: '/users/text={text};page={page}'.format({
+                text: ctx.searchQuery.text}),
+            initialPage: ctx.searchQuery.page,
+            pageRenderer: this.userListView,
+        });
     }
 
     createUserRoute() {
diff --git a/client/js/util/handlebars-helpers.js b/client/js/util/handlebars-helpers.js
index 7632bfcb..514509ad 100644
--- a/client/js/util/handlebars-helpers.js
+++ b/client/js/util/handlebars-helpers.js
@@ -14,7 +14,7 @@ handlebars.registerHelper('reltime', function(time) {
 
 handlebars.registerHelper('thumbnail', function(url) {
     return new handlebars.SafeString(
-        views.makeNonVoidElement('div', {
+        views.makeNonVoidElement('span', {
             class: 'thumbnail',
             style: 'background-image: url(\'{0}\')'.format(url)
         }, views.makeVoidElement('img', {alt: 'thumbnail', src: url})));
@@ -100,3 +100,9 @@ handlebars.registerHelper('emailInput', function(options) {
     options.hash.inputType = 'email';
     return handlebars.helpers.input(options);
 });
+
+handlebars.registerHelper('alignFlexbox', function(options) {
+    return new handlebars.SafeString(
+        Array.from(misc.range(20))
+            .map(() => '<li class="flexbox-dummy"></li>').join(''));
+});
diff --git a/client/js/util/misc.js b/client/js/util/misc.js
index 1909ceb8..9e8f13ac 100644
--- a/client/js/util/misc.js
+++ b/client/js/util/misc.js
@@ -52,7 +52,25 @@ function formatRelativeTime(timeString) {
     return future ? 'in ' + text : text + ' ago';
 }
 
+function parseSearchQuery(query) {
+    let result = {};
+    for (let word of (query || '').split(/;/)) {
+        const [key, value] = word.split(/=/, 2);
+        result[key] = value;
+    }
+    result.text = result.text || '';
+    result.page = parseInt(result.page || '1');
+    return result;
+}
+
+function parseSearchQueryRoute(ctx, next) {
+    ctx.searchQuery = parseSearchQuery(ctx.params.query || '');
+    next();
+}
+
 module.exports = {
     range: range,
+    parseSearchQuery: parseSearchQuery,
+    parseSearchQueryRoute: parseSearchQueryRoute,
     formatRelativeTime: formatRelativeTime,
 };
diff --git a/client/js/views/manual_page_view.js b/client/js/views/manual_page_view.js
new file mode 100644
index 00000000..d458257e
--- /dev/null
+++ b/client/js/views/manual_page_view.js
@@ -0,0 +1,81 @@
+'use strict';
+
+const events = require('../events.js');
+const misc = require('../util/misc.js');
+const views = require('../util/views.js');
+
+function removeConsecutiveDuplicates(a) {
+    return a.filter((item, pos, ary) => {
+        return !pos || item != ary[pos - 1];
+    });
+}
+
+class ManualPageView {
+    constructor() {
+        this.holderTemplate = views.getTemplate('manual-pager');
+        this.navTemplate = views.getTemplate('manual-pager-nav');
+    }
+
+    render(ctx) {
+        const target = document.getElementById('content-holder');
+        const source = this.holderTemplate();
+        const pageContentHolder = source.querySelector('.page-content-holder');
+        const pageNav = source.querySelector('.page-nav');
+
+        ctx.requestPage(ctx.initialPage).then(response => {
+            let pageRendererCtx = response;
+            pageRendererCtx.target = pageContentHolder;
+            ctx.pageRenderer.render(pageRendererCtx);
+
+            const totalPages = Math.ceil(response.total / response.pageSize);
+            const threshold = 2;
+
+            let pagesVisible = [];
+            for (let i = 1; i <= threshold; i++) {
+                pagesVisible.push(i);
+            }
+            for (let i = totalPages - threshold; i <= totalPages; i++) {
+                pagesVisible.push(i);
+            }
+            for (let i = ctx.initialPage - threshold;
+                    i <= ctx.initialPage + threshold;
+                    i++) {
+                pagesVisible.push(i);
+            }
+            pagesVisible = pagesVisible.filter((item, pos, ary) => {
+                return item >= 1 && item <= totalPages;
+            });
+            pagesVisible = pagesVisible.sort((a, b) => { return a - b; });
+            pagesVisible = removeConsecutiveDuplicates(pagesVisible);
+
+            const pages = [];
+            let lastPage = 0;
+            for (let page of pagesVisible) {
+                if (page !== lastPage + 1) {
+                    pages.push({ellipsis: true});
+                }
+                pages.push({
+                    number: page,
+                    link: ctx.clientUrl.format({page: page}),
+                    active: ctx.initialPage === page,
+                });
+                lastPage = page;
+            }
+            views.showView(pageNav, this.navTemplate({
+                prevLink: ctx.clientUrl.format({page: ctx.initialPage - 1}),
+                nextLink: ctx.clientUrl.format({page: ctx.initialPage + 1}),
+                prevLinkActive: ctx.initialPage > 1,
+                nextLinkActive: ctx.initialPage < totalPages,
+                pages: pages,
+            }));
+            views.listenToMessages(target);
+            views.showView(target, source);
+        }, response => {
+            views.listenToMessages(target);
+            views.showView(target, source);
+            events.notify(events.Error, response.description);
+        });
+    }
+}
+
+module.exports = ManualPageView;
diff --git a/client/js/views/user_list_view.js b/client/js/views/user_list_view.js
new file mode 100644
index 00000000..ab36637c
--- /dev/null
+++ b/client/js/views/user_list_view.js
@@ -0,0 +1,18 @@
+'use strict';
+
+const views = require('../util/views.js');
+
+class UserListView {
+    constructor() {
+        this.template = views.getTemplate('user-list');
+    }
+
+    render(ctx) {
+        const target = ctx.target;
+        const source = this.template(ctx);
+        views.listenToMessages(target);
+        views.showView(target, source);
+    }
+}
+
+module.exports = UserListView;