client/general: refactor control flow

- Controller lifetime is bound to route lifetime
- View lifetime is bound to controller lifetime
- Control lifetime is bound to view lifetime
- Enhanced event dispatching
- Enhanced responsiveness in some places
- Views communicate user input to controllers via new event system
This commit is contained in:
rr- 2016-06-14 10:31:48 +02:00
parent c74f06da35
commit 54e3099c56
68 changed files with 1755 additions and 1561 deletions

View file

@ -2,9 +2,7 @@
<div class='messages'></div>
<header>
<h1><%= ctx.name %></h1>
<aside>
Serving <%= ctx.postCount %> posts (<%= ctx.makeFileSize(ctx.diskUsage) %>)
</aside>
<aside class='stats-container'></aside>
</header>
<% if (ctx.canListPosts) { %>
<form class='horizontal'>

View file

@ -0,0 +1 @@
Serving <%= ctx.postCount %> posts (<%= ctx.makeFileSize(ctx.diskUsage) %>)

View file

@ -6,8 +6,9 @@ const request = require('superagent');
const config = require('./config.js');
const events = require('./events.js');
class Api {
class Api extends events.EventTarget {
constructor() {
super();
this.user = null;
this.userName = null;
this.userPassword = null;
@ -136,11 +137,10 @@ class Api {
options);
this.user = response;
resolve();
events.notify(events.Authentication);
this.dispatchEvent(new CustomEvent('login'));
}).catch(response => {
reject(response.description);
this.logout();
events.notify(events.Authentication);
});
});
}
@ -149,7 +149,7 @@ class Api {
this.user = null;
this.userName = null;
this.userPassword = null;
events.notify(events.Authentication);
this.dispatchEvent(new CustomEvent('logout'));
}
forget() {

View file

@ -2,105 +2,47 @@
const router = require('../router.js');
const api = require('../api.js');
const events = require('../events.js');
const TopNavigation = require('../models/top_navigation.js');
const topNavigation = require('../models/top_navigation.js');
const LoginView = require('../views/login_view.js');
const PasswordResetView = require('../views/password_reset_view.js');
class AuthController {
class LoginController {
constructor() {
api.forget();
topNavigation.activate('login');
this._loginView = new LoginView();
this._passwordResetView = new PasswordResetView();
this._loginView.addEventListener('submit', e => this._evtLogin(e));
}
registerRoutes() {
router.enter(
/\/password-reset\/([^:]+):([^:]+)$/,
(ctx, next) => {
this._passwordResetFinishRoute(ctx.params[0], ctx.params[1]);
});
router.enter(
'/password-reset',
(ctx, next) => { this._passwordResetRoute(); });
router.enter(
'/login',
(ctx, next) => { this._loginRoute(); });
router.enter(
'/logout',
(ctx, next) => { this._logoutRoute(); });
}
_loginRoute() {
_evtLogin(e) {
this._loginView.clearMessages();
this._loginView.disableForm();
api.forget();
TopNavigation.activate('login');
this._loginView.render({
login: (name, password, doRemember) => {
return new Promise((resolve, reject) => {
api.forget();
api.login(name, password, doRemember)
.then(() => {
resolve();
router.show('/');
events.notify(events.Success, 'Logged in');
}, errorMessage => {
reject(errorMessage);
events.notify(events.Error, errorMessage);
});
});
}});
}
_logoutRoute() {
api.forget();
api.logout();
router.show('/');
events.notify(events.Success, 'Logged out');
}
_passwordResetRoute() {
TopNavigation.activate('login');
this._passwordResetView.render({
proceed: (...args) => {
return this._passwordReset(...args);
}});
}
_passwordResetFinishRoute(name, token) {
api.forget();
api.logout();
let password = null;
api.post('/password-reset/' + name, {token: token})
.then(response => {
password = response.password;
return api.login(name, password, false);
}, response => {
return Promise.reject(response.description);
}).then(() => {
router.show('/');
events.notify(events.Success, 'New password: ' + password);
api.login(e.detail.name, e.detail.password, e.detail.remember)
.then(() => {
const ctx = router.show('/');
ctx.controller.showSuccess('Logged in');
}, errorMessage => {
router.show('/');
events.notify(events.Error, errorMessage);
this._loginView.showError(errorMessage);
this._loginView.enableForm();
});
}
_passwordReset(nameOrEmail) {
api.forget();
api.logout();
return new Promise((resolve, reject) => {
api.get('/password-reset/' + nameOrEmail)
.then(() => {
resolve();
events.notify(
events.Success,
'E-mail has been sent. To finish the procedure, ' +
'please click the link it contains.');
}, response => {
reject();
events.notify(events.Error, response.description);
});
});
}
}
module.exports = new AuthController();
class LogoutController {
constructor() {
api.forget();
api.logout();
const ctx = router.show('/');
ctx.controller.showSuccess('Logged out');
}
}
module.exports = router => {
router.enter('/login', (ctx, next) => {
ctx.controller = new LoginController();
});
router.enter('/logout', (ctx, next) => {
ctx.controller = new LogoutController();
});
};

View file

@ -1,29 +1,19 @@
'use strict';
const api = require('../api.js');
const router = require('../router.js');
const misc = require('../util/misc.js');
const pageController = require('../controllers/page_controller.js');
const TopNavigation = require('../models/top_navigation.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
const CommentsPageView = require('../views/comments_page_view.js');
const EmptyView = require('../views/empty_view.js');
class CommentsController {
registerRoutes() {
router.enter('/comments/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { this._listCommentsRoute(ctx); });
this._commentsPageView = new CommentsPageView();
this._emptyView = new EmptyView();
}
constructor(ctx) {
topNavigation.activate('comments');
_listCommentsRoute(ctx) {
TopNavigation.activate('comments');
pageController.run({
this._pageController = new PageController({
searchQuery: ctx.searchQuery,
clientUrl: '/comments/' + misc.formatSearchQuery({page: '{page}'}),
requestPage: pageController.createHistoryCacheProxy(
requestPage: PageController.createHistoryCacheProxy(
ctx,
page => {
return api.get(
@ -31,12 +21,18 @@ class CommentsController {
`&page=${page}&pageSize=10&fields=` +
'id,comments,commentCount,thumbnailUrl');
}),
pageRenderer: this._commentsPageView,
pageContext: {
canViewPosts: api.hasPrivilege('posts:view'),
}
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'),
});
return new CommentsPageView(pageCtx);
},
});
}
}
};
module.exports = new CommentsController();
module.exports = router => {
router.enter('/comments/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { new CommentsController(ctx); });
};

View file

@ -1,35 +1,23 @@
'use strict';
const router = require('../router.js');
const TopNavigation = require('../models/top_navigation.js');
const topNavigation = require('../models/top_navigation.js');
const HelpView = require('../views/help_view.js');
class HelpController {
constructor() {
this._helpView = new HelpView();
}
registerRoutes() {
router.enter(
'/help',
(ctx, next) => { this._showHelpRoute(); });
router.enter(
'/help/:section',
(ctx, next) => { this._showHelpRoute(ctx.params.section); });
router.enter(
'/help/:section/:subsection',
(ctx, next) => {
this._showHelpRoute(ctx.params.section, ctx.params.subsection);
});
}
_showHelpRoute(section, subsection) {
TopNavigation.activate('help');
this._helpView.render({
section: section,
subsection: subsection,
});
constructor(section, subsection) {
topNavigation.activate('help');
this._helpView = new HelpView(section, subsection);
}
}
module.exports = new HelpController();
module.exports = router => {
router.enter('/help', (ctx, next) => {
new HelpController();
});
router.enter('/help/:section', (ctx, next) => {
new HelpController(ctx.params.section);
});
router.enter('/help/:section/:subsection', (ctx, next) => {
new HelpController(ctx.params.section, ctx.params.subsection);
});
};

View file

@ -1,18 +1,15 @@
'use strict';
const router = require('../router.js');
const TopNavigation = require('../models/top_navigation.js');
const topNavigation = require('../models/top_navigation.js');
class HistoryController {
registerRoutes() {
router.enter(
'/history',
(ctx, next) => { this._listHistoryRoute(); });
}
_listHistoryRoute() {
TopNavigation.activate('');
constructor() {
topNavigation.activate('');
}
}
module.exports = new HistoryController();
module.exports = router => {
router.enter('/history', (ctx, next) => {
ctx.controller = new HistoryController();
});
};

View file

@ -1,53 +1,49 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const events = require('../events.js');
const TopNavigation = require('../models/top_navigation.js');
const config = require('../config.js');
const topNavigation = require('../models/top_navigation.js');
const HomeView = require('../views/home_view.js');
const NotFoundView = require('../views/not_found_view.js');
class HomeController {
constructor() {
this._homeView = new HomeView();
this._notFoundView = new NotFoundView();
}
topNavigation.activate('home');
registerRoutes() {
router.enter(
'/',
(ctx, next) => { this._indexRoute(); });
router.enter(
'*',
(ctx, next) => { this._notFoundRoute(ctx); });
}
_indexRoute() {
TopNavigation.activate('home');
this._homeView = new HomeView({
name: config.name,
version: config.meta.version,
buildDate: config.meta.buildDate,
canListPosts: api.hasPrivilege('posts:list'),
});
api.get('/info')
.then(response => {
this._homeView.render({
canListPosts: api.hasPrivilege('posts:list'),
this._homeView.setStats({
diskUsage: response.diskUsage,
postCount: response.postCount,
});
this._homeView.setFeaturedPost({
featuredPost: response.featuredPost,
featuringUser: response.featuringUser,
featuringTime: response.featuringTime,
});
},
response => {
this._homeView.render({
canListPosts: api.hasPrivilege('posts:list'),
});
events.notify(events.Error, response.description);
this._homeView.showError(response.description);
});
}
_notFoundRoute(ctx) {
TopNavigation.activate('');
this._notFoundView.render({path: ctx.canonicalPath});
showSuccess(message) {
this._homeView.showSuccess(message);
}
}
module.exports = new HomeController();
showError(message) {
this._homeView.showError(message);
}
};
module.exports = router => {
router.enter('/', (ctx, next) => {
ctx.controller = new HomeController();
});
};

View file

@ -0,0 +1,17 @@
'use strict';
const topNavigation = require('../models/top_navigation.js');
const NotFoundView = require('../views/not_found_view.js');
class NotFoundController {
constructor(path) {
topNavigation.activate('');
this._notFoundView = new NotFoundView(path);
}
};
module.exports = router => {
router.enter('*', (ctx, next) => {
ctx.controller = new NotFoundController(ctx.canonicalPath);
});
};

View file

@ -1,43 +1,35 @@
'use strict';
const events = require('../events.js');
const settings = require('../settings.js');
const settings = require('../models/settings.js');
const EndlessPageView = require('../views/endless_page_view.js');
const ManualPageView = require('../views/manual_page_view.js');
class PageController {
constructor() {
events.listen(events.SettingsChange, () => {
this._update();
return true;
});
this._update();
}
_update() {
if (settings.getSettings().endlessScroll) {
this._pageView = new EndlessPageView();
} else {
this._pageView = new ManualPageView();
}
}
run(ctx) {
this._pageView.unrender();
constructor(ctx) {
const extendedContext = {
clientUrl: ctx.clientUrl,
searchQuery: ctx.searchQuery,
};
ctx.headerContext = ctx.headerContext || {};
ctx.pageContext = ctx.pageContext || {};
Object.assign(ctx.headerContext, extendedContext);
Object.assign(ctx.pageContext, extendedContext);
this._pageView.render(ctx);
ctx.headerContext = Object.assign({}, extendedContext);
ctx.pageContext = Object.assign({}, extendedContext);
if (settings.get().endlessScroll) {
this._view = new EndlessPageView(ctx);
} else {
this._view = new ManualPageView(ctx);
}
}
createHistoryCacheProxy(routerCtx, requestPage) {
showSuccess(message) {
this._view.showSuccess(message);
}
showError(message) {
this._view.showError(message);
}
static createHistoryCacheProxy(routerCtx, requestPage) {
return page => {
if (routerCtx.state.response) {
return new Promise((resolve, reject) => {
@ -52,10 +44,6 @@ class PageController {
return promise;
};
}
stop() {
this._pageView.unrender();
}
}
module.exports = new PageController();
module.exports = PageController;

View file

@ -0,0 +1,63 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const topNavigation = require('../models/top_navigation.js');
const PasswordResetView = require('../views/password_reset_view.js');
class PasswordResetController {
constructor() {
topNavigation.activate('login');
this._passwordResetView = new PasswordResetView();
this._passwordResetView.addEventListener(
'submit', e => this._evtReset(e));
}
_evtReset(e) {
this._passwordResetView.clearMessages();
this._passwordResetView.disableForm();
api.forget();
api.logout();
api.get('/password-reset/' + e.detail.userNameOrEmail)
.then(() => {
this._passwordResetView.showSuccess(
'E-mail has been sent. To finish the procedure, ' +
'please click the link it contains.');
}, response => {
this._passwordResetView.showError(response.description);
this._passwordResetView.enableForm();
});
}
}
class PasswordResetFinishController {
constructor(name, token) {
api.forget();
api.logout();
let password = null;
api.post('/password-reset/' + name, {token: token})
.then(response => {
password = response.password;
return api.login(name, password, false);
}, response => {
return Promise.reject(response.description);
}).then(() => {
const ctx = router.show('/');
ctx.controller.showSuccess('New password: ' + password);
}, errorMessage => {
const ctx = router.show('/');
ctx.controller.showError(errorMessage);
});
}
}
module.exports = router => {
router.enter('/password-reset', (ctx, next) => {
ctx.controller = new PasswordResetController();
});
router.enter(/\/password-reset\/([^:]+):([^:]+)$/, (ctx, next) => {
ctx.controller = new PasswordResetFinishController(
ctx.params[0], ctx.params[1]);
});
};

View file

@ -1,38 +1,23 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const events = require('../events.js');
const settings = require('../settings.js');
const settings = require('../models/settings.js');
const Post = require('../models/post.js');
const TopNavigation = require('../models/top_navigation.js');
const topNavigation = require('../models/top_navigation.js');
const PostView = require('../views/post_view.js');
const EmptyView = require('../views/empty_view.js');
class PostController {
constructor() {
this._postView = new PostView();
this._emptyView = new EmptyView();
}
constructor(id, editMode) {
topNavigation.activate('posts');
registerRoutes() {
router.enter(
'/post/:id',
(ctx, next) => { this._showPostRoute(ctx.params.id, false); });
router.enter(
'/post/:id/edit',
(ctx, next) => { this._showPostRoute(ctx.params.id, true); });
}
_showPostRoute(id, editMode) {
TopNavigation.activate('posts');
Promise.all([
Post.get(id),
api.get(`/post/${id}/around?fields=id&query=` +
this._decorateSearchQuery('')),
]).then(responses => {
const [post, aroundResponse] = responses;
this._postView.render({
this._view = new PostView({
post: post,
editMode: editMode,
nextPostId: aroundResponse.next ? aroundResponse.next.id : null,
@ -42,13 +27,13 @@ class PostController {
canCreateComments: api.hasPrivilege('comments:create'),
});
}, response => {
this._emptyView.render();
events.notify(events.Error, response.description);
this._view = new EmptyView();
this._view.showError(response.description);
});
}
_decorateSearchQuery(text) {
const browsingSettings = settings.getSettings();
const browsingSettings = settings.get();
let disabledSafety = [];
for (let key of Object.keys(browsingSettings.listPosts)) {
if (browsingSettings.listPosts[key] === false) {
@ -62,4 +47,11 @@ class PostController {
}
}
module.exports = new PostController();
module.exports = router => {
router.enter('/post/:id', (ctx, next) => {
ctx.controller = new PostController(ctx.params.id, false);
});
router.enter('/post/:id/edit', (ctx, next) => {
ctx.controller = new PostController(ctx.params.id, true);
});
};

View file

@ -1,35 +1,22 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const settings = require('../settings.js');
const settings = require('../models/settings.js');
const misc = require('../util/misc.js');
const pageController = require('../controllers/page_controller.js');
const TopNavigation = require('../models/top_navigation.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
const PostsHeaderView = require('../views/posts_header_view.js');
const PostsPageView = require('../views/posts_page_view.js');
class PostListController {
constructor() {
this._postsHeaderView = new PostsHeaderView();
this._postsPageView = new PostsPageView();
}
constructor(ctx) {
topNavigation.activate('posts');
registerRoutes() {
router.enter(
'/posts/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { this._listPostsRoute(ctx); });
}
_listPostsRoute(ctx) {
TopNavigation.activate('posts');
pageController.run({
this._pageController = new PageController({
searchQuery: ctx.searchQuery,
clientUrl: '/posts/' + misc.formatSearchQuery({
text: ctx.searchQuery.text, page: '{page}'}),
requestPage: pageController.createHistoryCacheProxy(
requestPage: PageController.createHistoryCacheProxy(
ctx,
page => {
const text
@ -39,16 +26,20 @@ class PostListController {
'&fields=id,type,tags,score,favoriteCount,' +
'commentCount,thumbnailUrl');
}),
headerRenderer: this._postsHeaderView,
pageRenderer: this._postsPageView,
pageContext: {
canViewPosts: api.hasPrivilege('posts:view'),
}
headerRenderer: headerCtx => {
return new PostsHeaderView(headerCtx);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'),
});
return new PostsPageView(pageCtx);
},
});
}
_decorateSearchQuery(text) {
const browsingSettings = settings.getSettings();
const browsingSettings = settings.get();
let disabledSafety = [];
for (let key of Object.keys(browsingSettings.listPosts)) {
if (browsingSettings.listPosts[key] === false) {
@ -62,4 +53,9 @@ class PostListController {
}
}
module.exports = new PostListController();
module.exports = router => {
router.enter(
'/posts/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { ctx.controller = new PostListController(ctx); });
};

View file

@ -1,24 +1,17 @@
'use strict';
const router = require('../router.js');
const TopNavigation = require('../models/top_navigation.js');
const topNavigation = require('../models/top_navigation.js');
const EmptyView = require('../views/empty_view.js');
class PostUploadController {
constructor() {
topNavigation.activate('upload');
this._emptyView = new EmptyView();
}
registerRoutes() {
router.enter(
'/upload',
(ctx, next) => { this._uploadPostsRoute(); });
}
_uploadPostsRoute() {
TopNavigation.activate('upload');
this._emptyView.render();
}
}
module.exports = new PostUploadController();
module.exports = router => {
router.enter('/upload', (ctx, next) => {
ctx.controller = new PostUploadController();
});
};

View file

@ -1,26 +1,27 @@
'use strict';
const router = require('../router.js');
const settings = require('../settings.js');
const TopNavigation = require('../models/top_navigation.js');
const settings = require('../models/settings.js');
const topNavigation = require('../models/top_navigation.js');
const SettingsView = require('../views/settings_view.js');
class SettingsController {
constructor() {
this._settingsView = new SettingsView();
}
registerRoutes() {
router.enter('/settings', (ctx, next) => { this._settingsRoute(); });
}
_settingsRoute() {
TopNavigation.activate('settings');
this._settingsView.render({
getSettings: () => settings.getSettings(),
saveSettings: newSettings => settings.saveSettings(newSettings),
topNavigation.activate('settings');
this._view = new SettingsView({
settings: settings.get(),
});
this._view.addEventListener('change', e => this._evtChange(e));
}
_evtChange(e) {
this._view.clearMessages();
settings.save(e.detail.settings);
this._view.showSuccess('Settings saved.');
}
};
module.exports = new SettingsController();
module.exports = router => {
router.enter('/settings', (ctx, next) => {
ctx.controller = new SettingsController();
});
};

View file

@ -0,0 +1,80 @@
'use strict';
const api = require('../api.js');
const tags = require('../tags.js');
const misc = require('../util/misc.js');
const topNavigation = require('../models/top_navigation.js');
const TagCategoriesView = require('../views/tag_categories_view.js');
const EmptyView = require('../views/empty_view.js');
class TagCategoriesController {
constructor() {
topNavigation.activate('tags');
api.get('/tag-categories/').then(response => {
this._view = new TagCategoriesView({
tagCategories: response.results,
canEditName: api.hasPrivilege('tagCategories:edit:name'),
canEditColor: api.hasPrivilege('tagCategories:edit:color'),
canDelete: api.hasPrivilege('tagCategories:delete'),
canCreate: api.hasPrivilege('tagCategories:create'),
canSetDefault: api.hasPrivilege('tagCategories:setDefault'),
saveChanges: (...args) => {
return this._saveTagCategories(...args);
},
getCategories: () => {
return api.get('/tag-categories/').then(response => {
return Promise.resolve(response.results);
}, response => {
return Promise.reject(response);
});
}
});
}, response => {
this._view = new EmptyView();
this._view.showError(response.description);
});
}
_saveTagCategories(
addedCategories,
changedCategories,
removedCategories,
defaultCategory) {
let promises = [];
for (let category of addedCategories) {
promises.push(api.post('/tag-categories/', category));
}
for (let category of changedCategories) {
promises.push(
api.put('/tag-category/' + category.originalName, category));
}
for (let name of removedCategories) {
promises.push(api.delete('/tag-category/' + name));
}
Promise.all(promises)
.then(
() => {
if (!defaultCategory) {
return Promise.resolve();
}
return api.put(
'/tag-category/' + defaultCategory + '/default');
}, response => {
return Promise.reject(response);
})
.then(
() => {
tags.refreshExport();
this._view.showSuccess('Changes saved.');
},
response => {
this._view.showError(response.description);
});
}
}
module.exports = router => {
router.enter('/tag-categories', (ctx, next) => {
ctx.controller = new TagCategoriesController(ctx, next);
});
};

View file

@ -0,0 +1,115 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const tags = require('../tags.js');
const topNavigation = require('../models/top_navigation.js');
const TagView = require('../views/tag_view.js');
const EmptyView = require('../views/empty_view.js');
class TagController {
constructor(ctx, section) {
new Promise((resolve, reject) => {
if (ctx.state.tag) {
resolve(ctx.state.tag);
return;
}
api.get('/tag/' + ctx.params.name).then(response => {
ctx.state.tag = response;
ctx.save();
resolve(ctx.state.tag);
}, response => {
reject(response.description);
});
}).then(tag => {
topNavigation.activate('tags');
const categories = {};
for (let category of tags.getAllCategories()) {
categories[category.name] = category.name;
}
this._view = new TagView({
tag: tag,
section: section,
canEditNames: api.hasPrivilege('tags:edit:names'),
canEditCategory: api.hasPrivilege('tags:edit:category'),
canEditImplications: api.hasPrivilege('tags:edit:implications'),
canEditSuggestions: api.hasPrivilege('tags:edit:suggestions'),
canMerge: api.hasPrivilege('tags:delete'),
canDelete: api.hasPrivilege('tags:merge'),
categories: categories,
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('merge', e => this._evtMerge(e));
this._view.addEventListener('delete', e => this._evtDelete(e));
}, errorMessage => {
this._view = new EmptyView();
this._view.showError(errorMessage);
});
}
_evtChange(e) {
this._view.clearMessages();
this._view.disableForm();
return api.put('/tag/' + e.detail.tag.names[0], {
names: e.detail.names,
category: e.detail.category,
implications: e.detail.implications,
suggestions: e.detail.suggestions,
}).then(response => {
// TODO: update header links and text
if (e.detail.names && e.detail.names[0] !== e.detail.tag.names[0]) {
router.replace('/tag/' + e.detail.names[0], null, false);
}
this._view.showSuccess('Tag saved.');
this._view.enableForm();
}, response => {
this._view.showError(response.description);
this._view.enableForm();
});
}
_evtMerge(e) {
this._view.clearMessages();
this._view.disableForm();
return api.post(
'/tag-merge/',
{remove: e.detail.tag.names[0], mergeTo: e.detail.targetTagName}
).then(response => {
// TODO: update header links and text
router.replace(
'/tag/' + e.detail.targetTagName + '/merge', null, false);
this._view.showSuccess('Tag merged.');
this._view.enableForm();
}, response => {
this._view.showError(response.description);
this._view.enableForm();
});
}
_evtDelete(e) {
this._view.clearMessages();
this._view.disableForm();
return api.delete('/tag/' + e.detail.tag.names[0]).then(response => {
const ctx = router.show('/tags/');
ctx.controller.showSuccess('Tag deleted.');
}, response => {
this._view.showError(response.description);
this._view.enableForm();
});
}
}
module.exports = router => {
router.enter('/tag/:name', (ctx, next) => {
ctx.controller = new TagController(ctx, 'summary');
});
router.enter('/tag/:name/merge', (ctx, next) => {
ctx.controller = new TagController(ctx, 'merge');
});
router.enter('/tag/:name/delete', (ctx, next) => {
ctx.controller = new TagController(ctx, 'delete');
});
};

View file

@ -0,0 +1,54 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
const TagsHeaderView = require('../views/tags_header_view.js');
const TagsPageView = require('../views/tags_page_view.js');
class TagListController {
constructor(ctx) {
topNavigation.activate('tags');
this._pageController = new PageController({
searchQuery: ctx.searchQuery,
clientUrl: '/tags/' + misc.formatSearchQuery({
text: ctx.searchQuery.text, page: '{page}'}),
requestPage: PageController.createHistoryCacheProxy(
ctx,
page => {
const text = ctx.searchQuery.text;
return api.get(
`/tags/?query=${text}&page=${page}&pageSize=50` +
'&fields=names,suggestions,implications,' +
'lastEditTime,usages');
}),
headerRenderer: headerCtx => {
Object.assign(headerCtx, {
canEditTagCategories:
api.hasPrivilege('tagCategories:edit'),
});
return new TagsHeaderView(headerCtx);
},
pageRenderer: pageCtx => {
return new TagsPageView(pageCtx);
},
});
}
showSuccess(message) {
this._pageController.showSuccess(message);
}
showError(message) {
this._pageController.showError(message);
}
}
module.exports = router => {
router.enter(
'/tags/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { ctx.controller = new TagListController(ctx); });
};

View file

@ -1,228 +0,0 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const tags = require('../tags.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
const pageController = require('../controllers/page_controller.js');
const TopNavigation = require('../models/top_navigation.js');
const TagView = require('../views/tag_view.js');
const TagsHeaderView = require('../views/tags_header_view.js');
const TagsPageView = require('../views/tags_page_view.js');
const TagCategoriesView = require('../views/tag_categories_view.js');
const EmptyView = require('../views/empty_view.js');
class TagsController {
constructor() {
this._tagView = new TagView();
this._tagsHeaderView = new TagsHeaderView();
this._tagsPageView = new TagsPageView();
this._tagCategoriesView = new TagCategoriesView();
this._emptyView = new EmptyView();
}
registerRoutes() {
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); });
router.enter(
'/tag/:name/merge',
(ctx, next) => { this._loadTagRoute(ctx, next); },
(ctx, next) => { this._mergeTagRoute(ctx, next); });
router.enter(
'/tag/:name/delete',
(ctx, next) => { this._loadTagRoute(ctx, next); },
(ctx, next) => { this._deleteTagRoute(ctx, next); });
router.enter(
'/tags/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { this._listTagsRoute(ctx, next); });
}
_saveTagCategories(
addedCategories,
changedCategories,
removedCategories,
defaultCategory) {
let promises = [];
for (let category of addedCategories) {
promises.push(api.post('/tag-categories/', category));
}
for (let category of changedCategories) {
promises.push(
api.put('/tag-category/' + category.originalName, category));
}
for (let name of removedCategories) {
promises.push(api.delete('/tag-category/' + name));
}
Promise.all(promises)
.then(
() => {
if (!defaultCategory) {
return Promise.resolve();
}
return api.put(
'/tag-category/' + defaultCategory + '/default');
}, response => {
return Promise.reject(response);
})
.then(
() => {
events.notify(events.TagsChange);
events.notify(events.Success, 'Changes saved.');
},
response => {
events.notify(events.Error, response.description);
});
}
_loadTagRoute(ctx, next) {
if (ctx.state.tag) {
next();
} else if (this._cachedTag &&
this._cachedTag.names == ctx.params.names) {
ctx.state.tag = this._cachedTag;
next();
} else {
api.get('/tag/' + ctx.params.name).then(response => {
ctx.state.tag = response;
ctx.save();
this._cachedTag = response;
next();
}, response => {
this._emptyView.render();
events.notify(events.Error, response.description);
});
}
}
_showTagRoute(ctx, next) {
this._show(ctx.state.tag, 'summary');
}
_mergeTagRoute(ctx, next) {
this._show(ctx.state.tag, 'merge');
}
_deleteTagRoute(ctx, next) {
this._show(ctx.state.tag, 'delete');
}
_show(tag, section) {
TopNavigation.activate('tags');
const categories = {};
for (let category of tags.getAllCategories()) {
categories[category.name] = category.name;
}
this._tagView.render({
tag: tag,
section: section,
canEditNames: api.hasPrivilege('tags:edit:names'),
canEditCategory: api.hasPrivilege('tags:edit:category'),
canEditImplications: api.hasPrivilege('tags:edit:implications'),
canEditSuggestions: api.hasPrivilege('tags:edit:suggestions'),
canMerge: api.hasPrivilege('tags:delete'),
canDelete: api.hasPrivilege('tags:merge'),
categories: categories,
save: (...args) => { return this._saveTag(tag, ...args); },
mergeTo: (...args) => { return this._mergeTag(tag, ...args); },
delete: (...args) => { return this._deleteTag(tag, ...args); },
});
}
_saveTag(tag, input) {
return api.put('/tag/' + tag.names[0], input).then(response => {
if (input.names && input.names[0] !== tag.names[0]) {
router.show('/tag/' + input.names[0]);
}
events.notify(events.Success, 'Tag saved.');
return Promise.resolve();
}, response => {
events.notify(events.Error, response.description);
return Promise.reject();
});
}
_mergeTag(tag, targetTagName) {
return api.post(
'/tag-merge/',
{remove: tag.names[0], mergeTo: targetTagName}
).then(response => {
router.show('/tag/' + targetTagName + '/merge');
events.notify(events.Success, 'Tag merged.');
return Promise.resolve();
}, response => {
events.notify(events.Error, response.description);
return Promise.reject();
});
}
_deleteTag(tag) {
return api.delete('/tag/' + tag.names[0]).then(response => {
router.show('/tags/');
events.notify(events.Success, 'Tag deleted.');
return Promise.resolve();
}, response => {
events.notify(events.Error, response.description);
return Promise.reject();
});
}
_tagCategoriesRoute(ctx, next) {
TopNavigation.activate('tags');
api.get('/tag-categories/').then(response => {
this._tagCategoriesView.render({
tagCategories: response.results,
canEditName: api.hasPrivilege('tagCategories:edit:name'),
canEditColor: api.hasPrivilege('tagCategories:edit:color'),
canDelete: api.hasPrivilege('tagCategories:delete'),
canCreate: api.hasPrivilege('tagCategories:create'),
canSetDefault: api.hasPrivilege('tagCategories:setDefault'),
saveChanges: (...args) => {
return this._saveTagCategories(...args);
},
getCategories: () => {
return api.get('/tag-categories/').then(response => {
return Promise.resolve(response.results);
}, response => {
return Promise.reject(response);
});
}
});
}, response => {
this._emptyView.render();
events.notify(events.Error, response.description);
});
}
_listTagsRoute(ctx, next) {
TopNavigation.activate('tags');
pageController.run({
searchQuery: ctx.searchQuery,
clientUrl: '/tags/' + misc.formatSearchQuery({
text: ctx.searchQuery.text, page: '{page}'}),
requestPage: pageController.createHistoryCacheProxy(
ctx,
page => {
const text = ctx.searchQuery.text;
return api.get(
`/tags/?query=${text}&page=${page}&pageSize=50` +
'&fields=names,suggestions,implications,' +
'lastEditTime,usages');
}),
headerRenderer: this._tagsHeaderView,
pageRenderer: this._tagsPageView,
headerContext: {
canEditTagCategories: api.hasPrivilege('tagCategories:edit'),
},
});
}
}
module.exports = new TagsController();

View file

@ -1,70 +1,68 @@
'use strict';
const api = require('../api.js');
const events = require('../events.js');
const topNavigation = require('../models/top_navigation.js');
const TopNavigationView = require('../views/top_navigation_view.js');
const TopNavigation = require('../models/top_navigation.js');
class TopNavigationController {
constructor() {
this._topNavigationView = new TopNavigationView();
TopNavigation.addEventListener(
topNavigation.addEventListener(
'activate', e => this._evtActivate(e));
events.listen(
events.Authentication,
() => {
this._render();
return true;
});
api.addEventListener('login', e => this._evtAuthChange(e));
api.addEventListener('logout', e => this._evtAuthChange(e));
this._render();
}
_evtAuthChange(e) {
this._render();
}
_evtActivate(e) {
this._topNavigationView.activate(e.key);
}
_updateNavigationFromPrivileges() {
TopNavigation.get('account').url = '/user/' + api.userName;
TopNavigation.get('account').imageUrl =
topNavigation.get('account').url = '/user/' + api.userName;
topNavigation.get('account').imageUrl =
api.user ? api.user.avatarUrl : null;
TopNavigation.showAll();
topNavigation.showAll();
if (!api.hasPrivilege('posts:list')) {
TopNavigation.hide('posts');
topNavigation.hide('posts');
}
if (!api.hasPrivilege('posts:create')) {
TopNavigation.hide('upload');
topNavigation.hide('upload');
}
if (!api.hasPrivilege('comments:list')) {
TopNavigation.hide('comments');
topNavigation.hide('comments');
}
if (!api.hasPrivilege('tags:list')) {
TopNavigation.hide('tags');
topNavigation.hide('tags');
}
if (!api.hasPrivilege('users:list')) {
TopNavigation.hide('users');
topNavigation.hide('users');
}
if (api.isLoggedIn()) {
TopNavigation.hide('register');
TopNavigation.hide('login');
topNavigation.hide('register');
topNavigation.hide('login');
} else {
TopNavigation.hide('account');
TopNavigation.hide('logout');
topNavigation.hide('account');
topNavigation.hide('logout');
}
}
_render() {
this._updateNavigationFromPrivileges();
console.log(TopNavigation.getAll());
this._topNavigationView.render({
items: TopNavigation.getAll(),
items: topNavigation.getAll(),
});
this._topNavigationView.activate(
TopNavigation.activeItem ? TopNavigation.activeItem.key : '');
};
topNavigation.activeItem ? topNavigation.activeItem.key : '');
}
}
module.exports = new TopNavigationController();

View file

@ -0,0 +1,166 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const config = require('../config.js');
const views = require('../util/views.js');
const topNavigation = require('../models/top_navigation.js');
const UserView = require('../views/user_view.js');
const EmptyView = require('../views/empty_view.js');
const rankNames = new Map([
['anonymous', 'Anonymous'],
['restricted', 'Restricted user'],
['regular', 'Regular user'],
['power', 'Power user'],
['moderator', 'Moderator'],
['administrator', 'Administrator'],
['nobody', 'Nobody'],
]);
class UserController {
constructor(ctx, section) {
new Promise((resolve, reject) => {
if (ctx.state.user) {
resolve(ctx.state.user);
return;
}
api.get('/user/' + ctx.params.name).then(response => {
response.rankName = rankNames.get(response.rank);
ctx.state.user = response;
ctx.save();
resolve(ctx.state.user);
}, response => {
reject(response.description);
});
}).then(user => {
const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any';
const myRankIndex = api.user ?
api.allRanks.indexOf(api.user.rank) :
0;
let ranks = {};
for (let [rankIdx, rankIdentifier] of api.allRanks.entries()) {
if (rankIdentifier === 'anonymous') {
continue;
}
if (rankIdx > myRankIndex) {
continue;
}
ranks[rankIdentifier] = rankNames.get(rankIdentifier);
}
if (isLoggedIn) {
topNavigation.activate('account');
} else {
topNavigation.activate('users');
}
this._view = new UserView({
user: user,
section: section,
isLoggedIn: isLoggedIn,
canEditName: api.hasPrivilege(`users:edit:${infix}:name`),
canEditPassword: api.hasPrivilege(`users:edit:${infix}:pass`),
canEditEmail: api.hasPrivilege(`users:edit:${infix}:email`),
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
canDelete: api.hasPrivilege(`users:delete:${infix}`),
ranks: ranks,
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('delete', e => this._evtDelete(e));
}, errorMessage => {
this._view = new EmptyView();
this._view.showError(errorMessage);
});
}
_evtChange(e) {
this._view.clearMessages();
this._view.disableForm();
const isLoggedIn = api.isLoggedIn(e.detail.user);
const infix = isLoggedIn ? 'self' : 'any';
const files = [];
const data = {};
if (e.detail.name) {
data.name = e.detail.name;
}
if (e.detail.password) {
data.password = e.detail.password;
}
if (api.hasPrivilege('users:edit:' + infix + ':email')) {
data.email = e.detail.email;
}
if (e.detail.rank) {
data.rank = e.detail.rank;
}
if (e.detail.avatarStyle &&
(e.detail.avatarStyle != e.detail.user.avatarStyle ||
e.detail.avatarContent)) {
data.avatarStyle = e.detail.avatarStyle;
}
if (e.detail.avatarContent) {
files.avatar = e.detail.avatarContent;
}
api.put('/user/' + e.detail.user.name, data, files)
.then(response => {
return isLoggedIn ?
api.login(
data.name || api.userName,
data.password || api.userPassword,
false) :
Promise.resolve();
}, response => {
return Promise.reject(response.description);
}).then(() => {
if (data.name && data.name !== e.detail.user.name) {
// TODO: update header links and text
router.replace('/user/' + data.name + '/edit', null, false);
}
this._view.showSuccess('Settings updated.');
this._view.enableForm();
}, errorMessage => {
this._view.showError(errorMessage);
this._view.enableForm();
});
}
_evtDelete(e) {
this._view.clearMessages();
this._view.disableForm();
const isLoggedIn = api.isLoggedIn(e.detail.user);
api.delete('/user/' + e.detail.user.name)
.then(response => {
if (isLoggedIn) {
api.forget();
api.logout();
}
if (api.hasPrivilege('users:list')) {
const ctx = router.show('/users');
ctx.controller.showSuccess('Account deleted.');
} else {
const ctx = router.show('/');
ctx.controller.showSuccess('Account deleted.');
}
}, response => {
this._view.showError(response.description);
this._view.enableForm();
});
}
}
module.exports = router => {
router.enter('/user/:name', (ctx, next) => {
ctx.controller = new UserController(ctx, 'summary');
});
router.enter('/user/:name/edit', (ctx, next) => {
ctx.controller = new UserController(ctx, 'edit');
});
router.enter('/user/:name/delete', (ctx, next) => {
ctx.controller = new UserController(ctx, 'delete');
});
};

View file

@ -0,0 +1,47 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
const UsersHeaderView = require('../views/users_header_view.js');
const UsersPageView = require('../views/users_page_view.js');
class UserListController {
constructor(ctx) {
topNavigation.activate('users');
this._pageController = new PageController({
searchQuery: ctx.searchQuery,
clientUrl: '/users/' + misc.formatSearchQuery({
text: ctx.searchQuery.text, page: '{page}'}),
requestPage: PageController.createHistoryCacheProxy(
ctx,
page => {
const text = ctx.searchQuery.text;
return api.get(
`/users/?query=${text}&page=${page}&pageSize=30`);
}),
headerRenderer: headerCtx => {
return new UsersHeaderView(headerCtx);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
canViewUsers: api.hasPrivilege('users:view'),
});
return new UsersPageView(pageCtx);
},
});
}
showSuccess(message) {
this._pageController.showSuccess(message);
}
}
module.exports = router => {
router.enter(
'/users/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
(ctx, next) => { ctx.controller = new UserListController(ctx); });
};

View file

@ -0,0 +1,41 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const topNavigation = require('../models/top_navigation.js');
const RegistrationView = require('../views/registration_view.js');
class UserRegistrationController {
constructor() {
topNavigation.activate('register');
this._view = new RegistrationView();
this._view.addEventListener('submit', e => this._evtRegister(e));
}
_evtRegister(e) {
this._view.clearMessages();
this._view.disableForm();
api.post('/users/', {
name: e.detail.name,
password: e.detail.password,
email: e.detail.email
}).then(() => {
api.forget();
return api.login(e.detail.name, e.detail.password, false);
}, response => {
return Promise.reject(response.description);
}).then(() => {
const ctx = router.show('/');
ctx.controller.showSuccess('Welcome aboard!');
}, errorMessage => {
this._view.showError(errorMessage);
this._view.enableForm();
});
}
}
module.exports = router => {
router.enter('/register', (ctx, next) => {
new UserRegistrationController();
});
};

View file

@ -1,262 +0,0 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const config = require('../config.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const pageController = require('../controllers/page_controller.js');
const TopNavigation = require('../models/top_navigation.js');
const RegistrationView = require('../views/registration_view.js');
const UserView = require('../views/user_view.js');
const UsersHeaderView = require('../views/users_header_view.js');
const UsersPageView = require('../views/users_page_view.js');
const EmptyView = require('../views/empty_view.js');
const rankNames = new Map([
['anonymous', 'Anonymous'],
['restricted', 'Restricted user'],
['regular', 'Regular user'],
['power', 'Power user'],
['moderator', 'Moderator'],
['administrator', 'Administrator'],
['nobody', 'Nobody'],
]);
class UsersController {
constructor() {
this._registrationView = new RegistrationView();
this._userView = new UserView();
this._usersHeaderView = new UsersHeaderView();
this._usersPageView = new UsersPageView();
this._emptyView = new EmptyView();
}
registerRoutes() {
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); });
router.enter(
'/user/:name',
(ctx, next) => { this._loadUserRoute(ctx, next); },
(ctx, next) => { this._showUserRoute(ctx, next); });
router.enter(
'/user/:name/edit',
(ctx, next) => { this._loadUserRoute(ctx, next); },
(ctx, next) => { this._editUserRoute(ctx, next); });
router.enter(
'/user/:name/delete',
(ctx, next) => { this._loadUserRoute(ctx, next); },
(ctx, next) => { this._deleteUserRoute(ctx, next); });
router.exit(
/\/users\/.*/, (ctx, next) => {
pageController.stop();
next();
});
router.exit(/\/user\/.*/, (ctx, next) => {
this._cachedUser = null;
next();
});
}
_listUsersRoute(ctx, next) {
TopNavigation.activate('users');
pageController.run({
searchQuery: ctx.searchQuery,
clientUrl: '/users/' + misc.formatSearchQuery({
text: ctx.searchQuery.text, page: '{page}'}),
requestPage: pageController.createHistoryCacheProxy(
ctx,
page => {
const text = ctx.searchQuery.text;
return api.get(
`/users/?query=${text}&page=${page}&pageSize=30`);
}),
headerRenderer: this._usersHeaderView,
pageRenderer: this._usersPageView,
pageContext: {
canViewUsers: api.hasPrivilege('users:view'),
},
});
}
_createUserRoute(ctx, next) {
TopNavigation.activate('register');
this._registrationView.render({
register: (...args) => {
return this._register(...args);
}});
}
_loadUserRoute(ctx, next) {
if (ctx.state.user) {
next();
} else if (this._cachedUser && this._cachedUser == ctx.params.name) {
ctx.state.user = this._cachedUser;
next();
} else {
api.get('/user/' + ctx.params.name).then(response => {
response.rankName = rankNames.get(response.rank);
ctx.state.user = response;
ctx.save();
this._cachedUser = response;
next();
}, response => {
this._emptyView.render();
events.notify(events.Error, response.description);
});
}
}
_showUserRoute(ctx, next) {
this._show(ctx.state.user, 'summary');
}
_editUserRoute(ctx, next) {
this._show(ctx.state.user, 'edit');
}
_deleteUserRoute(ctx, next) {
this._show(ctx.state.user, 'delete');
}
_register(name, password, email) {
const data = {
name: name,
password: password,
email: email
};
return new Promise((resolve, reject) => {
api.post('/users/', data).then(() => {
api.forget();
return api.login(name, password, false);
}, response => {
return Promise.reject(response.description);
}).then(() => {
resolve();
router.show('/');
events.notify(events.Success, 'Welcome aboard!');
}, errorMessage => {
reject();
events.notify(events.Error, errorMessage);
});
});
}
_edit(user, data) {
const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any';
let files = [];
if (!data.name) {
delete data.name;
}
if (!data.password) {
delete data.password;
}
if (!api.hasPrivilege('users:edit:' + infix + ':email')) {
delete data.email;
}
if (!data.rank) {
delete data.rank;
}
if (!data.avatarStyle ||
(data.avatarStyle == user.avatarStyle && !data.avatarContent)) {
delete data.avatarStyle;
}
if (data.avatarContent) {
files.avatar = data.avatarContent;
}
return new Promise((resolve, reject) => {
api.put('/user/' + user.name, data, files)
.then(response => {
this._cachedUser = response;
return isLoggedIn ?
api.login(
data.name || api.userName,
data.password || api.userPassword,
false) :
Promise.resolve();
}, response => {
return Promise.reject(response.description);
}).then(() => {
resolve();
if (data.name && data.name !== user.name) {
router.show('/user/' + data.name + '/edit');
}
events.notify(events.Success, 'Settings updated.');
}, errorMessage => {
reject();
events.notify(events.Error, errorMessage);
});
});
}
_delete(user) {
const isLoggedIn = api.isLoggedIn(user);
return api.delete('/user/' + user.name)
.then(response => {
if (isLoggedIn) {
api.forget();
api.logout();
}
if (api.hasPrivilege('users:list')) {
router.show('/users');
} else {
router.show('/');
}
events.notify(events.Success, 'Account deleted.');
return Promise.resolve();
}, response => {
events.notify(events.Error, response.description);
return Promise.reject();
});
}
_show(user, section) {
const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any';
const myRankIdx = api.user ? api.allRanks.indexOf(api.user.rank) : 0;
let ranks = {};
for (let [rankIdx, rankIdentifier] of api.allRanks.entries()) {
if (rankIdentifier === 'anonymous') {
continue;
}
if (rankIdx > myRankIdx) {
continue;
}
ranks[rankIdentifier] = rankNames.get(rankIdentifier);
}
if (isLoggedIn) {
TopNavigation.activate('account');
} else {
TopNavigation.activate('users');
}
this._userView.render({
user: user,
section: section,
isLoggedIn: isLoggedIn,
canEditName: api.hasPrivilege('users:edit:' + infix + ':name'),
canEditPassword: api.hasPrivilege('users:edit:' + infix + ':pass'),
canEditEmail: api.hasPrivilege('users:edit:' + infix + ':email'),
canEditRank: api.hasPrivilege('users:edit:' + infix + ':rank'),
canEditAvatar: api.hasPrivilege('users:edit:' + infix + ':avatar'),
canEditAnything: api.hasPrivilege('users:edit:' + infix),
canDelete: api.hasPrivilege('users:delete:' + infix),
ranks: ranks,
edit: (...args) => { return this._edit(user, ...args); },
delete: (...args) => { return this._delete(user, ...args); },
});
}
}
module.exports = new UsersController();

View file

@ -25,7 +25,7 @@ class CommentControl {
canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`),
});
views.showView(
views.replaceContent(
sourceNode.querySelector('.score-container'),
this._scoreTemplate({
score: this._comment.score,
@ -77,7 +77,7 @@ class CommentControl {
canCancel: true
});
views.showView(this._hostNode, sourceNode);
views.replaceContent(this._hostNode, sourceNode);
}
_evtScoreClick(e, scoreGetter) {

View file

@ -47,7 +47,7 @@ class CommentFormControl {
this._growTextArea();
});
views.showView(this._hostNode, sourceNode);
views.replaceContent(this._hostNode, sourceNode);
}
enterEditMode() {

View file

@ -19,7 +19,7 @@ class CommentListControl {
canListComments: api.hasPrivilege('comments:list'),
});
views.showView(this._hostNode, sourceNode);
views.replaceContent(this._hostNode, sourceNode);
this._renderComments();
}
@ -42,7 +42,7 @@ class CommentListControl {
});
commentList.appendChild(commentListItemNode);
}
views.showView(this._hostNode.querySelector('ul'), commentList);
views.replaceContent(this._hostNode.querySelector('ul'), commentList);
}
};

View file

@ -28,7 +28,7 @@ class FileDropperControl {
this._fileInputNode.addEventListener(
'change', e => this._evtFileChange(e));
views.showView(target, source);
views.replaceContent(target, source);
}
_resolve(files) {

View file

@ -1,6 +1,6 @@
'use strict';
const settings = require('../settings.js');
const settings = require('../models/settings.js');
const views = require('../util/views.js');
const optimizedResize = require('../util/optimized_resize.js');
@ -21,7 +21,7 @@ class PostContentControl {
this._currentFitFunction = this.fitWidth;
const mul = this._post.canvasHeight / this._post.canvasWidth;
let width = this._viewportWidth;
if (!settings.getSettings().upscaleSmallPosts) {
if (!settings.get().upscaleSmallPosts) {
width = Math.min(this._post.canvasWidth, width);
}
this._resize(width, width * mul);
@ -31,7 +31,7 @@ class PostContentControl {
this._currentFitFunction = this.fitHeight;
const mul = this._post.canvasWidth / this._post.canvasHeight;
let height = this._viewportHeight;
if (!settings.getSettings().upscaleSmallPosts) {
if (!settings.get().upscaleSmallPosts) {
height = Math.min(this._post.canvasHeight, height);
}
this._resize(height * mul, height);
@ -42,13 +42,13 @@ class PostContentControl {
let mul = this._post.canvasHeight / this._post.canvasWidth;
if (this._viewportWidth * mul < this._viewportHeight) {
let width = this._viewportWidth;
if (!settings.getSettings().upscaleSmallPosts) {
if (!settings.get().upscaleSmallPosts) {
width = Math.min(this._post.canvasWidth, width);
}
this._resize(width, width * mul);
} else {
let height = this._viewportHeight;
if (!settings.getSettings().upscaleSmallPosts) {
if (!settings.get().upscaleSmallPosts) {
height = Math.min(this._post.canvasHeight, height);
}
this._resize(height / mul, height);
@ -83,7 +83,7 @@ class PostContentControl {
const postContentNode = this._template({
post: this._post,
});
if (settings.getSettings().transparencyGrid) {
if (settings.get().transparencyGrid) {
postContentNode.classList.add('transparency-grid');
}
this._containerNode.appendChild(postContentNode);

View file

@ -16,7 +16,7 @@ class PostEditSidebarControl {
const sourceNode = this._template({
post: this._post,
});
views.showView(this._hostNode, sourceNode);
views.replaceContent(this._hostNode, sourceNode);
}
};

View file

@ -25,7 +25,7 @@ class PostReadonlySidebarControl {
canViewTags: api.hasPrivilege('tags:view'),
});
views.showView(
views.replaceContent(
sourceNode.querySelector('.score-container'),
this._scoreTemplate({
score: this._post.score,
@ -33,7 +33,7 @@ class PostReadonlySidebarControl {
canScore: api.hasPrivilege('posts:score'),
}));
views.showView(
views.replaceContent(
sourceNode.querySelector('.fav-container'),
this._favTemplate({
favoriteCount: this._post.favoriteCount,
@ -85,7 +85,7 @@ class PostReadonlySidebarControl {
'click', this._eventZoomProxy(
() => this._postContentControl.fitHeight()));
views.showView(this._hostNode, sourceNode);
views.replaceContent(this._hostNode, sourceNode);
this._syncFitButton();
}

View file

@ -1,41 +1,5 @@
'use strict';
let pendingMessages = new Map();
let listeners = new Map();
function unlisten(messageClass) {
listeners.set(messageClass, []);
}
function listen(messageClass, handler) {
if (pendingMessages.has(messageClass)) {
let newPendingMessages = [];
for (let message of pendingMessages.get(messageClass)) {
if (!handler(message)) {
newPendingMessages.push(message);
}
}
pendingMessages.set(messageClass, newPendingMessages);
}
if (!listeners.has(messageClass)) {
listeners.set(messageClass, []);
}
listeners.get(messageClass).push(handler);
}
function notify(messageClass, message) {
if (!listeners.has(messageClass) || !listeners.get(messageClass).length) {
if (!pendingMessages.has(messageClass)) {
pendingMessages.set(messageClass, []);
}
pendingMessages.get(messageClass).push(message);
return;
}
for (let handler of listeners.get(messageClass)) {
handler(message);
}
}
class EventTarget {
constructor() {
this.eventTarget = document.createDocumentFragment();
@ -53,12 +17,6 @@ module.exports = {
Success: 'success',
Error: 'error',
Info: 'info',
Authentication: 'auth',
SettingsChange: 'settings-change',
TagsChange: 'tags-change',
notify: notify,
listen: listen,
unlisten: unlisten,
EventTarget: EventTarget,
};

View file

@ -2,7 +2,7 @@
require('./util/polyfill.js');
const misc = require('./util/misc.js');
const views = require('./util/views.js');
const router = require('./router.js');
history.scrollRestoration = 'manual';
@ -13,7 +13,6 @@ router.exit(
ctx.state.scrollX = window.scrollX;
ctx.state.scrollY = window.scrollY;
ctx.save();
views.unlistenToMessages();
if (misc.confirmPageExit()) {
next();
}
@ -33,28 +32,33 @@ router.enter(
});
});
// register controller routes
let controllers = [];
controllers.push(require('./controllers/auth_controller.js'));
controllers.push(require('./controllers/post_list_controller.js'));
controllers.push(require('./controllers/post_upload_controller.js'));
controllers.push(require('./controllers/post_controller.js'));
controllers.push(require('./controllers/users_controller.js'));
controllers.push(require('./controllers/home_controller.js'));
controllers.push(require('./controllers/help_controller.js'));
controllers.push(require('./controllers/auth_controller.js'));
controllers.push(require('./controllers/password_reset_controller.js'));
controllers.push(require('./controllers/comments_controller.js'));
controllers.push(require('./controllers/history_controller.js'));
controllers.push(require('./controllers/tags_controller.js'));
controllers.push(require('./controllers/post_controller.js'));
controllers.push(require('./controllers/post_list_controller.js'));
controllers.push(require('./controllers/post_upload_controller.js'));
controllers.push(require('./controllers/tag_controller.js'));
controllers.push(require('./controllers/tag_list_controller.js'));
controllers.push(require('./controllers/tag_categories_controller.js'));
controllers.push(require('./controllers/settings_controller.js'));
controllers.push(require('./controllers/user_controller.js'));
controllers.push(require('./controllers/user_list_controller.js'));
controllers.push(require('./controllers/user_registration_controller.js'));
// home defines 404 routes, need to be registered as last
controllers.push(require('./controllers/home_controller.js'));
// 404 controller needs to be registered last
controllers.push(require('./controllers/not_found_controller.js'));
const tags = require('./tags.js');
const events = require('./events.js');
const views = require('./util/views.js');
for (let controller of controllers) {
controller.registerRoutes();
controller(router);
}
const tags = require('./tags.js');
const api = require('./api.js');
Promise.all([tags.refreshExport(), api.loginFromCookies()])
.then(() => {
@ -64,9 +68,8 @@ Promise.all([tags.refreshExport(), api.loginFromCookies()])
api.forget();
router.start();
} else {
router.start('/');
events.notify(
events.Error,
const ctx = router.start('/');
ctx.controller.showError(
'An error happened while trying to log you in: ' +
errorMessage);
}

View file

@ -0,0 +1,40 @@
'use strict';
const events = require('../events.js');
const defaultSettings = {
listPosts: {
safe: true,
sketchy: true,
unsafe: false,
},
upscaleSmallPosts: false,
endlessScroll: false,
keyboardShortcuts: true,
transparencyGrid: true,
};
class Settings extends events.EventTarget {
save(newSettings, silent) {
localStorage.setItem('settings', JSON.stringify(newSettings));
if (silent !== true) {
this.dispatchEvent(new CustomEvent('change', {
detail: {
settings: this.get(),
},
}));
}
}
get() {
let ret = {};
Object.assign(ret, defaultSettings);
try {
Object.assign(ret, JSON.parse(localStorage.getItem('settings')));
} catch (e) {
}
return ret;
}
};
module.exports = new Settings();

View file

@ -42,15 +42,13 @@ class TopNavigation extends events.EventTarget {
}
activate(key) {
const event = new Event('activate');
event.key = key;
if (key) {
event.item = this.get(key);
} else {
event.item = null;
}
this.activeItem = null;
this.dispatchEvent(event);
this.dispatchEvent(new CustomEvent('activate', {
detail: {
key: key,
item: key ? this.get(key) : null,
},
}));
}
showAll() {

View file

@ -120,7 +120,7 @@ class Router {
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);
return this.replace(url, null, true);
}
stop() {

View file

@ -1,46 +0,0 @@
'use strict';
const events = require('./events.js');
function saveSettings(browsingSettings, silent) {
localStorage.setItem('settings', JSON.stringify(browsingSettings));
if (silent !== true) {
events.notify(events.Success, 'Settings saved');
events.notify(events.SettingsChange);
}
}
function getSettings(settings) {
const defaultSettings = {
listPosts: {
safe: true,
sketchy: true,
unsafe: false,
},
upscaleSmallPosts: false,
endlessScroll: false,
keyboardShortcuts: true,
transparencyGrid: true,
};
let ret = {};
let userSettings = localStorage.getItem('settings');
if (userSettings) {
userSettings = JSON.parse(userSettings);
}
if (!userSettings) {
userSettings = {};
}
for (let key of Object.keys(defaultSettings)) {
if (key in userSettings) {
ret[key] = userSettings[key];
} else {
ret[key] = defaultSettings[key];
}
}
return ret;
}
module.exports = {
getSettings: getSettings,
saveSettings: saveSettings,
};

View file

@ -1,7 +1,6 @@
'use strict';
const request = require('superagent');
const events = require('./events.js');
let _tags = null;
let _categories = null;
@ -88,10 +87,6 @@ function refreshExport() {
});
}
events.listen(
events.TagsChange,
() => { refreshExport(); return true; });
module.exports = {
getAllCategories: getAllCategories,
getAllTags: getAllTags,

View file

@ -1,10 +1,10 @@
'use strict';
const mousetrap = require('mousetrap');
const settings = require('../settings.js');
const settings = require('../models/settings.js');
function bind(hotkey, func) {
if (settings.getSettings().keyboardShortcuts) {
if (settings.get().keyboardShortcuts) {
mousetrap.bind(hotkey, func);
return true;
}

View file

@ -4,7 +4,6 @@ require('../util/polyfill.js');
const api = require('../api.js');
const templates = require('../templates.js');
const tags = require('../tags.js');
const events = require('../events.js');
const domParser = new DOMParser();
const misc = require('./misc.js');
@ -238,26 +237,6 @@ function showInfo(target, message) {
return showMessage(target, message, 'info');
}
function unlistenToMessages() {
events.unlisten(events.Success);
events.unlisten(events.Error);
events.unlisten(events.Info);
}
function listenToMessages(target) {
unlistenToMessages();
const listen = (eventType, className) => {
events.listen(
eventType,
msg => {
return showMessage(target, msg, className);
});
};
listen(events.Success, 'success');
listen(events.Error, 'error');
listen(events.Info, 'info');
}
function clearMessages(target) {
const messagesHolder = target.querySelector('.messages');
/* TODO: animate that */
@ -335,7 +314,7 @@ function enableForm(form) {
}
}
function showView(target, source) {
function replaceContent(target, source) {
while (target.lastChild) {
target.removeChild(target.lastChild);
}
@ -424,11 +403,9 @@ document.addEventListener('input', e => {
module.exports = {
htmlToDom: htmlToDom,
getTemplate: getTemplate,
showView: showView,
replaceContent: replaceContent,
enableForm: enableForm,
disableForm: disableForm,
listenToMessages: listenToMessages,
unlistenToMessages: unlistenToMessages,
clearMessages: clearMessages,
decorateValidator: decorateValidator,
makeVoidElement: makeVoidElement,

View file

@ -3,24 +3,25 @@
const views = require('../util/views.js');
const CommentListControl = require('../controls/comment_list_control.js');
class CommentsPageView {
constructor() {
this._template = views.getTemplate('comments-page');
}
const template = views.getTemplate('comments-page');
render(ctx) {
const target = ctx.target;
const source = this._template(ctx);
class CommentsPageView {
constructor(ctx) {
this._hostNode = ctx.hostNode;
this._controls = [];
const sourceNode = template(ctx);
for (let post of ctx.results) {
post.comments.sort((a, b) => { return b.id - a.id; });
new CommentListControl(
source.querySelector(
`.comments-container[data-for="${post.id}"]`),
post.comments);
this._controls.push(
new CommentListControl(
sourceNode.querySelector(
`.comments-container[data-for="${post.id}"]`),
post.comments));
}
views.showView(target, source);
views.replaceContent(this._hostNode, sourceNode);
}
}

View file

@ -2,19 +2,19 @@
const views = require('../util/views.js');
const template = () => {
return views.htmlToDom(
'<div class="wrapper"><div class="messages"></div></div>');
};
class EmptyView {
constructor() {
this._template = () => {
return views.htmlToDom(
'<div class="wrapper"><div class="messages"></div></div>');
};
this._hostNode = document.getElementById('content-holder');
views.replaceContent(this._hostNode, template());
}
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._template();
views.listenToMessages(source);
views.showView(target, source);
showError(message) {
views.showError(this._hostNode, message);
}
}

View file

@ -1,55 +1,43 @@
'use strict';
const router = require('../router.js');
const events = require('../events.js');
const views = require('../util/views.js');
const holderTemplate = views.getTemplate('endless-pager');
const pageTemplate = views.getTemplate('endless-pager-page');
function _formatUrl(url, page) {
return url.replace('{page}', page);
}
class EndlessPageView {
constructor() {
this._holderTemplate = views.getTemplate('endless-pager');
this._pageTemplate = views.getTemplate('endless-pager-page');
}
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._holderTemplate();
const pageHeaderHolder = source.querySelector('.page-header-holder');
this._pagesHolder = source.querySelector('.pages-holder');
views.listenToMessages(source);
views.showView(target, source);
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
this._active = true;
this._working = 0;
this._init = true;
ctx.headerContext.target = pageHeaderHolder;
if (ctx.headerRenderer) {
ctx.headerRenderer.render(ctx.headerContext);
}
this.threshold = window.innerHeight / 3;
this.minPageShown = null;
this.maxPageShown = null;
this.totalPages = null;
this.currentPage = null;
const sourceNode = holderTemplate();
const pageHeaderHolderNode
= sourceNode.querySelector('.page-header-holder');
this._pagesHolderNode = sourceNode.querySelector('.pages-holder');
views.replaceContent(this._hostNode, sourceNode);
ctx.headerContext.hostNode = pageHeaderHolderNode;
if (ctx.headerRenderer) {
ctx.headerRenderer(ctx.headerContext);
}
this._loadPage(ctx, ctx.searchQuery.page, true);
window.addEventListener('unload', this._scrollToTop, true);
this._probePageLoad(ctx);
}
unrender() {
this._active = false;
window.removeEventListener('unload', this._scrollToTop, true);
}
_scrollToTop() {
window.scroll(0, 0);
}
_probePageLoad(ctx) {
if (this._active) {
window.setTimeout(() => {
@ -115,23 +103,23 @@ class EndlessPageView {
this._working--;
});
}, response => {
events.notify(events.Error, response.description);
this.showError(response.description);
this._working--;
});
}
_renderPage(ctx, pageNumber, append, response) {
if (response.total) {
const pageNode = this._pageTemplate({
const pageNode = pageTemplate({
page: pageNumber,
totalPages: this.totalPages,
});
pageNode.setAttribute('data-page', pageNumber);
Object.assign(ctx.pageContext, response);
ctx.pageContext.target = pageNode.querySelector(
ctx.pageContext.hostNode = pageNode.querySelector(
'.page-content-holder');
ctx.pageRenderer.render(ctx.pageContext);
ctx.pageRenderer(ctx.pageContext);
if (pageNumber < this.minPageShown ||
this.minPageShown === null) {
@ -143,22 +131,34 @@ class EndlessPageView {
}
if (append) {
this._pagesHolder.appendChild(pageNode);
/*if (this._init && pageNumber !== 1) {
this._pagesHolderNode.appendChild(pageNode);
if (this._init && pageNumber !== 1) {
window.scroll(0, pageNode.getBoundingClientRect().top);
}*/
}
} else {
this._pagesHolder.prependChild(pageNode);
this._pagesHolderNode.prependChild(pageNode);
window.scroll(
window.scrollX,
window.scrollY + pageNode.offsetHeight);
}
} else if (response.total <= (pageNumber - 1) * response.pageSize) {
events.notify(events.Info, 'No data to show');
this.showInfo('No data to show');
}
this._init = false;
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
showInfo(message) {
views.showInfo(this._hostNode, message);
}
}
module.exports = EndlessPageView;

View file

@ -3,67 +3,62 @@
const config = require('../config.js');
const views = require('../util/views.js');
const template = views.getTemplate('help');
const sectionTemplates = {
'about': views.getTemplate('help-about'),
'keyboard': views.getTemplate('help-keyboard'),
'search': views.getTemplate('help-search'),
'comments': views.getTemplate('help-comments'),
'tos': views.getTemplate('help-tos'),
};
const subsectionTemplates = {
'search': {
'default': views.getTemplate('help-search-general'),
'posts': views.getTemplate('help-search-posts'),
'users': views.getTemplate('help-search-users'),
'tags': views.getTemplate('help-search-tags'),
},
};
class HelpView {
constructor() {
this._template = views.getTemplate('help');
this._sectionTemplates = {};
const sectionKeys = ['about', 'keyboard', 'search', 'comments', 'tos'];
for (let section of sectionKeys) {
const templateName = 'help-' + section;
this._sectionTemplates[section] = views.getTemplate(templateName);
}
this._subsectionTemplates = {
'search': {
'default': views.getTemplate('help-search-general'),
'posts': views.getTemplate('help-search-posts'),
'users': views.getTemplate('help-search-users'),
'tags': views.getTemplate('help-search-tags'),
}
constructor(section, subsection) {
this._hostNode = document.getElementById('content-holder');
const sourceNode = template();
const ctx = {
name: config.name,
};
}
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._template();
ctx.section = ctx.section || 'about';
if (ctx.section in this._sectionTemplates) {
views.showView(
source.querySelector('.content'),
this._sectionTemplates[ctx.section]({
name: config.name,
}));
section = section || 'about';
if (section in sectionTemplates) {
views.replaceContent(
sourceNode.querySelector('.content'),
sectionTemplates[section](ctx));
}
ctx.subsection = ctx.subsection || 'default';
if (ctx.section in this._subsectionTemplates &&
ctx.subsection in this._subsectionTemplates[ctx.section]) {
views.showView(
source.querySelector('.subcontent'),
this._subsectionTemplates[ctx.section][ctx.subsection]({
name: config.name,
}));
subsection = subsection || 'default';
if (section in subsectionTemplates &&
subsection in subsectionTemplates[section]) {
views.replaceContent(
sourceNode.querySelector('.subcontent'),
subsectionTemplates[section][subsection](ctx));
}
for (let item of source.querySelectorAll('.primary [data-name]')) {
if (item.getAttribute('data-name') === ctx.section) {
item.className = 'active';
} else {
item.className = '';
}
for (let itemNode of
sourceNode.querySelectorAll('.primary [data-name]')) {
itemNode.classList.toggle(
'active',
itemNode.getAttribute('data-name') === section);
}
for (let item of source.querySelectorAll('.secondary [data-name]')) {
if (item.getAttribute('data-name') === ctx.subsection) {
item.className = 'active';
} else {
item.className = '';
}
for (let itemNode of
sourceNode.querySelectorAll('.secondary [data-name]')) {
itemNode.classList.toggle(
'active',
itemNode.getAttribute('data-name') === subsection);
}
views.listenToMessages(source);
views.showView(target, source);
views.replaceContent(this._hostNode, sourceNode);
views.scrollToHash();
}
}

View file

@ -1,7 +1,6 @@
'use strict';
const router = require('../router.js');
const config = require('../config.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const PostContentControl = require('../controls/post_content_control.js');
@ -10,48 +9,45 @@ const PostNotesOverlayControl
const TagAutoCompleteControl =
require('../controls/tag_auto_complete_control.js');
const template = views.getTemplate('home');
const statsTemplate = views.getTemplate('home-stats');
class HomeView {
constructor() {
this._homeTemplate = views.getTemplate('home');
}
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
render(ctx) {
Object.assign(ctx, {
name: config.name,
version: config.meta.version,
buildDate: config.meta.buildDate,
});
const target = document.getElementById('content-holder');
const source = this._homeTemplate(ctx);
const sourceNode = template(ctx);
views.replaceContent(this._hostNode, sourceNode);
views.listenToMessages(source);
views.showView(target, source);
if (this._formNode) {
this._formNode.querySelector('input[name=all-posts')
.addEventListener('click', e => this._evtAllPostsClick(e));
const form = source.querySelector('form');
if (form) {
form.querySelector('input[name=all-posts')
.addEventListener('click', e => {
e.preventDefault();
router.show('/posts/');
});
const searchTextInput = form.querySelector(
'input[name=search-text]');
new TagAutoCompleteControl(searchTextInput);
form.addEventListener('submit', e => {
e.preventDefault();
const text = searchTextInput.value;
searchTextInput.blur();
router.show('/posts/' + misc.formatSearchQuery({text: text}));
});
this._tagAutoCompleteControl = new TagAutoCompleteControl(
this._searchInputNode);
this._formNode.addEventListener(
'submit', e => this._evtFormSubmit(e));
}
const postContainerNode = source.querySelector('.post-container');
}
if (postContainerNode && ctx.featuredPost) {
new PostContentControl(
postContainerNode,
ctx.featuredPost,
showSuccess(text) {
views.showSuccess(this._hostNode, text);
}
showError(text) {
views.showError(this._hostNode, text);
}
setStats(stats) {
views.replaceContent(this._statsContainerNode, statsTemplate(stats));
}
setFeaturedPost(postInfo) {
if (this._postContainerNode && postInfo.featuredPost) {
this._postContentControl = new PostContentControl(
this._postContainerNode,
postInfo.featuredPost,
() => {
return [
window.innerWidth * 0.8,
@ -59,11 +55,39 @@ class HomeView {
];
});
new PostNotesOverlayControl(
postContainerNode.querySelector('.post-overlay'),
ctx.featuredPost);
this._postNotesOverlay = new PostNotesOverlayControl(
this._postContainerNode.querySelector('.post-overlay'),
postInfo.featuredPost);
}
}
get _statsContainerNode() {
return this._hostNode.querySelector('.stats-container');
}
get _postContainerNode() {
return this._hostNode.querySelector('.post-container');
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _searchInputNode() {
return this._formNode.querySelector('input[name=search-text]');
}
_evtAllPostsClick(e) {
e.preventDefault();
router.show('/posts/');
}
_evtFormSubmit(e) {
e.preventDefault();
this._searchInputNode.blur();
router.show('/posts/' + misc.formatSearchQuery({
text: this._searchInputNode.value}));
}
}
module.exports = HomeView;

View file

@ -1,43 +1,67 @@
'use strict';
const config = require('../config.js');
const events = require('../events.js');
const views = require('../util/views.js');
class LoginView {
constructor() {
this._template = views.getTemplate('login');
}
const template = views.getTemplate('login');
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._template({
class LoginView extends events.EventTarget {
constructor() {
super();
this._hostNode = document.getElementById('content-holder');
views.replaceContent(this._hostNode, template({
userNamePattern: config.userNameRegex,
passwordPattern: config.passwordRegex,
canSendMails: config.canSendMails,
});
}));
const form = source.querySelector('form');
const userNameField = source.querySelector('#user-name');
const passwordField = source.querySelector('#user-password');
const rememberUserField = source.querySelector('#remember-user');
views.decorateValidator(form);
userNameField.setAttribute('pattern', config.userNameRegex);
passwordField.setAttribute('pattern', config.passwordRegex);
form.addEventListener('submit', e => {
views.decorateValidator(this._formNode);
this._userNameFieldNode.setAttribute('pattern', config.userNameRegex);
this._passwordFieldNode.setAttribute('pattern', config.passwordRegex);
this._formNode.addEventListener('submit', e => {
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.login(
userNameField.value,
passwordField.value,
rememberUserField.checked)
.always(() => { views.enableForm(form); });
this.dispatchEvent(new CustomEvent('submit', {
detail: {
name: this._userNameFieldNode.value,
password: this._passwordFieldNode.value,
remember: this._rememberFieldNode.checked,
},
}));
});
}
views.listenToMessages(source);
views.showView(target, source);
get _formNode() {
return this._hostNode.querySelector('form');
}
get _userNameFieldNode() {
return this._formNode.querySelector('#user-name');
}
get _passwordFieldNode() {
return this._formNode.querySelector('#user-password');
}
get _rememberFieldNode() {
return this._formNode.querySelector('#remember-user');
}
disableForm() {
views.disableForm(this._formNode);
}
enableForm() {
views.enableForm(this._formNode);
}
clearMessages() {
views.clearMessages(this._hostNode);
}
showError(message) {
views.showError(this._hostNode, message);
}
}

View file

@ -1,11 +1,13 @@
'use strict';
const router = require('../router.js');
const events = require('../events.js');
const keyboard = require('../util/keyboard.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const holderTemplate = views.getTemplate('manual-pager');
const navTemplate = views.getTemplate('manual-pager-nav');
function _formatUrl(url, page) {
return url.replace('{page}', page);
}
@ -56,28 +58,28 @@ function _getPages(currentPage, pageNumbers, clientUrl) {
}
class ManualPageView {
constructor() {
this._holderTemplate = views.getTemplate('manual-pager');
this._navTemplate = views.getTemplate('manual-pager-nav');
}
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._holderTemplate();
const pageContentHolder = source.querySelector('.page-content-holder');
const pageHeaderHolder = source.querySelector('.page-header-holder');
const pageNav = source.querySelector('.page-nav');
const sourceNode = holderTemplate();
const pageContentHolderNode
= sourceNode.querySelector('.page-content-holder');
const pageHeaderHolderNode
= sourceNode.querySelector('.page-header-holder');
const pageNavNode = sourceNode.querySelector('.page-nav');
const currentPage = ctx.searchQuery.page;
ctx.headerContext.target = pageHeaderHolder;
ctx.headerContext.hostNode = pageHeaderHolderNode;
if (ctx.headerRenderer) {
ctx.headerRenderer.render(ctx.headerContext);
ctx.headerRenderer(ctx.headerContext);
}
views.replaceContent(this._hostNode, sourceNode);
ctx.requestPage(currentPage).then(response => {
Object.assign(ctx.pageContext, response);
ctx.pageContext.target = pageContentHolder;
ctx.pageRenderer.render(ctx.pageContext);
ctx.pageContext.hostNode = pageContentHolderNode;
ctx.pageRenderer(ctx.pageContext);
const totalPages = Math.ceil(response.total / response.pageSize);
const pageNumbers = _getVisiblePageNumbers(currentPage, totalPages);
@ -95,28 +97,35 @@ class ManualPageView {
});
if (response.total) {
views.showView(pageNav, this._navTemplate({
prevLink: _formatUrl(ctx.clientUrl, currentPage - 1),
nextLink: _formatUrl(ctx.clientUrl, currentPage + 1),
prevLinkActive: currentPage > 1,
nextLinkActive: currentPage < totalPages,
pages: pages,
}));
views.replaceContent(
pageNavNode,
navTemplate({
prevLink: _formatUrl(ctx.clientUrl, currentPage - 1),
nextLink: _formatUrl(ctx.clientUrl, currentPage + 1),
prevLinkActive: currentPage > 1,
nextLinkActive: currentPage < totalPages,
pages: pages,
}));
}
views.listenToMessages(source);
views.showView(target, source);
if (response.total <= (currentPage - 1) * response.pageSize) {
events.notify(events.Info, 'No data to show');
this.showInfo('No data to show');
}
}, response => {
views.listenToMessages(source);
views.showView(target, source);
events.notify(events.Error, response.description);
this.showError(response.description);
});
}
unrender() {
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
showInfo(message) {
views.showInfo(this._hostNode, message);
}
}

View file

@ -3,15 +3,14 @@
const config = require('../config.js');
const views = require('../util/views.js');
class NotFoundView {
constructor() {
this._template = views.getTemplate('not-found');
}
const template = views.getTemplate('not-found');
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._template(ctx);
views.showView(target, source);
class NotFoundView {
constructor(path) {
this._hostNode = document.getElementById('content-holder');
const sourceNode = template({path: path});
views.replaceContent(this._hostNode, sourceNode);
}
}

View file

@ -1,31 +1,54 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
class PasswordResetView {
const template = views.getTemplate('password-reset');
class PasswordResetView extends events.EventTarget {
constructor() {
this._template = views.getTemplate('password-reset');
super();
this._hostNode = document.getElementById('content-holder');
views.replaceContent(this._hostNode, template());
views.decorateValidator(this._formNode);
this._hostNode.addEventListener('submit', e => {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
userNameOrEmail: this._userNameOrEmailFieldNode.value,
},
}));
});
}
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._template();
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
const form = source.querySelector('form');
const userNameOrEmailField = source.querySelector('#user-name');
showError(message) {
views.showError(this._hostNode, message);
}
views.decorateValidator(form);
clearMessages() {
views.clearMessages(this._hostNode);
}
form.addEventListener('submit', e => {
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.proceed(userNameOrEmailField.value)
.catch(() => { views.enableForm(form); });
});
enableForm() {
views.enableForm(this._formNode);
}
views.listenToMessages(source);
views.showView(target, source);
disableForm() {
views.disableForm(this._formNode);
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _userNameOrEmailFieldNode() {
return this._formNode.querySelector('#user-name');
}
}

View file

@ -14,20 +14,16 @@ const PostEditSidebarControl =
const CommentListControl = require('../controls/comment_list_control.js');
const CommentFormControl = require('../controls/comment_form_control.js');
const template = views.getTemplate('post');
class PostView {
constructor() {
this._template = views.getTemplate('post');
}
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._template(ctx);
const postContainerNode = source.querySelector('.post-container');
const sidebarNode = source.querySelector('.sidebar');
views.listenToMessages(source);
views.showView(target, source);
const sourceNode = template(ctx);
const postContainerNode = sourceNode.querySelector('.post-container');
const sidebarNode = sourceNode.querySelector('.sidebar');
views.replaceContent(this._hostNode, sourceNode);
const postViewNode = document.body.querySelector('.content-wrapper');
const topNavigationNode =

View file

@ -1,33 +1,27 @@
'use strict';
const router = require('../router.js');
const settings = require('../settings.js');
const settings = require('../models/settings.js');
const keyboard = require('../util/keyboard.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const TagAutoCompleteControl =
require('../controls/tag_auto_complete_control.js');
const template = views.getTemplate('posts-header');
class PostsHeaderView {
constructor() {
this._template = views.getTemplate('posts-header');
}
constructor(ctx) {
ctx.settings = settings.get();
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
render(ctx) {
ctx.settings = settings.getSettings();
const target = ctx.target;
const source = this._template(ctx);
const form = source.querySelector('form');
const searchTextInput = form.querySelector('[name=search-text]');
if (searchTextInput) {
new TagAutoCompleteControl(searchTextInput);
if (this._queryInputNode) {
new TagAutoCompleteControl(this._queryInputNode);
}
keyboard.bind('q', () => {
form.querySelector('input').focus();
this._formNode.querySelector('input').focus();
});
keyboard.bind('p', () => {
@ -38,31 +32,37 @@ class PostsHeaderView {
}
});
for (let safetyButton of form.querySelectorAll('.safety')) {
for (let safetyButton of this._formNode.querySelectorAll('.safety')) {
safetyButton.addEventListener(
'click', e => this._evtSafetyButtonClick(e, ctx.clientUrl));
}
form.addEventListener(
'submit', e => this._evtFormSubmit(e, searchTextInput));
this._formNode.addEventListener(
'submit', e => this._evtFormSubmit(e, this._queryInputNode));
}
views.showView(target, source);
get _formNode() {
return this._hostNode.querySelector('form');
}
get _queryInputNode() {
return this._formNode.querySelector('[name=search-text]');
}
_evtSafetyButtonClick(e, url) {
e.preventDefault();
e.target.classList.toggle('disabled');
const safety = e.target.getAttribute('data-safety');
let browsingSettings = settings.getSettings();
let browsingSettings = settings.get();
browsingSettings.listPosts[safety] =
!browsingSettings.listPosts[safety];
settings.saveSettings(browsingSettings, true);
settings.save(browsingSettings, true);
router.show(url.replace(/{page}/, 1));
}
_evtFormSubmit(e, searchTextInput) {
_evtFormSubmit(e, queryInputNode) {
e.preventDefault();
const text = searchTextInput.value;
searchTextInput.blur();
const text = queryInputNode.value;
queryInputNode.blur();
router.show('/posts/' + misc.formatSearchQuery({text: text}));
}
}

View file

@ -2,15 +2,11 @@
const views = require('../util/views.js');
class PostsPageView {
constructor() {
this._template = views.getTemplate('posts-page');
}
const template = views.getTemplate('posts-page');
render(ctx) {
const target = ctx.target;
const source = this._template(ctx);
views.showView(target, source);
class PostsPageView {
constructor(ctx) {
views.replaceContent(ctx.hostNode, template(ctx));
}
}

View file

@ -1,40 +1,64 @@
'use strict';
const config = require('../config.js');
const events = require('../events.js');
const views = require('../util/views.js');
class RegistrationView {
const template = views.getTemplate('user-registration');
class RegistrationView extends events.EventTarget {
constructor() {
this._template = views.getTemplate('user-registration');
super();
this._hostNode = document.getElementById('content-holder');
views.replaceContent(this._hostNode, template({
userNamePattern: config.userNameRegex,
passwordPattern: config.passwordRegex,
}));
views.decorateValidator(this._formNode);
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
render(ctx) {
ctx.userNamePattern = config.userNameRegex;
ctx.passwordPattern = config.passwordRegex;
clearMessages() {
views.clearMessages(this._hostNode);
}
const target = document.getElementById('content-holder');
const source = this._template(ctx);
showError(message) {
views.showError(this._hostNode, message);
}
const form = source.querySelector('form');
const userNameField = source.querySelector('#user-name');
const passwordField = source.querySelector('#user-password');
const emailField = source.querySelector('#user-email');
enableForm() {
views.enableForm(this._formNode);
}
views.decorateValidator(form);
disableForm() {
views.disableForm(this._formNode);
}
form.addEventListener('submit', e => {
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.register(
userNameField.value,
passwordField.value,
emailField.value)
.always(() => { views.enableForm(form); });
});
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
name: this._userNameFieldNode.value,
password: this._passwordFieldNode.value,
email: this._emailFieldNode.value,
},
}));
}
views.listenToMessages(source);
views.showView(target, source);
get _formNode() {
return this._hostNode.querySelector('form');
}
get _userNameFieldNode() {
return this._formNode.querySelector('#user-name');
}
get _passwordFieldNode() {
return this._formNode.querySelector('#user-password');
}
get _emailFieldNode() {
return this._formNode.querySelector('#user-email');
}
}

View file

@ -1,36 +1,50 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
class SettingsView {
constructor() {
this._template = views.getTemplate('settings');
const template = views.getTemplate('settings');
class SettingsView extends events.EventTarget {
constructor(ctx) {
super();
this._hostNode = document.getElementById('content-holder');
views.replaceContent(
this._hostNode, template({browsingSettings: ctx.settings}));
views.decorateValidator(this._formNode);
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._template({browsingSettings: ctx.getSettings()});
clearMessages() {
views.clearMessages(this._hostNode);
}
const form = source.querySelector('form');
views.decorateValidator(form);
showSuccess(text) {
views.showSuccess(this._hostNode, text);
}
form.addEventListener('submit', e => {
e.preventDefault();
views.clearMessages(source);
ctx.saveSettings({
upscaleSmallPosts:
form.querySelector('#upscale-small-posts').checked,
endlessScroll:
form.querySelector('#endless-scroll').checked,
keyboardShortcuts:
form.querySelector('#keyboard-shortcuts').checked,
transparencyGrid:
form.querySelector('#transparency-grid').checked,
});
});
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('change', {
detail: {
settings: {
upscaleSmallPosts: this._formNode.querySelector(
'#upscale-small-posts').checked,
endlessScroll: this._formNode.querySelector(
'#endless-scroll').checked,
keyboardShortcuts: this._formNode.querySelector(
'#keyboard-shortcuts').checked,
transparencyGrid: this._formNode.querySelector(
'#transparency-grid').checked,
},
},
}));
}
views.listenToMessages(source);
views.showView(target, source);
get _formNode() {
return this._hostNode.querySelector('form');
}
}

View file

@ -1,32 +1,28 @@
'use strict';
const misc = require('../util/misc.js');
const views = require('../util/views.js');
class TagListHeaderView {
constructor() {
this._template = views.getTemplate('tag-categories');
}
const template = views.getTemplate('tag-categories');
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._template(ctx);
class TagCategoriesView {
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
const sourceNode = template(ctx);
const form = source.querySelector('form');
const newRowTemplate = source.querySelector('.add-template');
const tableBody = source.querySelector('tbody');
const addLink = source.querySelector('a.add');
const saveButton = source.querySelector('button.save');
const formNode = sourceNode.querySelector('form');
const newRowTemplate = sourceNode.querySelector('.add-template');
const tableBodyNode = sourceNode.querySelector('tbody');
const addLinkNode = sourceNode.querySelector('a.add');
newRowTemplate.parentNode.removeChild(newRowTemplate);
views.decorateValidator(form);
views.decorateValidator(formNode);
for (let row of tableBody.querySelectorAll('tr')) {
for (let row of tableBodyNode.querySelectorAll('tr')) {
this._addRowHandlers(row);
}
if (addLink) {
addLink.addEventListener('click', e => {
if (addLinkNode) {
addLinkNode.addEventListener('click', e => {
e.preventDefault();
let newRow = newRowTemplate.cloneNode(true);
tableBody.appendChild(newRow);
@ -34,19 +30,26 @@ class TagListHeaderView {
});
}
form.addEventListener('submit', e => {
this._evtSaveButtonClick(e, ctx, target);
formNode.addEventListener('submit', e => {
this._evtSaveButtonClick(e, ctx);
});
views.listenToMessages(source);
views.showView(target, source);
views.replaceContent(this._hostNode, sourceNode);
}
_evtSaveButtonClick(e, ctx, target) {
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtSaveButtonClick(e, ctx) {
e.preventDefault();
views.clearMessages(target);
const tableBody = target.querySelector('tbody');
views.clearMessages(this._hostNode);
const tableBodyNode = this._hostNode.querySelector('tbody');
ctx.getCategories().then(categories => {
let existingCategories = {};
@ -59,7 +62,7 @@ class TagListHeaderView {
let removedCategories = [];
let changedCategories = [];
let allNames = [];
for (let row of tableBody.querySelectorAll('tr')) {
for (let row of tableBodyNode.querySelectorAll('tr')) {
let name = row.getAttribute('data-category');
let category = {
originalName: name,
@ -127,4 +130,4 @@ class TagListHeaderView {
}
}
module.exports = TagListHeaderView;
module.exports = TagCategoriesView;

View file

@ -1,30 +1,52 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
class TagDeleteView {
constructor() {
this._template = views.getTemplate('tag-delete');
const template = views.getTemplate('tag-delete');
class TagDeleteView extends events.EventTarget {
constructor(ctx) {
super();
this._hostNode = ctx.hostNode;
this._tag = ctx.tag;
views.replaceContent(this._hostNode, template(ctx));
views.decorateValidator(this._formNode);
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
render(ctx) {
const target = ctx.target;
const source = this._template(ctx);
clearMessages() {
views.clearMessages(this._hostNode);
}
const form = source.querySelector('form');
enableForm() {
views.enableForm(this._formNode);
}
views.decorateValidator(form);
disableForm() {
views.disableForm(this._formNode);
}
form.addEventListener('submit', e => {
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.delete(ctx.tag)
.catch(() => { views.enableForm(form); });
});
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
views.listenToMessages(source);
views.showView(target, source);
showError(message) {
views.showError(this._hostNode, message);
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
tag: this._tag,
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
}

View file

@ -1,40 +1,66 @@
'use strict';
const config = require('../config.js');
const events = require('../events.js');
const views = require('../util/views.js');
const TagAutoCompleteControl =
require('../controls/tag_auto_complete_control.js');
class TagMergeView {
constructor() {
this._template = views.getTemplate('tag-merge');
}
const template = views.getTemplate('tag-merge');
render(ctx) {
class TagMergeView extends events.EventTarget {
constructor(ctx) {
super();
this._tag = ctx.tag;
this._hostNode = ctx.hostNode;
ctx.tagNamePattern = config.tagNameRegex;
views.replaceContent(this._hostNode, template(ctx));
const target = ctx.target;
const source = this._template(ctx);
const form = source.querySelector('form');
const otherTagField = source.querySelector('.target input');
views.decorateValidator(form);
if (otherTagField) {
new TagAutoCompleteControl(otherTagField);
views.decorateValidator(this._formNode);
if (this._targetTagFieldNode) {
new TagAutoCompleteControl(this._targetTagFieldNode);
}
form.addEventListener('submit', e => {
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.mergeTo(otherTagField.value)
.catch(() => { views.enableForm(form); });
});
clearMessages() {
views.clearMessages(this._hostNode);
}
views.listenToMessages(source);
views.showView(target, source);
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
tag: this._tag,
targetTagName: this._targetTagFieldNode.value,
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _targetTagFieldNode() {
return this._formNode.querySelector('.target input');
}
}

View file

@ -1,54 +1,89 @@
'use strict';
const config = require('../config.js');
const events = require('../events.js');
const views = require('../util/views.js');
const TagInputControl = require('../controls/tag_input_control.js');
function split(str) {
const template = views.getTemplate('tag-summary');
function _split(str) {
return str.split(/\s+/).filter(s => s);
}
class TagSummaryView {
constructor() {
this._template = views.getTemplate('tag-summary');
}
class TagSummaryView extends events.EventTarget {
constructor(ctx) {
super();
render(ctx) {
this._tag = ctx.tag;
this._hostNode = ctx.hostNode;
const baseRegex = config.tagNameRegex.replace(/[\^\$]/g, '');
ctx.tagNamesPattern = '^((' + baseRegex + ')\\s+)*(' + baseRegex + ')$';
views.replaceContent(this._hostNode, template(ctx));
const target = ctx.target;
const source = this._template(ctx);
views.decorateValidator(this._formNode);
const form = source.querySelector('form');
const namesField = source.querySelector('.names input');
const categoryField = source.querySelector('.category select');
const implicationsField = source.querySelector('.implications input');
const suggestionsField = source.querySelector('.suggestions input');
if (implicationsField) {
new TagInputControl(implicationsField);
if (this._implicationsFieldNode) {
new TagInputControl(this._implicationsFieldNode);
}
if (suggestionsField) {
new TagInputControl(suggestionsField);
if (this._suggestionsFieldNode) {
new TagInputControl(this._suggestionsFieldNode);
}
views.decorateValidator(form);
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
form.addEventListener('submit', e => {
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.save({
names: split(namesField.value),
category: categoryField.value,
implications: split(implicationsField.value),
suggestions: split(suggestionsField.value),
}).always(() => { views.enableForm(form); });
});
clearMessages() {
views.clearMessages(this._hostNode);
}
views.listenToMessages(source);
views.showView(target, source);
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
tag: this._tag,
names: _split(this._namesFieldNode.value),
category: this._categoryFieldNode.value,
implications: _split(this._implicationsFieldNode.value),
suggestions: _split(this._suggestionsFieldNode.value),
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _namesFieldNode() {
return this._formNode.querySelector('.names input');
}
get _categoryFieldNode() {
return this._formNode.querySelector('.category select');
}
get _implicationsFieldNode() {
return this._formNode.querySelector('.implications input');
}
get _suggestionsFieldNode() {
return this._formNode.querySelector('.suggestions input');
}
}

View file

@ -1,25 +1,22 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const TagSummaryView = require('./tag_summary_view.js');
const TagMergeView = require('./tag_merge_view.js');
const TagDeleteView = require('./tag_delete_view.js');
class TagView {
constructor() {
this._template = views.getTemplate('tag');
this._summaryView = new TagSummaryView();
this._mergeView = new TagMergeView();
this._deleteView = new TagDeleteView();
}
const template = views.getTemplate('tag');
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._template(ctx);
class TagView extends events.EventTarget {
constructor(ctx) {
super();
this._hostNode = document.getElementById('content-holder');
ctx.section = ctx.section || 'summary';
views.replaceContent(this._hostNode, template(ctx));
for (let item of source.querySelectorAll('[data-name]')) {
for (let item of this._hostNode.querySelectorAll('[data-name]')) {
if (item.getAttribute('data-name') === ctx.section) {
item.className = 'active';
} else {
@ -27,21 +24,47 @@ class TagView {
}
}
let view = null;
ctx.hostNode = this._hostNode.querySelector('.tag-content-holder');
if (ctx.section == 'merge') {
view = this._mergeView;
this._view = new TagMergeView(ctx);
this._view.addEventListener('submit', e => {
this.dispatchEvent(
new CustomEvent('merge', {detail: e.detail}));
});
} else if (ctx.section == 'delete') {
view = this._deleteView;
this._view = new TagDeleteView(ctx);
this._view.addEventListener('submit', e => {
this.dispatchEvent(
new CustomEvent('delete', {detail: e.detail}));
});
} else {
view = this._summaryView;
this._view = new TagSummaryView(ctx);
this._view.addEventListener('submit', e => {
this.dispatchEvent(
new CustomEvent('change', {detail: e.detail}));
});
}
ctx.target = source.querySelector('.tag-content-holder');
view.render(ctx);
}
views.listenToMessages(source);
views.showView(target, source);
clearMessages() {
this._view.clearMessages();
}
enableForm() {
this._view.enableForm();
}
disableForm() {
this._view.disableForm();
}
showSuccess(message) {
this._view.showSuccess(message);
}
showError(message) {
this._view.showError(message);
}
}
module.exports = TagView;

View file

@ -7,34 +7,39 @@ const views = require('../util/views.js');
const TagAutoCompleteControl =
require('../controls/tag_auto_complete_control.js');
const template = views.getTemplate('tags-header');
class TagsHeaderView {
constructor() {
this._template = views.getTemplate('tags-header');
}
constructor(ctx) {
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
render(ctx) {
const target = ctx.target;
const source = this._template(ctx);
const form = source.querySelector('form');
const searchTextInput = form.querySelector('[name=search-text]');
if (searchTextInput) {
new TagAutoCompleteControl(searchTextInput);
if (this._queryInputNode) {
new TagAutoCompleteControl(this._queryInputNode);
}
keyboard.bind('q', () => {
form.querySelector('input').focus();
});
form.addEventListener('submit', e => {
e.preventDefault();
const text = searchTextInput.value;
searchTextInput.blur();
router.show('/tags/' + misc.formatSearchQuery({text: text}));
});
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
views.showView(target, source);
get _formNode() {
return this._hostNode.querySelector('form');
}
get _queryInputNode() {
return this._hostNode.querySelector('[name=search-text]');
}
_evtSubmit(e) {
e.preventDefault();
this._queryInputNode.blur();
router.show(
'/tags/' + misc.formatSearchQuery({
text: this._queryInputNode.value,
}));
}
}

View file

@ -2,15 +2,11 @@
const views = require('../util/views.js');
class TagsPageView {
constructor() {
this._template = views.getTemplate('tags-page');
}
const template = views.getTemplate('tags-page');
render(ctx) {
const target = ctx.target;
const source = this._template(ctx);
views.showView(target, source);
class TagsPageView {
constructor(ctx) {
views.replaceContent(ctx.hostNode, template(ctx));
}
}

View file

@ -2,24 +2,19 @@
const views = require('../util/views.js');
const template = views.getTemplate('top-navigation');
class TopNavigationView {
constructor() {
this._template = views.getTemplate('top-navigation');
this._navHolder = document.getElementById('top-navigation-holder');
this._lastCtx = null;
this._hostNode = document.getElementById('top-navigation-holder');
}
render(ctx) {
this._lastCtx = ctx;
const target = this._navHolder;
const source = this._template(ctx);
views.showView(this._navHolder, source);
views.replaceContent(this._hostNode, template(ctx));
}
activate(key) {
const allItemNodes = document.querySelectorAll(
'#top-navigation-holder [data-name]');
for (let itemNode of allItemNodes) {
for (let itemNode of this._hostNode.querySelectorAll('[data-name]')) {
itemNode.classList.toggle(
'active', itemNode.getAttribute('data-name') === key);
}

View file

@ -1,30 +1,54 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
class UserDeleteView {
constructor() {
this._template = views.getTemplate('user-delete');
const template = views.getTemplate('user-delete');
class UserDeleteView extends events.EventTarget {
constructor(ctx) {
super();
this._user = ctx.user;
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
views.decorateValidator(this._formNode);
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
render(ctx) {
const target = ctx.target;
const source = this._template(ctx);
clearMessages() {
views.clearMessages(this._hostNode);
}
const form = source.querySelector('form');
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
views.decorateValidator(form);
showError(message) {
views.showError(this._hostNode, message);
}
form.addEventListener('submit', e => {
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.delete()
.catch(() => { views.enableForm(form); });
});
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
user: this._user,
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
views.listenToMessages(source);
views.showView(target, source);
}
}

View file

@ -1,63 +1,102 @@
'use strict';
const config = require('../config.js');
const events = require('../events.js');
const views = require('../util/views.js');
const FileDropperControl = require('../controls/file_dropper_control.js');
class UserEditView {
constructor() {
this._template = views.getTemplate('user-edit');
}
const template = views.getTemplate('user-edit');
class UserEditView extends events.EventTarget {
constructor(ctx) {
super();
render(ctx) {
ctx.userNamePattern = config.userNameRegex + /|^$/.source;
ctx.passwordPattern = config.passwordRegex + /|^$/.source;
const target = ctx.target;
const source = this._template(ctx);
this._user = ctx.user;
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
views.decorateValidator(this._formNode);
const form = source.querySelector('form');
const avatarContentField = source.querySelector('#avatar-content');
views.decorateValidator(form);
let avatarContent = null;
if (avatarContentField) {
this._avatarContent = null;
if (this._avatarContentFieldNode) {
new FileDropperControl(
avatarContentField,
this._avatarContentFieldNode,
{
lock: true,
resolve: files => {
source.querySelector(
this._hostNode.querySelector(
'[name=avatar-style][value=manual]').checked = true;
avatarContent = files[0];
this._avatarContent = files[0];
},
});
}
form.addEventListener('submit', e => {
const rankField = source.querySelector('#user-rank');
const emailField = source.querySelector('#user-email');
const userNameField = source.querySelector('#user-name');
const passwordField = source.querySelector('#user-password');
const avatarStyleField = source.querySelector(
'[name=avatar-style]:checked');
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.edit({
name: userNameField.value,
password: passwordField.value,
email: emailField.value,
rank: rankField.value,
avatarStyle: avatarStyleField.value,
avatarContent: avatarContent})
.always(() => { views.enableForm(form); });
});
clearMessages() {
views.clearMessages(this._hostNode);
}
views.listenToMessages(source);
views.showView(target, source);
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
user: this._user,
name: this._userNameFieldNode.value,
password: this._passwordFieldNode.value,
email: this._emailFieldNode.value,
rank: this._rankFieldNode.value,
avatarStyle: this._avatarStyleFieldNode.value,
avatarContent: this._avatarContent,
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _rankFieldNode() {
return this._formNode.querySelector('#user-rank');
}
get _emailFieldNode() {
return this._formNode.querySelector('#user-email');
}
get _userNameFieldNode() {
return this._formNode.querySelector('#user-name');
}
get _passwordFieldNode() {
return this._formNode.querySelector('#user-password');
}
get _avatarContentFieldNode() {
return this._formNode.querySelector('#avatar-content');
}
get _avatarStyleFieldNode() {
return this._formNode.querySelector('[name=avatar-style]:checked');
}
}

View file

@ -2,16 +2,12 @@
const views = require('../util/views.js');
class UserSummaryView {
constructor() {
this._template = views.getTemplate('user-summary');
}
const template = views.getTemplate('user-summary');
render(ctx) {
const target = ctx.target;
const source = this._template(ctx);
views.listenToMessages(source);
views.showView(target, source);
class UserSummaryView {
constructor(ctx) {
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
}
}

View file

@ -1,25 +1,22 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const UserDeleteView = require('./user_delete_view.js');
const UserSummaryView = require('./user_summary_view.js');
const UserEditView = require('./user_edit_view.js');
class UserView {
constructor() {
this._template = views.getTemplate('user');
this._deleteView = new UserDeleteView();
this._summaryView = new UserSummaryView();
this._editView = new UserEditView();
}
const template = views.getTemplate('user');
render(ctx) {
const target = document.getElementById('content-holder');
const source = this._template(ctx);
class UserView extends events.EventTarget {
constructor(ctx) {
super();
this._hostNode = document.getElementById('content-holder');
ctx.section = ctx.section || 'summary';
views.replaceContent(this._hostNode, template(ctx));
for (let item of source.querySelectorAll('[data-name]')) {
for (let item of this._hostNode.querySelectorAll('[data-name]')) {
if (item.getAttribute('data-name') === ctx.section) {
item.className = 'active';
} else {
@ -27,19 +24,42 @@ class UserView {
}
}
let view = null;
ctx.hostNode = this._hostNode.querySelector('#user-content-holder');
if (ctx.section == 'edit') {
view = this._editView;
this._view = new UserEditView(ctx);
this._view.addEventListener('submit', e => {
this.dispatchEvent(
new CustomEvent('change', {detail: e.detail}));
});
} else if (ctx.section == 'delete') {
view = this._deleteView;
this._view = new UserDeleteView(ctx);
this._view.addEventListener('submit', e => {
this.dispatchEvent(
new CustomEvent('delete', {detail: e.detail}));
});
} else {
view = this._summaryView;
this._view = new UserSummaryView(ctx);
}
ctx.target = source.querySelector('#user-content-holder');
view.render(ctx);
}
views.listenToMessages(source);
views.showView(target, source);
clearMessages() {
this._view.clearMessages();
}
showSuccess(message) {
this._view.showSuccess(message);
}
showError(message) {
this._view.showError(message);
}
enableForm() {
this._view.enableForm();
}
disableForm() {
this._view.disableForm();
}
}

View file

@ -5,30 +5,35 @@ const keyboard = require('../util/keyboard.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const template = views.getTemplate('users-header');
class UsersHeaderView {
constructor() {
this._template = views.getTemplate('users-header');
}
render(ctx) {
const target = ctx.target;
const source = this._template(ctx);
const form = source.querySelector('form');
constructor(ctx) {
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
keyboard.bind('q', () => {
form.querySelector('input').focus();
this._formNode.querySelector('input').focus();
});
form.addEventListener('submit', e => {
e.preventDefault();
const searchTextInput = form.querySelector('[name=search-text]');
const text = searchTextInput.value;
searchTextInput.blur();
router.show('/users/' + misc.formatSearchQuery({text: text}));
});
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
views.showView(target, source);
get _formNode() {
return this._hostNode.querySelector('form');
}
get _queryInputNode() {
return this._formNode.querySelector('[name=search-text]');
}
_evtSubmit(e) {
e.preventDefault();
this._queryInputNode.blur();
router.show(
'/users/' + misc.formatSearchQuery({
text: this._queryInputNode.value,
}));
}
}

View file

@ -2,15 +2,11 @@
const views = require('../util/views.js');
class UsersPageView {
constructor() {
this._template = views.getTemplate('users-page');
}
const template = views.getTemplate('users-page');
render(ctx) {
const target = ctx.target;
const source = this._template(ctx);
views.showView(target, source);
class UsersPageView {
constructor(ctx) {
views.replaceContent(ctx.hostNode, template(ctx));
}
}