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 @@ +
+
+
+ +
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 @@ + 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 @@ +
+ +
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(() => '
  • ').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;