client/router: introduce own router

I'm tired of page.js lack of documentation around finer quirks, and
being forced to read its crap code. Refactored into classes, removed
unused cruft.
This commit is contained in:
rr- 2016-06-12 20:11:43 +02:00
parent 4295e1c827
commit 76882b59ef
19 changed files with 415 additions and 94 deletions

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const api = require('../api.js');
const events = require('../events.js');
const topNavController = require('../controllers/top_nav_controller.js');
@ -14,13 +14,20 @@ class AuthController {
}
registerRoutes() {
page(/\/password-reset\/([^:]+):([^:]+)$/,
router.enter(
/\/password-reset\/([^:]+):([^:]+)$/,
(ctx, next) => {
this._passwordResetFinishRoute(ctx.params[0], ctx.params[1]);
});
page('/password-reset', (ctx, next) => { this._passwordResetRoute(); });
page('/login', (ctx, next) => { this._loginRoute(); });
page('/logout', (ctx, next) => { this._logoutRoute(); });
router.enter(
'/password-reset',
(ctx, next) => { this._passwordResetRoute(); });
router.enter(
'/login',
(ctx, next) => { this._loginRoute(); });
router.enter(
'/logout',
(ctx, next) => { this._logoutRoute(); });
}
_loginRoute() {
@ -33,7 +40,7 @@ class AuthController {
api.login(name, password, doRemember)
.then(() => {
resolve();
page('/');
router.show('/');
events.notify(events.Success, 'Logged in');
}, errorMessage => {
reject(errorMessage);
@ -46,7 +53,7 @@ class AuthController {
_logoutRoute() {
api.forget();
api.logout();
page('/');
router.show('/');
events.notify(events.Success, 'Logged out');
}
@ -68,10 +75,10 @@ class AuthController {
}, response => {
return Promise.reject(response.description);
}).then(() => {
page('/');
router.show('/');
events.notify(events.Success, 'New password: ' + password);
}, errorMessage => {
page('/');
router.show('/');
events.notify(events.Error, errorMessage);
});
}

View file

@ -1,7 +1,7 @@
'use strict';
const api = require('../api.js');
const page = require('page');
const router = require('../router.js');
const misc = require('../util/misc.js');
const topNavController = require('../controllers/top_nav_controller.js');
const pageController = require('../controllers/page_controller.js');
@ -10,7 +10,7 @@ const EmptyView = require('../views/empty_view.js');
class CommentsController {
registerRoutes() {
page('/comments/:query?',
router.enter('/comments/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { this._listCommentsRoute(ctx); });
this._commentsPageView = new CommentsPageView();

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const topNavController = require('../controllers/top_nav_controller.js');
const HelpView = require('../views/help_view.js');
@ -10,11 +10,13 @@ class HelpController {
}
registerRoutes() {
page('/help', () => { this._showHelpRoute(); });
page(
router.enter(
'/help',
(ctx, next) => { this._showHelpRoute(); });
router.enter(
'/help/:section',
(ctx, next) => { this._showHelpRoute(ctx.params.section); });
page(
router.enter(
'/help/:section/:subsection',
(ctx, next) => {
this._showHelpRoute(ctx.params.section, ctx.params.subsection);

View file

@ -1,11 +1,13 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const topNavController = require('../controllers/top_nav_controller.js');
class HistoryController {
registerRoutes() {
page('/history', (ctx, next) => { this._listHistoryRoute(); });
router.enter(
'/history',
(ctx, next) => { this._listHistoryRoute(); });
}
_listHistoryRoute() {

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const api = require('../api.js');
const events = require('../events.js');
const topNavController = require('../controllers/top_nav_controller.js');
@ -14,8 +14,12 @@ class HomeController {
}
registerRoutes() {
page('/', (ctx, next) => { this._indexRoute(); });
page('*', (ctx, next) => { this._notFoundRoute(ctx); });
router.enter(
'/',
(ctx, next) => { this._indexRoute(); });
router.enter(
'*',
(ctx, next) => { this._notFoundRoute(ctx); });
}
_indexRoute() {

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const api = require('../api.js');
const settings = require('../settings.js');
const events = require('../events.js');
@ -20,14 +20,17 @@ class PostsController {
}
registerRoutes() {
page('/upload', (ctx, next) => { this._uploadPostsRoute(); });
page('/posts/:query?',
router.enter(
'/upload',
(ctx, next) => { this._uploadPostsRoute(); });
router.enter(
'/posts/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { this._listPostsRoute(ctx); });
page(
router.enter(
'/post/:id',
(ctx, next) => { this._showPostRoute(ctx.params.id, false); });
page(
router.enter(
'/post/:id/edit',
(ctx, next) => { this._showPostRoute(ctx.params.id, true); });
this._emptyView = new EmptyView();

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const settings = require('../settings.js');
const topNavController = require('../controllers/top_nav_controller.js');
const SettingsView = require('../views/settings_view.js');
@ -11,7 +11,7 @@ class SettingsController {
}
registerRoutes() {
page('/settings', (ctx, next) => { this._settingsRoute(); });
router.enter('/settings', (ctx, next) => { this._settingsRoute(); });
}
_settingsRoute() {

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const api = require('../api.js');
const tags = require('../tags.js');
const events = require('../events.js');
@ -23,20 +23,22 @@ class TagsController {
}
registerRoutes() {
page('/tag-categories', () => { this._tagCategoriesRoute(); });
page(
router.enter(
'/tag-categories',
(ctx, next) => { this._tagCategoriesRoute(ctx, next); });
router.enter(
'/tag/:name',
(ctx, next) => { this._loadTagRoute(ctx, next); },
(ctx, next) => { this._showTagRoute(ctx, next); });
page(
router.enter(
'/tag/:name/merge',
(ctx, next) => { this._loadTagRoute(ctx, next); },
(ctx, next) => { this._mergeTagRoute(ctx, next); });
page(
router.enter(
'/tag/:name/delete',
(ctx, next) => { this._loadTagRoute(ctx, next); },
(ctx, next) => { this._deleteTagRoute(ctx, next); });
page(
router.enter(
'/tags/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { this._listTagsRoute(ctx, next); });
@ -136,7 +138,7 @@ class TagsController {
_saveTag(tag, input) {
return api.put('/tag/' + tag.names[0], input).then(response => {
if (input.names && input.names[0] !== tag.names[0]) {
page('/tag/' + input.names[0]);
router.show('/tag/' + input.names[0]);
}
events.notify(events.Success, 'Tag saved.');
return Promise.resolve();
@ -151,7 +153,7 @@ class TagsController {
'/tag-merge/',
{remove: tag.names[0], mergeTo: targetTagName}
).then(response => {
page('/tag/' + targetTagName + '/merge');
router.show('/tag/' + targetTagName + '/merge');
events.notify(events.Success, 'Tag merged.');
return Promise.resolve();
}, response => {
@ -162,7 +164,7 @@ class TagsController {
_deleteTag(tag) {
return api.delete('/tag/' + tag.names[0]).then(response => {
page('/tags/');
router.show('/tags/');
events.notify(events.Success, 'Tag deleted.');
return Promise.resolve();
}, response => {

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const api = require('../api.js');
const config = require('../config.js');
const events = require('../events.js');
@ -34,28 +34,31 @@ class UsersController {
}
registerRoutes() {
page('/register', () => { this._createUserRoute(); });
page(
router.enter(
'/register',
(ctx, next) => { this._createUserRoute(ctx, next); });
router.enter(
'/users/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { this._listUsersRoute(ctx, next); });
page(
router.enter(
'/user/:name',
(ctx, next) => { this._loadUserRoute(ctx, next); },
(ctx, next) => { this._showUserRoute(ctx, next); });
page(
router.enter(
'/user/:name/edit',
(ctx, next) => { this._loadUserRoute(ctx, next); },
(ctx, next) => { this._editUserRoute(ctx, next); });
page(
router.enter(
'/user/:name/delete',
(ctx, next) => { this._loadUserRoute(ctx, next); },
(ctx, next) => { this._deleteUserRoute(ctx, next); });
page.exit(/\/users\/.*/, (ctx, next) => {
pageController.stop();
next();
});
page.exit(/\/user\/.*/, (ctx, next) => {
router.exit(
/\/users\/.*/, (ctx, next) => {
pageController.stop();
next();
});
router.exit(/\/user\/.*/, (ctx, next) => {
this._cachedUser = null;
next();
});
@ -81,7 +84,7 @@ class UsersController {
});
}
_createUserRoute() {
_createUserRoute(ctx, next) {
topNavController.activate('register');
this._registrationView.render({
register: (...args) => {
@ -135,7 +138,7 @@ class UsersController {
return Promise.reject(response.description);
}).then(() => {
resolve();
page('/');
router.show('/');
events.notify(events.Success, 'Welcome aboard!');
}, errorMessage => {
reject();
@ -184,7 +187,7 @@ class UsersController {
}).then(() => {
resolve();
if (data.name && data.name !== user.name) {
page('/user/' + data.name + '/edit');
router.show('/user/' + data.name + '/edit');
}
events.notify(events.Success, 'Settings updated.');
}, errorMessage => {
@ -203,9 +206,9 @@ class UsersController {
api.logout();
}
if (api.hasPrivilege('users:list')) {
page('/users');
router.show('/users');
} else {
page('/');
router.show('/');
}
events.notify(events.Success, 'Account deleted.');
return Promise.resolve();

View file

@ -3,32 +3,37 @@
require('./util/polyfill.js');
const misc = require('./util/misc.js');
const page = require('page');
const origPushState = page.Context.prototype.pushState;
page.Context.prototype.pushState = function() {
const router = require('./router.js');
const origPushState = router.Context.prototype.pushState;
router.Context.prototype.pushState = function() {
window.scrollTo(0, 0);
origPushState.call(this);
};
page.cancel = function(ctx) {
router.cancel = function(ctx) {
prevContext = ctx;
ctx.pushState();
};
page.exit((ctx, next) => {
views.unlistenToMessages();
if (misc.confirmPageExit()) {
next();
} else {
page.cancel(ctx);
}
});
router.exit(
/.*/,
(ctx, next) => {
views.unlistenToMessages();
if (misc.confirmPageExit()) {
next();
} else {
router.cancel(ctx);
}
});
const mousetrap = require('mousetrap');
page(/.*/, (ctx, next) => {
mousetrap.reset();
next();
});
router.enter(
/.*/,
(ctx, next) => {
mousetrap.reset();
next();
});
let controllers = [];
controllers.push(require('./controllers/auth_controller.js'));
@ -40,6 +45,7 @@ controllers.push(require('./controllers/history_controller.js'));
controllers.push(require('./controllers/tags_controller.js'));
controllers.push(require('./controllers/settings_controller.js'));
// home defines 404 routes, need to be registered as last
controllers.push(require('./controllers/home_controller.js'));
const tags = require('./tags.js');
@ -52,13 +58,13 @@ for (let controller of controllers) {
const api = require('./api.js');
Promise.all([tags.refreshExport(), api.loginFromCookies()])
.then(() => {
page();
router.start();
}).catch(errorMessage => {
if (window.location.href.indexOf('login') !== -1) {
api.forget();
page();
router.start();
} else {
page('/');
router.start('/');
events.notify(
events.Error,
'An error happened while trying to log you in: ' +

293
client/js/router.js Normal file
View file

@ -0,0 +1,293 @@
'use strict';
// modified page.js by visionmedia
// - removed unused crap
// - refactored to classes
const pathToRegexp = require('path-to-regexp');
const clickEvent = document.ontouchstart ? 'touchstart' : 'click';
let location = window.history.location || window.location;
const base = '';
let prevContext = null;
function _decodeURLEncodedURIComponent(val) {
if (typeof val !== 'string') {
return val;
}
return decodeURIComponent(val.replace(/\+/g, ' '));
}
function _isSameOrigin(href) {
let origin = location.protocol + '//' + location.hostname;
if (location.port) {
origin += ':' + location.port;
}
return href && href.indexOf(origin) === 0;
}
class Context {
constructor(path, state) {
if (path[0] === '/' && path.indexOf(base) !== 0) {
path = base + path;
}
this.canonicalPath = path;
this.path = path.replace(base, '') || '/';
this.title = document.title;
this.state = state || {};
this.state.path = path;
this.params = {};
}
pushState() {
history.pushState(this.state, this.title, this.canonicalPath);
}
save() {
history.replaceState(this.state, this.title, this.canonicalPath);
}
};
class Route {
constructor(path, options) {
options = options || {};
this.path = (path === '*') ? '(.*)' : path;
this.method = 'GET';
this.regexp = pathToRegexp(this.path, this.keys = [], options);
}
middleware(fn) {
return (ctx, next) => {
if (this.match(ctx.path, ctx.params)) {
return fn(ctx, next);
}
next();
};
}
match(path, params) {
const keys = this.keys;
const qsIndex = path.indexOf('?');
const pathname = ~qsIndex ? path.slice(0, qsIndex) : path;
const m = this.regexp.exec(decodeURIComponent(pathname));
if (!m) {
return false;
}
for (let i = 1, len = m.length; i < len; ++i) {
const key = keys[i - 1];
const val = _decodeURLEncodedURIComponent(m[i]);
if (val !== undefined || !(hasOwnProperty.call(params, key.name))) {
params[key.name] = val;
}
}
return true;
}
};
class Router {
constructor() {
this._callbacks = [];
this._exits = [];
this._current = '';
}
enter(path) {
const route = new Route(path);
for (let i = 1; i < arguments.length; ++i) {
this._callbacks.push(route.middleware(arguments[i]));
}
}
exit(path, fn) {
const route = new Route(path);
for (let i = 1; i < arguments.length; ++i) {
this._exits.push(route.middleware(arguments[i]));
}
}
start() {
if (this._running) {
return;
}
this._running = true;
this._onPopState = _onPopState(this);
this._onClick = _onClick(this);
window.addEventListener('popstate', this._onPopState, false);
document.addEventListener(clickEvent, this._onClick, false);
const url = location.pathname + location.search + location.hash;
this.replace(url, null, true);
}
stop() {
if (!this._running) {
return;
}
this._current = '';
this._running = false;
document.removeEventListener(clickEvent, this._onClick, false);
window.removeEventListener('popstate', this._onPopState, false);
}
show(path, state, push) {
const ctx = new Context(path, state);
this._current = ctx.path;
this.dispatch(ctx);
if (ctx.handled !== false && push !== false) {
ctx.pushState();
}
return ctx;
}
replace(path, state, dispatch) {
var ctx = new Context(path, state);
this._current = ctx.path;
ctx.save();
if (dispatch) {
this.dispatch(ctx);
}
return ctx;
}
dispatch(ctx) {
const prev = prevContext;
let i = 0;
let j = 0;
prevContext = ctx;
const nextExit = () => {
const fn = this._exits[j++];
if (!fn) {
return nextEnter();
}
fn(prev, nextExit);
};
const nextEnter = () => {
const fn = this._callbacks[i++];
if (ctx.path !== this._current) {
ctx.handled = false;
return;
}
if (!fn) {
return this._unhandled(ctx);
}
fn(ctx, nextEnter);
};
if (prev) {
nextExit();
} else {
nextEnter();
}
}
_unhandled(ctx) {
if (ctx.handled) {
return;
}
let current = location.pathname + location.search;
if (current === ctx.canonicalPath) {
return;
}
router.stop();
ctx.handled = false;
location.href = ctx.canonicalPath;
}
};
const _onPopState = router => {
let loaded = false;
if (document.readyState === 'complete') {
loaded = true;
} else {
window.addEventListener(
'load',
() => {
setTimeout(() => {
loaded = true;
}, 0);
});
}
return e => {
if (!loaded) {
return;
}
if (e.state) {
const path = e.state.path;
router.replace(path, e.state, true);
} else {
router.show(
location.pathname + location.hash,
undefined,
false);
}
};
};
const _onClick = router => {
return e => {
if (1 !== _which(e)) {
return;
}
if (e.metaKey || e.ctrlKey || e.shiftKey) {
return;
}
if (e.defaultPrevented) {
return;
}
let el = e.path ? e.path[0] : e.target;
while (el && el.nodeName !== 'A') {
el = el.parentNode;
}
if (!el || el.nodeName !== 'A') {
return;
}
if (el.hasAttribute('download') ||
el.getAttribute('rel') === 'external') {
return;
}
const link = el.getAttribute('href');
if (el.pathname === location.pathname && (el.hash || '#' === link)) {
return;
}
if (link && link.indexOf('mailto:') > -1) {
return;
}
if (el.target) {
return;
}
if (!_isSameOrigin(el.href)) {
return;
}
let path = el.pathname + el.search + (el.hash || '');
const orig = path;
if (path.indexOf(base) === 0) {
path = path.substr(base.length);
}
if (base && orig === path) {
return;
}
e.preventDefault();
router.show(orig);
};
};
function _which(e) {
e = e || window.event;
return e.which === null ? e.button : e.which;
}
Router.prototype.Context = Context;
Router.prototype.Route = Route;
module.exports = new Router();

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const events = require('../events.js');
const views = require('../util/views.js');
@ -55,10 +55,9 @@ class EndlessPageView {
}
let topPageNumber = parseInt(topPageNode.getAttribute('data-page'));
if (topPageNumber !== this.currentPage) {
page.replace(
router.replace(
_formatUrl(ctx.clientUrl, topPageNumber),
null,
false,
{},
false);
this.currentPage = topPageNumber;
}

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const config = require('../config.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
@ -32,7 +32,7 @@ class HomeView {
form.querySelector('input[name=all-posts')
.addEventListener('click', e => {
e.preventDefault();
page('/posts/');
router.show('/posts/');
});
const searchTextInput = form.querySelector(
@ -42,7 +42,7 @@ class HomeView {
e.preventDefault();
const text = searchTextInput.value;
searchTextInput.blur();
page('/posts/' + misc.formatSearchQuery({text: text}));
router.show('/posts/' + misc.formatSearchQuery({text: text}));
});
}

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const events = require('../events.js');
const keyboard = require('../util/keyboard.js');
const misc = require('../util/misc.js');
@ -85,12 +85,12 @@ class ManualPageView {
keyboard.bind(['a', 'left'], () => {
if (currentPage > 1) {
page.show(_formatUrl(ctx.clientUrl, currentPage - 1));
router.show(_formatUrl(ctx.clientUrl, currentPage - 1));
}
});
keyboard.bind(['d', 'right'], () => {
if (currentPage < totalPages) {
page.show(_formatUrl(ctx.clientUrl, currentPage + 1));
router.show(_formatUrl(ctx.clientUrl, currentPage + 1));
}
});

View file

@ -1,9 +1,9 @@
'use strict';
const api = require('../api.js');
const router = require('../router.js');
const views = require('../util/views.js');
const keyboard = require('../util/keyboard.js');
const page = require('page');
const PostContentControl = require('../controls/post_content_control.js');
const PostNotesOverlayControl
= require('../controls/post_notes_overlay_control.js');
@ -60,19 +60,19 @@ class PostView {
keyboard.bind('e', () => {
if (ctx.editMode) {
page.show('/post/' + ctx.post.id);
router.show('/post/' + ctx.post.id);
} else {
page.show('/post/' + ctx.post.id + '/edit');
router.show('/post/' + ctx.post.id + '/edit');
}
});
keyboard.bind(['a', 'left'], () => {
if (ctx.nextPostId) {
page.show('/post/' + ctx.nextPostId);
router.show('/post/' + ctx.nextPostId);
}
});
keyboard.bind(['d', 'right'], () => {
if (ctx.prevPostId) {
page.show('/post/' + ctx.prevPostId);
router.show('/post/' + ctx.prevPostId);
}
});
}

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const settings = require('../settings.js');
const keyboard = require('../util/keyboard.js');
const misc = require('../util/misc.js');
@ -56,14 +56,14 @@ class PostsHeaderView {
browsingSettings.listPosts[safety]
= !browsingSettings.listPosts[safety];
settings.saveSettings(browsingSettings, true);
page(url.replace(/{page}/, 1));
router.show(url.replace(/{page}/, 1));
}
_evtFormSubmit(e, searchTextInput) {
e.preventDefault();
const text = searchTextInput.value;
searchTextInput.blur();
page('/posts/' + misc.formatSearchQuery({text: text}));
router.show('/posts/' + misc.formatSearchQuery({text: text}));
}
}

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const keyboard = require('../util/keyboard.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
@ -31,7 +31,7 @@ class TagsHeaderView {
e.preventDefault();
const text = searchTextInput.value;
searchTextInput.blur();
page('/tags/' + misc.formatSearchQuery({text: text}));
router.show('/tags/' + misc.formatSearchQuery({text: text}));
});
views.showView(target, source);

View file

@ -1,6 +1,6 @@
'use strict';
const page = require('page');
const router = require('../router.js');
const keyboard = require('../util/keyboard.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
@ -25,7 +25,7 @@ class UsersHeaderView {
const searchTextInput = form.querySelector('[name=search-text]');
const text = searchTextInput.value;
searchTextInput.blur();
page('/users/' + misc.formatSearchQuery({text: text}));
router.show('/users/' + misc.formatSearchQuery({text: text}));
});
views.showView(target, source);

View file

@ -22,7 +22,7 @@
"merge": "^1.2.0",
"mousetrap": "^1.5.3",
"nprogress": "^0.2.0",
"page": "^1.7.1",
"path-to-regexp": "^1.5.1",
"stylus": "^0.54.2",
"superagent": "^1.8.3",
"uglify-js": "git://github.com/mishoo/UglifyJS2.git#harmony",