Replace Ember app with Mithril app

This commit is contained in:
Toby Zerner
2015-04-25 22:28:39 +09:30
parent 6f67b8c247
commit b68a4711dc
377 changed files with 5641 additions and 7330 deletions

4
js/admin/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
bower_components
node_modules
mithril.js
dist

51
js/admin/Gulpfile.js Normal file
View File

@ -0,0 +1,51 @@
var gulp = require('gulp');
var livereload = require('gulp-livereload');
var concat = require('gulp-concat');
var argv = require('yargs').argv;
var uglify = require('gulp-uglify');
var gulpif = require('gulp-if');
var merge = require('merge-stream');
var babel = require('gulp-babel');
var cached = require('gulp-cached');
var remember = require('gulp-remember');
var vendorFiles = [
'./bower_components/loader.js/loader.js',
'./bower_components/mithril/mithril.js',
'./bower_components/jquery/dist/jquery.js',
'./bower_components/moment/moment.js',
'./bower_components/bootstrap/dist/js/bootstrap.js',
'./bower_components/spin.js/spin.js',
'./bower_components/spin.js/jquery.spin.js'
];
var moduleFiles = [
'src/**/*.js',
'../lib/**/*.js'
];
var modulePrefix = 'flarum';
gulp.task('default', function() {
return merge(
gulp.src(vendorFiles),
gulp.src(moduleFiles)
.pipe(cached('scripts'))
.pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix }))
.pipe(remember('scripts'))
)
.pipe(concat('app.js'))
.pipe(gulpif(argv.production, uglify()))
.pipe(gulp.dest('dist'))
.pipe(livereload());
});
gulp.task('watch', ['default'], function () {
livereload.listen();
var watcher = gulp.watch(moduleFiles.concat(vendorFiles), ['default']);
watcher.on('change', function (event) {
if (event.type === 'deleted') {
delete cached.caches.scripts[event.path];
remember.forget('scripts', event.path);
}
});
});

13
js/admin/bower.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "flarum-forum",
"dependencies": {
"jquery": "2.1.3",
"jquery.hotkeys": "jeresig/jquery.hotkeys#0.2.0",
"bootstrap": "~3.3.2",
"spin.js": "~2.0.1",
"moment": "~2.8.4",
"color-thief": "v2.0",
"mithril": "lhorie/mithril.js#components",
"loader.js": "~3.2.1"
}
}

15
js/admin/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "flarum-forum",
"devDependencies": {
"gulp": "^3.8.11",
"gulp-babel": "^5.1.0",
"gulp-cached": "^1.0.4",
"gulp-concat": "^2.5.2",
"gulp-if": "^1.2.5",
"gulp-livereload": "^3.8.0",
"gulp-remember": "^0.3.0",
"gulp-uglify": "^1.2.0",
"merge-stream": "^0.1.7",
"yargs": "^3.7.2"
}
}

18
js/admin/src/app.js Normal file
View File

@ -0,0 +1,18 @@
import App from 'flarum/utils/app';
import store from 'flarum/initializers/store';
import preload from 'flarum/initializers/preload';
import session from 'flarum/initializers/session';
import routes from 'flarum/initializers/routes';
import timestamps from 'flarum/initializers/timestamps';
import boot from 'flarum/initializers/boot';
var app = new App();
app.initializers.add('store', store);
app.initializers.add('preload', preload);
app.initializers.add('session', session);
app.initializers.add('routes', routes);
app.initializers.add('timestamps', timestamps);
app.initializers.add('boot', boot, {last: true});
export default app;

View File

@ -0,0 +1,14 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
import NavItem from 'flarum/components/nav-item';
export default class AdminNavItem extends NavItem {
view() {
var active = this.constructor.active(this.props);
return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route}, [
icon(this.props.icon+' icon'),
m('span.label', this.props.label),
m('div.description', this.props.description)
]))
}
}

View File

@ -0,0 +1,54 @@
import Component from 'flarum/component';
import UserDropdown from 'flarum/components/user-dropdown';
import AdminNavItem from 'flarum/components/admin-nav-item';
import DropdownSelect from 'flarum/components/dropdown-select';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
export default class AdminNav extends Component {
view() {
return DropdownSelect.component({ items: this.items().toArray() });
}
items() {
var items = new ItemList();
items.add('dashboard', AdminNavItem.component({
href: app.route('dashboard'),
icon: 'bar-chart',
label: 'Dashboard',
description: 'Your forum at a glance.'
}));
items.add('basics', AdminNavItem.component({
href: app.route('basics'),
icon: 'pencil',
label: 'Basics',
description: 'Set your forum title, language, and other basic settings.'
}));
items.add('permissions', AdminNavItem.component({
href: app.route('permissions'),
icon: 'key',
label: 'Permissions',
description: 'Configure who can see and do what.'
}));
items.add('appearance', AdminNavItem.component({
href: app.route('appearance'),
icon: 'paint-brush',
label: 'Appearance',
description: 'Customize your forum\'s colors, logos, and other variables.'
}));
items.add('extensions', AdminNavItem.component({
href: app.route('extensions'),
icon: 'puzzle-piece',
label: 'Extensions',
description: 'Add extra functionality to your forum and make it your own.'
}));
return items;
}
}

View File

@ -0,0 +1,7 @@
import Component from 'flarum/component';
export default class AppearancePage extends Component {
view() {
return m('div', 'appearance');
}
};

View File

@ -0,0 +1,7 @@
import Component from 'flarum/component';
export default class BasicsPage extends Component {
view() {
return m('div', 'basics');
}
};

View File

@ -0,0 +1,7 @@
import Component from 'flarum/component';
export default class DashboardPage extends Component {
view() {
return m('div', 'dashboard');
}
};

View File

@ -0,0 +1,7 @@
import Component from 'flarum/component';
export default class ExtensionsPage extends Component {
view() {
return m('div', 'extensions');
}
};

View File

@ -0,0 +1,15 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
export default class HeaderPrimary extends Component {
view() {
return m('ul.header-controls', listItems(this.items().toArray()));
}
items() {
var items = new ItemList();
return items;
}
}

View File

@ -0,0 +1,19 @@
import Component from 'flarum/component';
import UserDropdown from 'flarum/components/user-dropdown';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
export default class HeaderSecondary extends Component {
view() {
return m('ul.header-controls', listItems(this.items().toArray()));
}
items() {
var items = new ItemList();
items.add('user', UserDropdown.component({ user: app.session.user() }));
return items;
}
}

View File

@ -0,0 +1,7 @@
import Component from 'flarum/component';
export default class PermissionsPage extends Component {
view() {
return m('div', 'permissions');
}
};

View File

@ -0,0 +1,35 @@
import Component from 'flarum/component';
import avatar from 'flarum/helpers/avatar';
import username from 'flarum/helpers/username';
import DropdownButton from 'flarum/components/dropdown-button';
import ActionButton from 'flarum/components/action-button';
import ItemList from 'flarum/utils/item-list';
import Separator from 'flarum/components/separator';
export default class UserDropdown extends Component {
view() {
var user = this.props.user;
return DropdownButton.component({
buttonClass: 'btn btn-default btn-naked btn-rounded btn-user',
menuClass: 'pull-right',
buttonContent: [avatar(user), ' ', m('span.label', username(user))],
items: this.items().toArray()
});
}
items() {
var items = new ItemList();
var user = this.props.user;
items.add('logOut',
ActionButton.component({
icon: 'sign-out',
label: 'Log Out',
onclick: app.session.logout.bind(app.session)
})
);
return items;
}
}

View File

@ -0,0 +1,38 @@
import ScrollListener from 'flarum/utils/scroll-listener';
import mapRoutes from 'flarum/utils/map-routes';
import BackButton from 'flarum/components/back-button';
import HeaderPrimary from 'flarum/components/header-primary';
import HeaderSecondary from 'flarum/components/header-secondary';
import Modal from 'flarum/components/modal';
import Alerts from 'flarum/components/alerts';
import AdminNav from 'flarum/components/admin-nav';
export default function(app) {
var id = id => document.getElementById(id);
app.history = {
back: function() {
window.location = 'http://flarum.dev';
},
canGoBack: function() {
return true;
}
};
m.mount(id('back-control'), BackButton.component({ className: 'back-control', drawer: true }));
m.mount(id('back-button'), BackButton.component());
m.mount(id('header-primary'), HeaderPrimary.component());
m.mount(id('header-secondary'), HeaderSecondary.component());
m.mount(id('admin-nav'), AdminNav.component());
app.modal = m.mount(id('modal'), Modal.component());
app.alerts = m.mount(id('alerts'), Alerts.component());
m.route.mode = 'hash';
m.route(id('content'), '/', mapRoutes(app.routes));
new ScrollListener(top => $('body').toggleClass('scrolled', top > 0)).start();
}

View File

@ -0,0 +1,15 @@
import DashboardPage from 'flarum/components/dashboard-page';
import BasicsPage from 'flarum/components/basics-page';
import PermissionsPage from 'flarum/components/permissions-page';
import AppearancePage from 'flarum/components/appearance-page';
import ExtensionsPage from 'flarum/components/extensions-page';
export default function(app) {
app.routes = {
'dashboard': ['/', DashboardPage.component()],
'basics': ['/basics', BasicsPage.component()],
'permissions': ['/permissions', PermissionsPage.component()],
'appearance': ['/appearance', AppearancePage.component()],
'extensions': ['/extensions', ExtensionsPage.component()]
};
}

4
js/forum/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
bower_components
node_modules
mithril.js
dist

53
js/forum/Gulpfile.js Normal file
View File

@ -0,0 +1,53 @@
var gulp = require('gulp');
var livereload = require('gulp-livereload');
var concat = require('gulp-concat');
var argv = require('yargs').argv;
var uglify = require('gulp-uglify');
var gulpif = require('gulp-if');
var merge = require('merge-stream');
var babel = require('gulp-babel');
var cached = require('gulp-cached');
var remember = require('gulp-remember');
var vendorFiles = [
'./bower_components/loader.js/loader.js',
'./bower_components/mithril/mithril.js',
'./bower_components/jquery/dist/jquery.js',
'./bower_components/jquery.hotkeys/jquery.hotkeys.js',
'./bower_components/color-thief/js/color-thief.js',
'./bower_components/moment/moment.js',
'./bower_components/bootstrap/dist/js/bootstrap.js',
'./bower_components/spin.js/spin.js',
'./bower_components/spin.js/jquery.spin.js'
];
var moduleFiles = [
'src/**/*.js',
'../lib/**/*.js'
];
var modulePrefix = 'flarum';
gulp.task('default', function() {
return merge(
gulp.src(vendorFiles),
gulp.src(moduleFiles)
.pipe(cached('scripts'))
.pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix }))
.pipe(remember('scripts'))
)
.pipe(concat('app.js'))
.pipe(gulpif(argv.production, uglify()))
.pipe(gulp.dest('dist'))
.pipe(livereload());
});
gulp.task('watch', ['default'], function () {
livereload.listen();
var watcher = gulp.watch(moduleFiles.concat(vendorFiles), ['default']);
watcher.on('change', function (event) {
if (event.type === 'deleted') {
delete cached.caches.scripts[event.path];
remember.forget('scripts', event.path);
}
});
});

13
js/forum/bower.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "flarum-forum",
"dependencies": {
"jquery": "2.1.3",
"jquery.hotkeys": "jeresig/jquery.hotkeys#0.2.0",
"bootstrap": "~3.3.2",
"spin.js": "~2.0.1",
"moment": "~2.8.4",
"color-thief": "v2.0",
"mithril": "lhorie/mithril.js#components",
"loader.js": "~3.2.1"
}
}

15
js/forum/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "flarum-forum",
"devDependencies": {
"gulp": "^3.8.11",
"gulp-babel": "^5.1.0",
"gulp-cached": "^1.0.4",
"gulp-concat": "^2.5.2",
"gulp-if": "^1.2.5",
"gulp-livereload": "^3.8.0",
"gulp-remember": "^0.3.0",
"gulp-uglify": "^1.2.0",
"merge-stream": "^0.1.7",
"yargs": "^3.7.2"
}
}

20
js/forum/src/app.js Normal file
View File

@ -0,0 +1,20 @@
import App from 'flarum/utils/app';
import store from 'flarum/initializers/store';
import preload from 'flarum/initializers/preload';
import session from 'flarum/initializers/session';
import routes from 'flarum/initializers/routes';
import components from 'flarum/initializers/components';
import timestamps from 'flarum/initializers/timestamps';
import boot from 'flarum/initializers/boot';
var app = new App();
app.initializers.add('store', store);
app.initializers.add('preload', preload);
app.initializers.add('session', session);
app.initializers.add('routes', routes);
app.initializers.add('components', components);
app.initializers.add('timestamps', timestamps);
app.initializers.add('boot', boot, {last: true});
export default app;

View File

@ -0,0 +1,18 @@
import Component from 'flarum/component';
import humanTime from 'flarum/helpers/human-time';
import avatar from 'flarum/helpers/avatar';
export default class ActivityJoin extends Component {
view() {
var activity = this.props.activity;
var user = activity.user();
return m('div', [
avatar(user, {className: 'activity-icon'}),
m('div.activity-info', [
m('strong', 'Joined the forum'),
humanTime(activity.time())
])
]);
}
}

View File

@ -0,0 +1,84 @@
import UserPage from 'flarum/components/user-page';
import LoadingIndicator from 'flarum/components/loading-indicator';
import ActionButton from 'flarum/components/action-button';
export default class ActivityPage extends UserPage {
/**
*/
constructor(props) {
super(props);
this.user = m.prop();
this.loading = m.prop(true);
this.moreResults = m.prop(false);
this.activity = m.prop([]);
var username = m.route.param('username').toLowerCase();
var users = app.store.all('users');
for (var id in users) {
if (users[id].username().toLowerCase() == username && users[id].joinTime()) {
this.setupUser(users[id]);
break;
}
}
if (!this.user()) {
app.store.find('users', username).then(this.setupUser.bind(this));
}
}
setupUser(user) {
m.startComputation();
this.user(user);
m.endComputation();
this.refresh();
}
refresh() {
m.startComputation();
this.loading(true);
this.activity([]);
m.endComputation();
this.loadResults().then(this.parseResults.bind(this));
}
loadResults(start) {
return app.store.find('activity', {
users: this.user().id(),
start,
type: this.props.filter
})
}
loadMore() {
var self = this;
this.loading(true);
this.loadResults(this.activity().length).then((results) => this.parseResults(results, true));
}
parseResults(results, append) {
this.loading(false);
[].push.apply(this.activity(), results);
this.moreResults(!!results.length);
m.redraw();
return results;
}
content() {
return m('div.user-activity', [
m('ul.activity-list', this.activity().map(activity => {
var ActivityComponent = app.activityComponentRegistry[activity.contentType()];
return ActivityComponent ? m('li', ActivityComponent.component({activity})) : '';
})),
this.loading()
? LoadingIndicator.component()
: (this.moreResults() ? m('div.load-more', ActionButton.component({
label: 'Load More',
className: 'control-loadMore btn btn-default',
onclick: this.loadMore.bind(this)
})) : '')
]);
}
}

View File

@ -0,0 +1,28 @@
import Component from 'flarum/component';
import humanTime from 'flarum/helpers/human-time';
import avatar from 'flarum/helpers/avatar';
export default class ActivityPost extends Component {
view() {
var activity = this.props.activity;
var user = activity.user();
var post = activity.post();
var discussion = post.discussion();
return m('div', [
avatar(user, {className: 'activity-icon'}),
m('div.activity-info', [
m('strong', post.number() == 1 ? 'Started a discussion' : 'Posted a reply'),
humanTime(activity.time())
]),
m('a.activity-content.activity-post', {href: app.route('discussion.near', {
id: discussion.id(),
slug: discussion.slug(),
near: post.number()
}), config: m.route}, [
m('h3.title', discussion.title()),
m('div.body', m.trust(post.contentHtml()))
])
]);
}
}

View File

@ -0,0 +1,70 @@
import Component from 'flarum/component';
import avatar from 'flarum/helpers/avatar';
import icon from 'flarum/helpers/icon';
import LoadingIndicator from 'flarum/components/loading-indicator';
export default class AvatarEditor extends Component {
constructor(props) {
super(props);
this.loading = m.prop(false);
}
view() {
var user = this.props.user;
return m('div.avatar-editor.dropdown', {
className: (this.loading() ? 'loading' : '')+' '+(this.props.className || '')
}, [
avatar(user),
m('a.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', {onclick: this.quickUpload.bind(this)}, [
this.loading() ? LoadingIndicator.component() : icon('pencil')
]),
m('ul.dropdown-menu', [
m('li', m('a[href=javascript:;]', {onclick: this.upload.bind(this)}, [icon('upload'), ' Upload'])),
m('li', m('a[href=javascript:;]', {onclick: this.remove.bind(this)}, [icon('times'), ' Remove']))
])
]);
}
quickUpload(e) {
if (!this.props.user.avatarUrl()) {
e.preventDefault();
e.stopPropagation();
this.upload();
}
}
upload() {
if (this.loading()) { return; }
var $input = $('<input type="file">');
var user = this.props.user;
var self = this;
$input.appendTo('body').hide().click().on('change', function() {
var data = new FormData();
data.append('avatar', $(this)[0].files[0]);
self.loading(true);
m.redraw();
m.request({
method: 'POST',
url: app.config.apiURL+'/users/'+user.id()+'/avatar',
data: data,
serialize: data => data,
background: true,
config: app.session.authorize.bind(app.session)
}).then(function(data) {
self.loading(false);
app.store.pushPayload(data);
delete user.avatarColor;
m.redraw();
});
});
}
remove() {
this.props.user.pushData({avatarUrl: null});
delete this.props.user.avatarColor;
m.redraw();
}
}

View File

@ -0,0 +1,45 @@
import Component from 'flarum/component';
import LoadingIndicator from 'flarum/components/loading-indicator';
import TextEditor from 'flarum/components/text-editor';
import avatar from 'flarum/helpers/avatar';
import listItems from 'flarum/helpers/list-items';
export default class ComposerBody extends Component {
constructor(props) {
super(props);
this.loading = m.prop(false);
this.disabled = m.prop(false);
this.content = m.prop(this.props.originalContent);
}
view() {
return m('div', {config: this.element}, [
avatar(this.props.user, {className: 'composer-avatar'}),
m('div.composer-body', [
m('ul.composer-header', listItems(this.headerItems().toArray())),
m('div.composer-editor', TextEditor.component({
submitLabel: this.props.submitLabel,
placeholder: this.props.placeholder,
disabled: this.loading(),
onchange: this.content,
onsubmit: this.onsubmit.bind(this),
value: this.content()
}))
]),
LoadingIndicator.component({className: 'composer-loading'+(this.loading() ? ' active' : '')})
]);
}
focus() {
this.$().find(':input:enabled:visible:first').focus();
}
preventExit() {
return this.content() != this.props.originalContent && !confirm(this.props.confirmExit);
}
onsubmit(value) {
//
}
}

View File

@ -0,0 +1,71 @@
import ItemList from 'flarum/utils/item-list';
import ComposerBody from 'flarum/components/composer-body';
import Alert from 'flarum/components/alert';
import ActionButton from 'flarum/components/action-button';
/**
The composer body for starting a new discussion. Adds a text field as a
control so the user can enter the title of their discussion. Also overrides
the `submit` and `willExit` actions to account for the title.
*/
export default class ComposerDiscussion extends ComposerBody {
constructor(props) {
props.submitLabel = props.submitLabel || 'Post Discussion';
props.confirmExit = props.confirmExit || 'You have not posted your discussion. Do you wish to discard it?';
props.titlePlaceholder = props.titlePlaceholder || 'Discussion Title';
super(props);
this.title = m.prop('');
}
headerItems() {
var items = new ItemList();
var post = this.props.post;
items.add('title', m('h3', m('input', {
className: 'form-control',
value: this.title(),
onchange: m.withAttr('value', this.title),
placeholder: this.props.titlePlaceholder,
disabled: !!this.props.disabled,
config: function(element, isInitialized) {
if (isInitialized) { return; }
$(element).on('input', function() {
var $this = $(this);
var empty = !$this.val();
if (empty) { $this.val($this.attr('placeholder')); }
$this.css('width', 0);
$this.css('width', $this[0].scrollWidth);
if (empty) { $this.val(''); }
});
setTimeout(() => $(element).trigger('input'));
}
})));
return items;
}
preventExit() {
return (this.title() || this.content()) && !confirm(this.props.confirmExit);
}
onsubmit(content) {
this.loading(true);
m.redraw();
var data = {
title: this.title(),
content: content
};
app.store.createRecord('discussions').save(data).then(discussion => {
app.composer.hide();
app.cache.discussionList.discussions().unshift(discussion);
m.route(app.route('discussion', discussion));
}, response => {
this.loading(false);
m.redraw();
});
}
}

View File

@ -0,0 +1,44 @@
import ItemList from 'flarum/utils/item-list';
import ComposerBody from 'flarum/components/composer-body';
import Alert from 'flarum/components/alert';
import ActionButton from 'flarum/components/action-button';
/**
The composer body for editing a post. Sets the initial content to the
content of the post that is being edited, and adds a title control to
indicate which post is being edited.
*/
export default class ComposerEdit extends ComposerBody {
constructor(props) {
props.submitLabel = props.submitLabel || 'Save Changes';
props.confirmExit = props.confirmExit || 'You have not saved your changes. Do you wish to discard them?';
props.originalContent = props.originalContent || props.post.content();
props.user = props.user || props.post.user();
super(props);
}
headerItems() {
var items = new ItemList();
var post = this.props.post;
items.add('title', m('h3', ['Editing Post #'+post.number()+' in ', m('em', post.discussion().title())]));
return items;
}
onsubmit(content) {
var post = this.props.post;
this.loading(true);
m.redraw();
post.save({content}).then(post => {
app.composer.hide();
m.redraw();
}, response => {
this.loading(false);
m.redraw();
});
}
}

View File

@ -0,0 +1,82 @@
import ItemList from 'flarum/utils/item-list';
import ComposerBody from 'flarum/components/composer-body';
import Alert from 'flarum/components/alert';
import ActionButton from 'flarum/components/action-button';
export default class ComposerReply extends ComposerBody {
constructor(props) {
props.submitLabel = props.submitLabel || 'Post Reply';
props.confirmExit = props.confirmExit || 'You have not posted your reply. Do you wish to discard it?';
super(props);
}
headerItems() {
var items = new ItemList();
items.add('title', m('h3', ['Replying to ', m('em', this.props.discussion.title())]));
return items;
}
onsubmit(value) {
var discussion = this.props.discussion;
this.loading(true);
m.redraw();
var data = {
content: value,
links: {discussion}
};
app.store.createRecord('posts').save(data).then((post) => {
app.composer.hide();
discussion.pushData({
links: {
lastUser: post.user(),
lastPost: post
},
lastTime: post.time(),
lastPostNumber: post.number(),
commentsCount: discussion.commentsCount() + 1,
readTime: post.time(),
readNumber: post.number()
});
// If we're currently viewing the discussion which this reply was made
// in, then we can add the post to the end of the post stream.
if (app.current && app.current.discussion && app.current.discussion().id() === discussion.id()) {
app.current.stream().addPostToEnd(post);
m.route(app.route('discussion.near', {
id: discussion.id(),
slug: discussion.slug(),
near: post.number()
}));
} else {
// Otherwise, we'll create an alert message to inform the user that
// their reply has been posted, containing a button which will
// transition to their new post when clicked.
var alert;
var viewButton = ActionButton.component({
label: 'View',
onclick: () => {
m.route(app.route('discussion.near', { id: discussion.id(), slug: discussion.slug(), near: post.number() }));
app.alerts.dismiss(alert);
}
});
app.alerts.show(
alert = new Alert({
type: 'success',
message: 'Your reply was posted.',
controls: [viewButton]
})
);
}
}, (response) => {
this.loading(false);
m.redraw();
});
}
}

View File

@ -0,0 +1,295 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import ActionButton from 'flarum/components/action-button';
import icon from 'flarum/helpers/icon';
import listItems from 'flarum/helpers/list-items';
import classList from 'flarum/utils/class-list';
import computed from 'flarum/utils/computed';
class Composer extends Component {
constructor(props) {
super(props);
this.position = m.prop(Composer.PositionEnum.HIDDEN);
this.height = m.prop();
// Calculate the composer's current height, based on the intended height
// (which is set when the resizing handle is dragged), and the composer's
// current state.
this.computedHeight = computed('height', 'position', function(height, position) {
if (position === Composer.PositionEnum.MINIMIZED || position === Composer.PositionEnum.HIDDEN) {
return '';
} else if (position === Composer.PositionEnum.FULLSCREEN) {
return $(window).height();
} else {
return Math.max(200, Math.min(height, $(window).height() - $('#header').outerHeight()));
}
});
}
view() {
var classes = {
'minimized': this.position() === Composer.PositionEnum.MINIMIZED,
'full-screen': this.position() === Composer.PositionEnum.FULLSCREEN
};
classes.visible = this.position() === Composer.PositionEnum.NORMAL || classes.minimized || classes.fullScreen;
this.component && (this.component.props.disabled = classes.minimized);
return m('div.composer', {config: this.onload.bind(this), className: classList(classes)}, [
m('div.composer-handle', {config: this.configHandle.bind(this)}),
m('ul.composer-controls', listItems(this.controlItems().toArray())),
m('div.composer-content', {onclick: this.show.bind(this)}, this.component ? this.component.view() : '')
]);
}
onload(element, isInitialized, context) {
this.element(element);
if (isInitialized) { return; }
context.retain = true;
// Hide the composer to begin with.
this.height(localStorage.getItem('composerHeight') || this.$().height());
this.$().hide();
// Modulate the view's active property/class according to the focus
// state of any inputs.
this.$().on('focus blur', ':input', (e) => this.$().toggleClass('active', e.type === 'focusin'));
// When the escape key is pressed on any inputs, close the composer.
this.$().on('keydown', ':input', 'esc', () => this.close());
context.onunload = this.ondestroy.bind(this);
this.handlers = {};
$(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
$(document)
.on('mousemove', this.handlers.onmousemove = this.onmousemove.bind(this))
.on('mouseup', this.handlers.onmouseup = this.onmouseup.bind(this));
}
configHandle(element, isInitialized) {
if (isInitialized) { return; }
var self = this;
$(element).css('cursor', 'row-resize')
.mousedown(function(e) {
self.mouseStart = e.clientY;
self.heightStart = self.$().height();
self.handle = $(this);
$('body').css('cursor', 'row-resize');
}).bind('dragstart mousedown', function(e) {
e.preventDefault();
});
}
ondestroy() {
$(window).off('resize', this.handlers.onresize);
$(document)
.off('mousemove', this.handlers.onmousemove)
.off('mouseup', this.handlers.onmouseup);
}
updateHeight() {
this.$().height(this.computedHeight());
this.setContentHeight(this.computedHeight());
}
onresize() {
this.updateHeight();
}
onmousemove(e) {
if (!this.handle) { return; }
// Work out how much the mouse has been moved, and set the height
// relative to the old one based on that. Then update the content's
// height so that it fills the height of the composer, and update the
// body's padding.
var deltaPixels = this.mouseStart - e.clientY;
var height = this.heightStart + deltaPixels;
this.height(height);
this.updateHeight();
this.updateBodyPadding();
localStorage.setItem('composerHeight', height);
}
onmouseup(e) {
if (!this.handle) { return; }
this.handle = null;
$('body').css('cursor', '');
}
preventExit() {
return this.component && this.component.preventExit();
}
render() {
// @todo this function's logic could probably use some reworking. The
// following line is bad because it prevents focusing on the composer
// input when the composer is shown when it's already being shown
if (this.position() === this.oldPosition) { return; }
var $composer = this.$();
var oldHeight = $composer.is(':visible') ? $composer.height() : 0;
if (this.position() !== Composer.PositionEnum.HIDDEN) {
m.redraw(true);
}
this.updateHeight();
var newHeight = $composer.height();
switch (this.position()) {
case Composer.PositionEnum.HIDDEN:
$composer.css({height: oldHeight}).animate({bottom: -newHeight}, 'fast', () => {
$composer.hide();
this.clear();
m.redraw();
});
break;
case Composer.PositionEnum.NORMAL:
if (this.oldPosition !== Composer.PositionEnum.FULLSCREEN) {
$composer.show();
$composer.css({height: oldHeight}).animate({bottom: 0, height: newHeight}, 'fast', this.component.focus.bind(this.component));
} else {
this.component.focus();
}
break;
case Composer.PositionEnum.MINIMIZED:
$composer.css({height: oldHeight}).animate({height: newHeight}, 'fast', this.component.focus.bind(this.component));
break;
}
if (this.position() !== Composer.PositionEnum.FULLSCREEN) {
this.updateBodyPadding(true);
} else {
this.component.focus();
}
$('body').toggleClass('composer-open', this.position() !== Composer.PositionEnum.HIDDEN);
this.oldPosition = this.position();
this.setContentHeight(this.computedHeight());
}
// Update the amount of padding-bottom on the body so that the page's
// content will still be visible above the composer when the page is
// scrolled right to the bottom.
updateBodyPadding(animate) {
// Before we change anything, work out if we're currently scrolled
// right to the bottom of the page. If we are, we'll want to anchor
// the body's scroll position to the bottom after we update the
// padding.
var scrollTop = $(window).scrollTop();
var anchorScroll = scrollTop > 0 && scrollTop + $(window).height() >= $(document).height();
var func = animate ? 'animate' : 'css';
var paddingBottom = this.position() !== Composer.PositionEnum.HIDDEN ? this.computedHeight() - parseInt($('#page').css('padding-bottom')) : 0;
$('#content')[func]({paddingBottom}, 'fast');
if (anchorScroll) {
if (animate) {
$('html, body').stop(true).animate({scrollTop: $(document).height()}, 'fast');
} else {
$('html, body').scrollTop($(document).height());
}
}
}
// Update the height of the stuff inside of the composer. There should be
// an element with the class .flexible-height — this element is intended
// to fill up the height of the composer, minus the space taken up by the
// composer's header/footer/etc.
setContentHeight(height) {
var content = this.$('.composer-content');
this.$('.flexible-height').height(height -
parseInt(content.css('padding-top')) -
parseInt(content.css('padding-bottom')) -
this.$('.composer-header').outerHeight(true) -
this.$('.text-editor-controls').outerHeight(true));
}
load(component) {
if (!this.preventExit()) {
this.component = component;
}
}
clear() {
this.component = null;
}
show() {
if ([Composer.PositionEnum.MINIMIZED, Composer.PositionEnum.HIDDEN].indexOf(this.position()) !== -1) {
this.position(Composer.PositionEnum.NORMAL);
}
this.render();
}
hide() {
this.position(Composer.PositionEnum.HIDDEN);
this.render();
}
close() {
if (!this.preventExit()) {
this.hide();
}
}
minimize() {
if (this.position() !== Composer.PositionEnum.HIDDEN) {
this.position(Composer.PositionEnum.MINIMIZED);
this.render();
}
}
fullScreen() {
if (this.position() !== Composer.PositionEnum.HIDDEN) {
this.position(Composer.PositionEnum.FULLSCREEN);
this.render();
}
}
exitFullScreen() {
if (this.position() === Composer.PositionEnum.FULLSCREEN) {
this.position(Composer.PositionEnum.NORMAL);
this.render();
}
}
control(props) {
props.className = 'btn btn-icon btn-link';
return ActionButton.component(props);
}
controlItems() {
var items = new ItemList();
if (this.position() === Composer.PositionEnum.FULLSCREEN) {
items.add('exitFullScreen', this.control({ icon: 'compress', title: 'Exit Full Screen', onclick: this.exitFullScreen.bind(this) }));
} else {
if (this.position() !== Composer.PositionEnum.MINIMIZED) {
items.add('minimize', this.control({ icon: 'minus minimize', title: 'Minimize', onclick: this.minimize.bind(this) }));
items.add('fullScreen', this.control({ icon: 'expand', title: 'Full Screen', onclick: this.fullScreen.bind(this) }));
}
items.add('close', this.control({ icon: 'times', title: 'Close', wrapperClass: 'back-control', onclick: this.close.bind(this) }));
}
return items;
}
}
Composer.PositionEnum = {
HIDDEN: 'hidden',
NORMAL: 'normal',
MINIMIZED: 'minimized',
FULLSCREEN: 'fullScreen'
};
export default Composer;

View File

@ -0,0 +1,197 @@
import Component from 'flarum/component';
import avatar from 'flarum/helpers/avatar';
import listItems from 'flarum/helpers/list-items';
import humanTime from 'flarum/utils/human-time';
import ItemList from 'flarum/utils/item-list';
import abbreviateNumber from 'flarum/utils/abbreviate-number';
import ActionButton from 'flarum/components/action-button';
import DropdownButton from 'flarum/components/dropdown-button';
import LoadingIndicator from 'flarum/components/loading-indicator';
import TerminalPost from 'flarum/components/terminal-post';
export default class DiscussionList extends Component {
constructor(props) {
super(props);
this.loading = m.prop(true);
this.moreResults = m.prop(false);
this.discussions = m.prop([]);
this.sort = m.prop(this.props.sort || 'recent');
this.sortOptions = m.prop([
{key: 'recent', value: 'Recent', sort: 'recent'},
{key: 'replies', value: 'Replies', sort: '-replies'},
{key: 'newest', value: 'Newest', sort: '-created'},
{key: 'oldest', value: 'Oldest', sort: 'created'}
]);
this.refresh();
app.session.on('loggedIn', this.loggedInHandler = this.refresh.bind(this))
}
refresh() {
m.startComputation();
this.loading(true);
this.discussions([]);
m.endComputation();
this.loadResults().then(this.parseResults.bind(this));
}
onunload() {
app.session.off('loggedIn', this.loggedInHandler);
}
terminalPostType() {
return ['newest', 'oldest'].indexOf(this.sort()) !== -1 ? 'start' : 'last'
}
countType() {
return this.sort() === 'replies' ? 'replies' : 'unread';
}
loadResults(start) {
var self = this;
var sort = this.sortOptions()[0].sort;
this.sortOptions().some(function(option) {
if (option.key === self.sort()) {
sort = option.sort;
return true;
}
});
var params = {sort, start};
return app.store.find('discussions', params);
}
loadMore() {
var self = this;
this.loading(true);
this.loadResults(this.discussions().length).then((results) => this.parseResults(results, true));
}
parseResults(results, append) {
m.startComputation();
this.loading(false);
[].push.apply(this.discussions(), results);
this.moreResults(!!results.meta.moreUrl);
m.endComputation();
return results;
}
markAsRead(discussion) {
if (discussion.isUnread()) {
discussion.save({ readNumber: discussion.lastPostNumber() });
m.redraw();
}
}
delete(discussion) {
if (confirm('Are you sure you want to delete this discussion?')) {
discussion.delete();
this.removeDiscussion(discussion);
if (app.current.discussion && app.current.discussion().id() === discussion.id()) {
app.history.back();
}
}
}
removeDiscussion(discussion) {
var index = this.discussions().indexOf(discussion);
if (index !== -1) {
this.discussions().splice(index, 1);
}
}
view() {
return m('div', [
m('ul.discussions-list', [
this.discussions().map(function(discussion) {
var startUser = discussion.startUser()
var isUnread = discussion.isUnread()
var displayUnread = this.props.countType !== 'replies' && isUnread
var jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1)
var controls = this.controlItems(discussion).toArray();
var discussionRoute = app.route('discussion', discussion);
var active = m.route().substr(0, discussionRoute.length) === discussionRoute;
return m('li.discussion-summary'+(isUnread ? '.unread' : '')+(active ? '.active' : ''), {key: discussion.id()}, [
controls.length ? DropdownButton.component({
items: controls,
className: 'contextual-controls',
buttonClass: 'btn btn-default btn-icon btn-sm btn-naked',
menuClass: 'pull-right'
}) : '',
m('a.author', {
href: app.route('user', startUser),
config: function(element, isInitialized, context) {
$(element).tooltip({ placement: 'right' })
m.route.call(this, element)
},
title: 'Started by '+startUser.username()+' '+humanTime(discussion.startTime())
}, [
avatar(startUser, {title: ''})
]),
m('ul.badges', listItems(discussion.badges().toArray())),
m('a.main', {href: app.route('discussion.near', {id: discussion.id(), slug: discussion.slug(), near: jumpTo}), config: m.route}, [
m('h3.title', discussion.title()),
m('ul.info', listItems(this.infoItems(discussion).toArray()))
]),
m('span.count', {onclick: this.markAsRead.bind(this, discussion)}, [
abbreviateNumber(discussion[displayUnread ? 'unreadCount' : 'repliesCount']()),
m('span.label', displayUnread ? 'unread' : 'replies')
])
])
}.bind(this))
]),
this.loading()
? LoadingIndicator.component()
: (this.moreResults() ? m('div.load-more', ActionButton.component({
label: 'Load More',
className: 'control-loadMore btn btn-default',
onclick: this.loadMore.bind(this)
})) : '')
]);
}
/**
Build an item list of info for a discussion listing. By default this is
just the first/last post indicator.
@return {ItemList}
*/
infoItems(discussion) {
var items = new ItemList();
items.add('terminalPost',
TerminalPost.component({
discussion,
lastPost: this.props.terminalPostType !== 'start'
})
);
return items;
}
/**
Build an item list of controls for a discussion listing.
@return {ItemList}
*/
controlItems(discussion) {
var items = new ItemList();
if (discussion.canDelete()) {
items.add('delete', ActionButton.component({
icon: 'times',
label: 'Delete',
onclick: this.delete.bind(this, discussion)
}));
}
return items;
}
}

View File

@ -0,0 +1,280 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import IndexPage from 'flarum/components/index-page';
import PostStream from 'flarum/utils/post-stream';
import DiscussionList from 'flarum/components/discussion-list';
import StreamContent from 'flarum/components/stream-content';
import StreamScrubber from 'flarum/components/stream-scrubber';
import ComposerReply from 'flarum/components/composer-reply';
import ActionButton from 'flarum/components/action-button';
import LoadingIndicator from 'flarum/components/loading-indicator';
import DropdownSplit from 'flarum/components/dropdown-split';
import Separator from 'flarum/components/separator';
import listItems from 'flarum/helpers/list-items';
export default class DiscussionPage extends Component {
/**
*/
constructor(props) {
super(props);
this.discussion = m.prop();
// Set up the stream. The stream is an object that represents the posts in
// a discussion, as they're displayed on the screen (i.e. missing posts
// are condensed into "load more" gaps).
this.stream = m.prop();
// Get the discussion. We may already have a copy of it in our store, so
// we'll start off with that. If we do have a copy of the discussion, and
// its posts relationship has been loaded (i.e. we've viewed this
// discussion before), then we can proceed with displaying it immediately.
// If not, we'll make an API request first.
app.store.find('discussions', m.route.param('id'), {
near: this.currentNear = m.route.param('near'),
include: 'posts'
}).then(this.setupDiscussion.bind(this));
if (app.cache.discussionList) {
app.pane.enable();
app.pane.hide();
m.redraw.strategy('diff'); // otherwise pane redraws and mouseenter even is triggered so it doesn't hide
}
app.history.push('discussion');
app.current = this;
app.composer.minimize();
}
/*
*/
setupDiscussion(discussion) {
this.discussion(discussion);
var includedPosts = [];
discussion.payload.included.forEach(record => {
if (record.type === 'posts') {
includedPosts.push(record.id);
}
});
// Set up the post stream for this discussion, and add all of the posts we
// have loaded so far.
this.stream(new PostStream(discussion));
this.stream().addPosts(discussion.posts().filter(value => value && includedPosts.indexOf(value.id()) !== -1));
this.streamContent = new StreamContent({
stream: this.stream(),
className: 'discussion-posts posts',
positionChanged: this.positionChanged.bind(this)
});
// Hold up there skippy! If the slug in the URL doesn't match up, we'll
// redirect so we have the correct one.
if (m.route.param('id') === discussion.id() && m.route.param('slug') !== discussion.slug()) {
var params = m.route.param();
params.slug = discussion.slug();
params.near = params.near || '';
m.route(app.route('discussion.near', params), null, true);
return;
}
this.streamContent.goToNumber(this.currentNear, true);
}
onload(element, isInitialized, context) {
if (isInitialized) { return; }
context.retain = true;
$('body').addClass('discussion-page');
context.onunload = function() {
$('body').removeClass('discussion-page');
}
}
/**
*/
onunload(e) {
// If we have routed to the same discussion as we were viewing previously,
// cancel the unloading of this controller and instead prompt the post
// stream to jump to the new 'near' param.
var discussion = this.discussion();
if (discussion) {
var discussionRoute = app.route('discussion', discussion);
if (m.route().substr(0, discussionRoute.length) === discussionRoute) {
e.preventDefault();
if (m.route.param('near') != this.currentNear) {
this.streamContent.goToNumber(m.route.param('near'));
}
return;
}
}
app.pane.disable();
}
/**
*/
view() {
var discussion = this.discussion();
return m('div', {config: this.onload.bind(this)}, [
app.cache.discussionList ? m('div.index-area.paned', {config: this.configIndex.bind(this)}, app.cache.discussionList.view()) : '',
m('div.discussion-area', discussion ? [
m('header.hero.discussion-hero', [
m('div.container', [
m('ul.badges', listItems(discussion.badges().toArray())), ' ',
m('h2.discussion-title', discussion.title())
])
]),
m('div.container', [
m('nav.discussion-nav', [
m('ul', listItems(this.sidebarItems().toArray()))
]),
this.streamContent.view()
])
] : LoadingIndicator.component({className: 'loading-indicator-block'}))
]);
}
/**
*/
configIndex(element, isInitialized, context) {
if (isInitialized) { return; }
context.retain = true;
// When viewing a discussion (for which the discussions route is the
// parent,) the discussion list is still rendered but it becomes a
// pane hidden on the side of the screen. When the mouse enters and
// leaves the discussions pane, we want to show and hide the pane
// respectively. We also create a 10px 'hot edge' on the left of the
// screen to activate the pane.
var pane = app.pane;
$(element).hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
var hotEdge = function(e) {
if (e.pageX < 10) { pane.show(); }
};
$(document).on('mousemove', hotEdge);
context.onunload = function() {
$(document).off('mousemove', hotEdge);
};
}
/**
*/
sidebarItems() {
var items = new ItemList();
items.add('controls',
DropdownSplit.component({
items: this.controlItems().toArray(),
icon: 'reply',
buttonClass: 'btn btn-primary',
wrapperClass: 'primary-control'
})
);
items.add('scrubber',
StreamScrubber.component({
streamContent: this.streamContent,
wrapperClass: 'title-control'
})
);
return items;
}
/**
*/
controlItems() {
var items = new ItemList();
var discussion = this.discussion();
items.add('reply', ActionButton.component({ icon: 'reply', label: 'Reply', onclick: this.reply.bind(this) }));
items.add('separator', Separator.component());
if (discussion.canEdit()) {
items.add('rename', ActionButton.component({ icon: 'pencil', label: 'Rename', onclick: this.rename.bind(this) }));
}
if (discussion.canDelete()) {
items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.delete.bind(this) }));
}
return items;
}
reply() {
if (app.session.user()) {
this.streamContent.goToLast();
if (!this.composer || app.composer.component !== this.composer) {
this.composer = new ComposerReply({
user: app.session.user(),
discussion: this.discussion()
});
app.composer.load(this.composer);
}
app.composer.show();
} else {
// signup
}
}
delete() {
if (confirm('Are you sure you want to delete this discussion?')) {
var discussion = this.discussion();
discussion.delete();
if (app.cache.discussionList) {
app.cache.discussionList.removeDiscussion(discussion);
}
app.history.back();
}
}
rename() {
var discussion = this.discussion();
var currentTitle = discussion.title();
var title = prompt('Enter a new title for this discussion:', currentTitle);
if (title && title !== currentTitle) {
discussion.save({title}).then(discussion => {
discussion.addedPosts().forEach(post => this.stream().addPostToEnd(post));
m.redraw();
});
}
}
/**
*/
positionChanged(startNumber, endNumber) {
var discussion = this.discussion();
var url = app.route('discussion.near', {
id: discussion.id(),
slug: discussion.slug(),
near: this.currentNear = startNumber
});
// https://github.com/lhorie/mithril.js/issues/559
m.route(url, true);
window.history.replaceState(null, document.title, (m.route.mode === 'hash' ? '#' : '')+url);
app.history.push('discussion');
if (app.session.user() && endNumber > discussion.readNumber()) {
discussion.save({readNumber: endNumber});
m.redraw();
}
}
}

View File

@ -0,0 +1,15 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
export default class FooterPrimary extends Component {
view() {
return m('ul.footer-controls', listItems(this.items().toArray()));
}
items() {
var items = new ItemList();
return items;
}
}

View File

@ -0,0 +1,17 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
export default class FooterSecondary extends Component {
view() {
return m('ul.footer-controls', listItems(this.items().toArray()));
}
items() {
var items = new ItemList();
items.add('poweredBy', m('a[href=http://flarum.org][target=_blank]', 'Powered by Flarum'));
return items;
}
}

View File

@ -0,0 +1,15 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
export default class HeaderPrimary extends Component {
view() {
return m('ul.header-controls', listItems(this.items().toArray()));
}
items() {
var items = new ItemList();
return items;
}
}

View File

@ -0,0 +1,44 @@
import Component from 'flarum/component';
import ActionButton from 'flarum/components/action-button';
import LoginModal from 'flarum/components/login-modal';
import SignupModal from 'flarum/components/signup-modal';
import UserDropdown from 'flarum/components/user-dropdown';
import UserNotifications from 'flarum/components/user-notifications';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
export default class HeaderSecondary extends Component {
view() {
return m('ul.header-controls', listItems(this.items().toArray()));
}
items() {
var items = new ItemList();
if (app.session.user()) {
items.add('notifications', UserNotifications.component({ user: app.session.user() }))
items.add('user', UserDropdown.component({ user: app.session.user() }));
}
else {
items.add('signUp',
ActionButton.component({
label: 'Sign Up',
className: 'btn btn-link',
onclick: () => app.modal.show(new SignupModal())
})
);
items.add('logIn',
ActionButton.component({
label: 'Log In',
className: 'btn btn-link',
onclick: () => app.modal.show(new LoginModal())
})
);
}
return items;
}
}

View File

@ -0,0 +1,173 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
import Discussion from 'flarum/models/discussion';
import mixin from 'flarum/utils/mixin';
import DiscussionList from 'flarum/components/discussion-list';
import WelcomeHero from 'flarum/components/welcome-hero';
import ComposerDiscussion from 'flarum/components/composer-discussion';
import SelectInput from 'flarum/components/select-input';
import ActionButton from 'flarum/components/action-button';
import NavItem from 'flarum/components/nav-item';
import LoadingIndicator from 'flarum/components/loading-indicator';
import DropdownSelect from 'flarum/components/dropdown-select';
export default class IndexPage extends Component {
constructor(props) {
super(props);
if (app.cache.discussionList) {
if (app.cache.discussionList.props.sort !== m.route.param('sort')) {
app.cache.discussionList = null;
}
}
if (!app.cache.discussionList) {
app.cache.discussionList = new DiscussionList({
sort: m.route.param('sort')
});
}
app.history.push('index');
app.current = this;
app.composer.minimize();
}
reorder(sort) {
var filter = m.route.param('filter') || '';
var params = sort !== 'recent' ? {sort} : {};
m.route(app.route('index.filter', {filter}, params));
}
/**
Render the component.
@method view
@return void
*/
view() {
return m('div.index-area', {config: this.onload.bind(this)}, [
WelcomeHero.component(),
m('div.container', [
m('nav.side-nav.index-nav', {config: this.affixSidebar}, [
m('ul', listItems(this.sidebarItems().toArray()))
]),
m('div.offset-content.index-results', [
m('div.index-toolbar', [
m('div.index-toolbar-view', [
SelectInput.component({
options: app.cache.discussionList.sortOptions(),
value: app.cache.discussionList.sort(),
onchange: this.reorder.bind(this)
}),
]),
m('div.index-toolbar-action', [
ActionButton.component({
title: 'Mark All as Read',
icon: 'check',
className: 'control-markAllAsRead btn btn-default btn-icon',
onclick: this.markAllAsRead.bind(this)
})
])
]),
app.cache.discussionList.view()
])
])
])
}
onload(element, isInitialized, context) {
if (isInitialized) { return; }
this.element(element);
$('body').addClass('index-page');
context.onunload = function() {
$('body').removeClass('index-page');
}
}
newDiscussion() {
if (app.session.user()) {
app.composer.load(new ComposerDiscussion({ user: app.session.user() }));
app.composer.show();
} else {
// signup
}
}
markAllAsRead() {
app.session.user().save({ readTime: new Date() });
}
/**
Build an item list for the sidebar of the index page. By default this is a
"New Discussion" button, and then a DropdownSelect component containing a
list of navigation items (see this.navItems).
@return {ItemList}
*/
sidebarItems() {
var items = new ItemList();
items.add('newDiscussion',
ActionButton.component({
label: 'Start a Discussion',
icon: 'edit',
className: 'btn btn-primary new-discussion',
wrapperClass: 'primary-control',
onclick: this.newDiscussion.bind(this)
})
);
items.add('nav',
DropdownSelect.component({
items: this.navItems(this).toArray(),
wrapperClass: 'title-control'
})
);
return items;
}
/**
Build an item list for the navigation in the sidebar of the index page. By
default this is just the 'All Discussions' link.
@return {ItemList}
*/
navItems() {
var items = new ItemList();
var params = {sort: m.route.param('sort')};
items.add('allDiscussions',
NavItem.component({
href: app.route('index', {}, params),
label: 'All Discussions',
icon: 'comments-o'
})
);
return items;
}
/**
Setup the sidebar DOM element to be affixed to the top of the viewport
using Bootstrap's affix plugin.
@param {DOMElement} element
@param {Boolean} isInitialized
@return {void}
*/
affixSidebar(element, isInitialized) {
if (isInitialized) { return; }
var $sidebar = $(element);
$sidebar.find('> ul').affix({
offset: {
top: () => $sidebar.offset().top - $('.global-header').outerHeight(true) - parseInt($sidebar.css('margin-top')),
bottom: () => (this.bottom = $('.global-footer').outerHeight(true))
}
});
}
};

View File

@ -0,0 +1,57 @@
import Component from 'flarum/component';
import LoadingIndicator from 'flarum/components/loading-indicator';
import icon from 'flarum/helpers/icon';
export default class LoginModal extends Component {
constructor(props) {
super(props);
this.email = m.prop();
this.password = m.prop();
this.loading = m.prop(false);
}
view() {
return m('div.modal-dialog.modal-sm.modal-login', [
m('div.modal-content', [
m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')),
m('form', {onsubmit: this.login.bind(this)}, [
m('div.modal-header', m('h3.title-control', 'Log In')),
m('div.modal-body', [
m('div.form-centered', [
m('div.form-group', [
m('input.form-control[name=email][placeholder=Username or Email]', {onchange: m.withAttr('value', this.email)})
]),
m('div.form-group', [
m('input.form-control[type=password][name=password][placeholder=Password]', {onchange: m.withAttr('value', this.password)})
]),
m('div.form-group', [
m('button.btn.btn-primary.btn-block[type=submit]', 'Log In')
])
])
]),
m('div.modal-footer', [
m('p.forgot-password-link', m('a[href=javascript:;]', 'Forgot password?')),
m('p.sign-up-link', ['Don\'t have an account? ', m('a[href=javascript:;]', {onclick: app.signup}, 'Sign Up')])
])
])
]),
LoadingIndicator.component({className: 'modal-loading'+(this.loading() ? ' active' : '')})
])
}
ready($modal) {
$modal.find('[name=email]').focus();
}
login(e) {
e.preventDefault();
this.loading(true);
app.session.login(this.email(), this.password()).then(function() {
app.modal.close();
}, (response) => {
this.loading(false);
m.redraw();
});
}
}

View File

@ -0,0 +1,31 @@
import Notification from 'flarum/components/notification';
import avatar from 'flarum/helpers/avatar';
import icon from 'flarum/helpers/icon';
import username from 'flarum/helpers/username';
import humanTime from 'flarum/helpers/human-time';
export default class NotificationDiscussionRenamed extends Notification {
content() {
var notification = this.props.notification;
var discussion = notification.subject();
return m('a', {href: app.route('discussion.near', {
id: discussion.id(),
slug: discussion.slug(),
near: notification.content().number
}), config: m.route}, [
avatar(notification.sender()),
m('h3.notification-title', notification.content().oldTitle),
m('div.notification-info', [
icon('pencil'),
' Renamed by ', username(notification.sender()),
notification.additionalUnreadCount() ? ' and '+notification.additionalUnreadCount()+' others' : '',
' ', humanTime(notification.time())
])
]);
}
read() {
this.props.notification.save({isRead: true});
}
}

View File

@ -0,0 +1,97 @@
import Component from 'flarum/component';
import YesNoInput from 'flarum/components/yesno-input';
import icon from 'flarum/helpers/icon';
export default class NotificationGrid extends Component {
constructor(props) {
super(props);
this.methods = [
{ name: 'alert', icon: 'bell', label: 'Alert' },
{ name: 'email', icon: 'envelope-o', label: 'Email' }
];
this.inputs = {};
this.props.types.forEach(type => {
this.methods.forEach(method => {
var key = this.key(type.name, method.name);
var preference = this.props.user.preferences()[key];
this.inputs[key] = new YesNoInput({
state: !!preference,
disabled: typeof preference == 'undefined',
onchange: () => this.toggle([key])
});
});
});
}
key(type, method) {
return 'notify_'+type+'_'+method;
}
view() {
return m('div.notification-grid', {config: this.onload.bind(this)}, [
m('table', [
m('thead', [
m('tr', [
m('td'),
this.methods.map(method => m('th.toggle-group', {onclick: this.toggleMethod.bind(this, method.name)}, [icon(method.icon), ' ', method.label]))
])
]),
m('tbody', [
this.props.types.map(type => m('tr', [
m('td.toggle-group', {onclick: this.toggleType.bind(this, type.name)}, type.label),
this.methods.map(method => {
var key = this.key(type.name, method.name);
return m('td.yesno-cell', this.inputs[key].view());
})
]))
])
])
]);
}
onload(element, isInitialized) {
if (isInitialized) { return; }
this.element(element);
var self = this;
this.$('thead .toggle-group').bind('mouseenter mouseleave', function(e) {
var i = parseInt($(this).index()) + 1;
self.$('table').find('td:nth-child('+i+')').toggleClass('highlighted', e.type === 'mouseenter');
});
this.$('tbody .toggle-group').bind('mouseenter mouseleave', function(e) {
$(this).parent().find('td').toggleClass('highlighted', e.type === 'mouseenter');
});
}
toggle(keys) {
var user = this.props.user;
var preferences = user.preferences();
var enabled = !preferences[keys[0]];
keys.forEach(key => {
var control = this.inputs[key];
if (!control.props.disabled) {
control.loading(true);
preferences[key] = control.props.state = enabled;
}
});
m.redraw();
user.save({preferences}).then(() => {
keys.forEach(key => this.inputs[key].loading(false));
m.redraw();
});
}
toggleMethod(method) {
var keys = this.props.types.map(type => this.key(type.name, method));
this.toggle(keys);
}
toggleType(type) {
var keys = this.methods.map(method => this.key(type, method.name));
this.toggle(keys);
}
}

View File

@ -0,0 +1,20 @@
import Component from 'flarum/component';
export default class Notification extends Component {
view() {
var notification = this.props.notification;
return m('div.notification', {
classNames: !notification.isRead ? 'unread' : '',
onclick: this.read.bind(this)
}, this.content());
}
content() {
//
}
read() {
this.props.notification.save({isRead: true});
}
}

View File

@ -0,0 +1,133 @@
import Component from 'flarum/component';
import classList from 'flarum/utils/class-list';
import ComposerEdit from 'flarum/components/composer-edit';
import PostHeaderUser from 'flarum/components/post-header-user';
import PostHeaderMeta from 'flarum/components/post-header-meta';
import PostHeaderEdited from 'flarum/components/post-header-edited';
import PostHeaderToggle from 'flarum/components/post-header-toggle';
import ItemList from 'flarum/utils/item-list';
import ActionButton from 'flarum/components/action-button';
import DropdownButton from 'flarum/components/dropdown-button';
import SubtreeRetainer from 'flarum/utils/subtree-retainer';
import listItems from 'flarum/helpers/list-items';
/**
Component for a `comment`-typed post. Displays a number of item lists
(controls, header, and footer) surrounding the post's HTML content. Allows
the post to be edited with the composer, hidden, or restored.
*/
export default class PostComment extends Component {
constructor(props) {
super(props);
this.postHeaderUser = new PostHeaderUser({post: this.props.post});
this.subtree = new SubtreeRetainer(
() => this.props.post.freshness,
() => this.props.post.user().freshness,
this.postHeaderUser.showCard
);
}
view() {
var post = this.props.post;
var classes = {
'is-hidden': post.isHidden(),
'is-edited': post.isEdited(),
'reveal-content': this.revealContent
};
var controls = this.controlItems().toArray();
// @todo Having to wrap children in a div isn't nice
return m('article.post.post-comment', {className: classList(classes)}, this.subtree.retain() || m('div', [
controls.length ? DropdownButton.component({
items: controls,
className: 'contextual-controls',
buttonClass: 'btn btn-default btn-icon btn-sm btn-naked',
menuClass: 'pull-right'
}) : '',
m('header.post-header', m('ul', listItems(this.headerItems().toArray()))),
m('div.post-body', m.trust(post.contentHtml())),
m('aside.post-footer', m('ul', listItems(this.footerItems().toArray()))),
m('aside.post-actions', m('ul', listItems(this.actionItems().toArray())))
]));
}
toggleContent() {
this.revealContent = !this.revealContent;
}
headerItems() {
var items = new ItemList();
var post = this.props.post;
var props = {post};
items.add('user', this.postHeaderUser.view(), {first: true});
items.add('meta', PostHeaderMeta.component(props));
if (post.isEdited() && !post.isHidden()) {
items.add('edited', PostHeaderEdited.component(props));
}
if (post.isHidden()) {
items.add('toggle', PostHeaderToggle.component({toggle: this.toggleContent.bind(this)}));
}
return items;
}
controlItems() {
var items = new ItemList();
var post = this.props.post;
if (post.isHidden()) {
if (post.canEdit()) {
items.add('restore', ActionButton.component({ icon: 'reply', label: 'Restore', onclick: this.restore.bind(this) }));
}
if (post.canDelete()) {
items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete Forever', onclick: this.delete.bind(this) }));
}
} else if (post.canEdit()) {
items.add('edit', ActionButton.component({ icon: 'pencil', label: 'Edit', onclick: this.edit.bind(this) }));
items.add('hide', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.hide.bind(this) }));
}
return items;
}
footerItems() {
return new ItemList();
}
actionItems() {
return new ItemList();
}
edit() {
if (!this.composer || app.composer.component !== this.composer) {
this.composer = new ComposerEdit({ post: this.props.post });
app.composer.load(this.composer);
}
app.composer.show();
}
hide() {
var post = this.props.post;
post.save({ isHidden: true });
post.pushData({ hideTime: new Date(), hideUser: app.session.user() });
}
restore() {
var post = this.props.post;
post.save({ isHidden: false });
post.pushData({ hideTime: null, hideUser: null });
}
delete() {
var post = this.props.post;
post.delete();
this.props.ondelete && this.props.ondelete(post);
}
}

View File

@ -0,0 +1,59 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
import username from 'flarum/helpers/username';
import humanTime from 'flarum/utils/human-time';
import SubtreeRetainer from 'flarum/utils/subtree-retainer';
import ItemList from 'flarum/utils/item-list';
import ActionButton from 'flarum/components/action-button';
import DropdownButton from 'flarum/components/dropdown-button';
export default class PostDiscussionRenamed extends Component {
constructor(props) {
super(props);
this.subtree = new SubtreeRetainer(
() => this.props.post.freshness,
() => this.props.post.user().freshness
);
}
view(ctrl) {
var controls = this.controlItems().toArray();
var post = this.props.post;
var oldTitle = post.content()[0];
var newTitle = post.content()[1];
return m('article.post.post-activity.post-discussion-renamed', this.subtree.retain() || m('div', [
controls.length ? DropdownButton.component({
items: controls,
className: 'contextual-controls',
buttonClass: 'btn btn-default btn-icon btn-sm btn-naked',
menuClass: 'pull-right'
}) : '',
icon('pencil post-icon'),
m('div.post-activity-info', [
m('a.post-user', {href: app.route('user', post.user()), config: m.route}, username(post.user())),
' changed the title from ', m('strong.old-title', oldTitle), ' to ', m('strong.new-title', newTitle), '.'
]),
m('div.post-activity-time', humanTime(post.time()))
]));
}
controlItems() {
var items = new ItemList();
var post = this.props.post;
if (post.canDelete()) {
items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.delete.bind(this) }));
}
return items;
}
delete() {
var post = this.props.post;
post.delete();
this.props.ondelete && this.props.ondelete(post);
}
}

View File

@ -0,0 +1,20 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
import humanTime from 'flarum/utils/human-time';
/**
Component for the edited pencil icon in a post header. Shows a tooltip on
hover which details who edited the post and when.
*/
export default class PostHeaderEdited extends Component {
view() {
var post = this.props.post;
var title = 'Edited '+(post.editUser() ? 'by '+post.editUser().username()+' ' : '')+humanTime(post.editTime());
return m('span.post-edited', {
title: title,
config: (element) => $(element).tooltip()
}, icon('pencil'));
}
}

View File

@ -0,0 +1,42 @@
import Component from 'flarum/component';
import humanTime from 'flarum/helpers/human-time';
import fullTime from 'flarum/helpers/full-time';
/**
Component for the meta part of a post header. Displays the time, and when
clicked, shows a dropdown containing more information about the post
(number, full time, permalink).
*/
export default class PostHeaderMeta extends Component {
view() {
var post = this.props.post;
var discussion = post.discussion();
var params = {
id: discussion.id(),
slug: discussion.slug(),
near: post.number()
};
var permalink = window.location.origin+app.route('discussion.near', params);
var touch = 'ontouchstart' in document.documentElement;
// When the dropdown menu is shown, select the contents of the permalink
// input so that the user can quickly copy the URL.
var selectPermalink = function() {
var input = $(this).parent().find('.permalink');
setTimeout(() => input.select());
m.redraw.strategy('none');
}
return m('span.dropdown',
m('a.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', {onclick: selectPermalink}, humanTime(post.time())),
m('div.dropdown-menu.post-meta', [
m('span.number', 'Post #'+post.number()),
m('span.time', fullTime(post.time())),
touch
? m('a.btn.btn-default.permalink', {href: permalink}, permalink)
: m('input.form-control.permalink', {value: permalink, onclick: (e) => e.stopPropagation()})
])
);
}
}

View File

@ -0,0 +1,13 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
/**
Component for the toggle button in a post header. Toggles the
`parent.revealContent` property when clicked. Only displays if the supplied
post is not hidden.
*/
export default class PostHeaderToggle extends Component {
view() {
return m('a.btn.btn-default.btn-more[href=javascript:;]', {onclick: this.props.toggle}, icon('ellipsis-h'));
}
}

View File

@ -0,0 +1,61 @@
import Component from 'flarum/component';
import UserCard from 'flarum/components/user-card';
import avatar from 'flarum/helpers/avatar';
import username from 'flarum/helpers/username';
import listItems from 'flarum/helpers/list-items';
/**
Component for the username/avatar in a post header.
*/
export default class PostHeaderUser extends Component {
constructor(props) {
super(props);
this.showCard = m.prop(false);
}
view() {
var post = this.props.post;
var user = post.user();
return m('div.post-user', {config: this.onload.bind(this)}, [
m('h3',
user ? [
m('a', {href: app.route('user', user), config: m.route}, [
avatar(user),
username(user)
]),
m('ul.badges', listItems(user.badges().toArray()))
] : [
avatar(),
username()
]
),
this.showCard() ? UserCard.component({user, className: 'user-card-popover fade', controlsButtonClass: 'btn btn-default btn-icon btn-sm btn-naked'}) : ''
]);
}
onload(element, isInitialized) {
if (isInitialized) { return; }
this.element(element);
var component = this;
var timeout;
this.$().bind('mouseover', '> a, .user-card', function() {
clearTimeout(timeout);
timeout = setTimeout(function() {
component.showCard(true);
m.redraw();
setTimeout(() => component.$('.user-card').addClass('in'));
}, 250);
}).bind('mouseout', '> a, .user-card', function() {
clearTimeout(timeout);
timeout = setTimeout(function() {
component.$('.user-card').removeClass('in').one('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function() {
component.showCard(false);
});
}, 250);
});
}
}

View File

@ -0,0 +1,126 @@
import UserPage from 'flarum/components/user-page';
import ItemList from 'flarum/utils/item-list';
import SwitchInput from 'flarum/components/switch-input';
import ActionButton from 'flarum/components/action-button';
import FieldSet from 'flarum/components/field-set';
import NotificationGrid from 'flarum/components/notification-grid';
import listItems from 'flarum/helpers/list-items';
export default class SettingsPage extends UserPage {
/**
*/
constructor(props) {
super(props);
this.user = app.session.user;
}
content() {
return m('div.settings', [
m('ul', listItems(this.settingsItems().toArray()))
]);
}
settingsItems() {
var items = new ItemList();
items.add('account',
FieldSet.component({
label: 'Account',
className: 'settings-account',
fields: this.accountItems().toArray()
})
);
items.add('notifications',
FieldSet.component({
label: 'Notifications',
className: 'settings-account',
fields: [NotificationGrid.component({
types: this.notificationTypes().toArray(),
user: this.user()
})]
})
);
items.add('privacy',
FieldSet.component({
label: 'Privacy',
fields: this.privacyItems().toArray()
})
);
return items;
}
accountItems() {
var items = new ItemList();
items.add('changePassword',
ActionButton.component({
label: 'Change Password',
className: 'btn btn-default'
})
);
items.add('changeEmail',
ActionButton.component({
label: 'Change Email',
className: 'btn btn-default'
})
);
items.add('deleteAccount',
ActionButton.component({
label: 'Delete Account',
className: 'btn btn-default btn-danger'
})
);
return items;
}
save(key) {
return (value, control) => {
var preferences = this.user().preferences();
preferences[key] = value;
control.loading(true);
m.redraw();
this.user().save({preferences}).then(() => {
control.loading(false);
m.redraw();
});
};
}
privacyItems() {
var items = new ItemList();
items.add('discloseOnline',
SwitchInput.component({
label: 'Allow others to see when I am online',
state: this.user().preferences().discloseOnline,
onchange: (value, component) => {
this.user().pushData({lastSeenTime: null});
this.save('discloseOnline')(value, component);
}
})
);
return items;
}
notificationTypes() {
var items = new ItemList();
items.add('discussionRenamed', {
name: 'discussionRenamed',
label: 'Someone renames a discussion I started'
});
return items;
}
}

View File

@ -0,0 +1,88 @@
import Component from 'flarum/component';
import LoadingIndicator from 'flarum/components/loading-indicator';
import icon from 'flarum/helpers/icon';
import avatar from 'flarum/helpers/avatar';
export default class SignupModal extends Component {
constructor(props) {
super(props);
this.username = m.prop();
this.email = m.prop();
this.password = m.prop();
this.welcomeUser = m.prop();
this.loading = m.prop(false);
}
view() {
var welcomeUser = this.welcomeUser();
var emailProviderName = welcomeUser && welcomeUser.email().split('@')[1];
return m('div.modal-dialog.modal-sm.modal-signup', [
m('div.modal-content', [
m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')),
m('form', {onsubmit: this.signup.bind(this)}, [
m('div.modal-header', m('h3.title-control', 'Sign Up')),
m('div.modal-body', [
m('div.form-centered', [
m('div.form-group', [
m('input.form-control[name=username][placeholder=Username]', {onchange: m.withAttr('value', this.username)})
]),
m('div.form-group', [
m('input.form-control[name=email][placeholder=Email]', {onchange: m.withAttr('value', this.email)})
]),
m('div.form-group', [
m('input.form-control[type=password][name=password][placeholder=Password]', {onchange: m.withAttr('value', this.password)})
]),
m('div.form-group', [
m('button.btn.btn-primary.btn-block[type=submit]', 'Sign Up')
])
])
]),
m('div.modal-footer', [
m('p.log-in-link', ['Already have an account? ', m('a[href=javascript:;]', {onclick: app.login}, 'Log In')])
])
])
]),
LoadingIndicator.component({className: 'modal-loading'+(this.loading() ? ' active' : '')}),
welcomeUser ? m('div.signup-welcome', {style: 'background: '+this.welcomeUser().color(), config: this.fadeIn}, [
avatar(welcomeUser),
m('h3', 'Welcome, '+welcomeUser.username()+'!'),
!welcomeUser.isConfirmed()
? [
m('p', ['We\'ve sent a confirmation email to ', m('strong', welcomeUser.email()), '. If it doesn\'t arrive soon, check your spam folder.']),
m('p', m('a.btn.btn-default', {href: 'http://'+emailProviderName}, 'Go to '+emailProviderName))
]
: ''
]) : ''
])
}
fadeIn(element, isInitialized) {
if (isInitialized) { return; }
$(element).hide().fadeIn();
}
ready($modal) {
$modal.find('[name=username]').focus();
}
signup(e) {
e.preventDefault();
this.loading(true);
var self = this;
app.store.createRecord('users').save({
username: this.username(),
email: this.email(),
password: this.password()
}).then(user => {
this.welcomeUser(user);
this.loading(false);
m.redraw();
}, response => {
this.loading(false);
m.redraw();
});
}
}

View File

@ -0,0 +1,343 @@
import Component from 'flarum/component';
import StreamItem from 'flarum/components/stream-item';
import LoadingIndicator from 'flarum/components/loading-indicator';
import ScrollListener from 'flarum/utils/scroll-listener';
import mixin from 'flarum/utils/mixin';
import evented from 'flarum/utils/evented';
/**
*/
export default class StreamContent extends mixin(Component, evented) {
/**
*/
constructor(props) {
super(props);
this.loaded = () => this.props.stream.loadedCount();
this.paused = m.prop(false);
this.active = () => this.loaded() && !this.paused();
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
this.on('loadingIndex', this.loadingIndex.bind(this));
this.on('loadedIndex', this.loadedIndex.bind(this));
this.on('loadingNumber', this.loadingNumber.bind(this));
this.on('loadedNumber', this.loadedNumber.bind(this));
}
/**
*/
view() {
var stream = this.props.stream;
return m('div', {className: 'stream '+(this.props.className || ''), config: this.onload.bind(this)},
stream ? stream.content.map(item => StreamItem.component({
key: item.start+'-'+item.end,
item: item,
loadRange: stream.loadRange.bind(stream),
ondelete: this.ondelete.bind(this)
}))
: LoadingIndicator.component());
}
/**
*/
onload(element, isInitialized, context) {
this.element(element);
if (isInitialized) { return; }
context.onunload = this.ondestroy.bind(this);
this.scrollListener.start();
}
ondelete(post) {
this.props.stream.removePost(post);
}
/**
*/
ondestroy() {
this.scrollListener.stop();
clearTimeout(this.positionChangedTimeout);
}
/**
*/
onscroll(top) {
if (!this.active()) { return; }
var $items = this.$('.item');
var marginTop = this.getMarginTop();
var $window = $(window);
var viewportHeight = $window.height() - marginTop;
var scrollTop = top + marginTop;
var loadAheadDistance = 300;
var startNumber;
var endNumber;
// Loop through each of the items in the stream. An 'item' is either a
// single post or a 'gap' of one or more posts that haven't been loaded
// yet.
$items.each(function() {
var $this = $(this);
var top = $this.offset().top;
var height = $this.outerHeight();
// If this item is above the top of the viewport (plus a bit of leeway
// for loading-ahead gaps), skip to the next one. If it's below the
// bottom of the viewport, break out of the loop.
if (top + height < scrollTop - loadAheadDistance) { return; }
if (top > scrollTop + viewportHeight + loadAheadDistance) { return false; }
// If this item is a gap, then we may proceed to check if it's a
// *terminal* gap and trigger its loading mechanism.
if ($this.hasClass('gap')) {
var first = $this.is(':first-child');
var last = $this.is(':last-child');
var item = $this[0].instance.props.item;
if ((first || last) && !item.loading) {
item.direction = first ? 'up' : 'down';
$this[0].instance.load();
}
} else {
if (top + height < scrollTop + viewportHeight) {
endNumber = $this.data('number');
}
// Check if this item is in the viewport, minus the distance we allow
// for load-ahead gaps. If we haven't yet stored a post's number, then
// this item must be the FIRST item in the viewport. Therefore, we'll
// grab its post number so we can update the controller's state later.
if (top + height > scrollTop && !startNumber) {
startNumber = $this.data('number');
}
}
});
// Finally, we want to update the controller's state with regards to the
// current viewing position of the discussion. However, we don't want to
// do this on every single scroll event as it will slow things down. So,
// let's do it at a minimum of 250ms by clearing and setting a timeout.
clearTimeout(this.positionChangedTimeout);
this.positionChangedTimeout = setTimeout(() => this.props.positionChanged(startNumber || 1, endNumber), 500);
}
/**
Get the distance from the top of the viewport to the point at which we
would consider a post to be the first one visible.
*/
getMarginTop() {
return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top'));
}
/**
Scroll down to a certain post by number (or the gap which we think the
post is in) and highlight it.
*/
scrollToNumber(number, noAnimation) {
// Clear the highlight class from all posts, and attempt to find and
// highlight a post with the specified number. However, we don't apply
// the highlight to the first post in the stream because it's pretty
// obvious that it's the top one.
var $item = this.$('.item').removeClass('highlight').filter('[data-number='+number+']');
if (!$item.is(':first-child')) {
$item.addClass('highlight');
}
// If we didn't have any luck, then a post with this number either
// doesn't exist, or it hasn't been loaded yet. We'll find the item
// that's closest to the post with this number and scroll to that
// instead.
if (!$item.length) {
$item = this.findNearestToNumber(number);
}
return this.scrollToItem($item, noAnimation);
}
/**
Scroll down to a certain post by index (or the gap the post is in.)
*/
scrollToIndex(index, noAnimation) {
var $item = this.findNearestToIndex(index);
return this.scrollToItem($item, noAnimation);
}
/**
*/
scrollToItem($item, noAnimation) {
var $container = $('html, body').stop(true);
if ($item.length) {
var scrollTop = $item.is(':first-child') ? 0 : $item.offset().top - this.getMarginTop();
if (noAnimation) {
$container.scrollTop(scrollTop);
} else if (scrollTop !== $(document).scrollTop()) {
$container.animate({scrollTop: scrollTop}, 'fast');
}
}
return $container.promise();
}
/**
Find the DOM element of the item that is nearest to a post with a certain
number. This will either be another post (if the requested post doesn't
exist,) or a gap presumed to contain the requested post.
*/
findNearestToNumber(number) {
var $nearestItem = $();
this.$('.item').each(function() {
var $this = $(this);
if ($this.data('number') > number) {
return false;
}
$nearestItem = $this;
});
return $nearestItem;
}
/**
*/
findNearestToIndex(index) {
var $nearestItem = this.$('.item[data-start='+index+'][data-end='+index+']');
if (!$nearestItem.length) {
this.$('.item').each(function() {
$nearestItem = $(this);
if ($nearestItem.data('end') >= index) {
return false;
}
});
}
return $nearestItem;
}
/**
*/
loadingIndex(index, noAnimation) {
// The post at this index is being loaded. We want to scroll to where we
// think it will appear. We may be scrolling to the edge of the page,
// but we don't want to trigger any terminal post gaps to load by doing
// that. So, we'll disable the window's scroll handler for now.
this.paused(true);
this.scrollToIndex(index, noAnimation);
}
/**
*/
loadedIndex(index, noAnimation) {
m.redraw(true);
// The post at this index has been loaded. After we scroll to this post,
// we want to resume scroll events.
this.scrollToIndex(index, noAnimation).done(this.unpause.bind(this));
}
/**
*/
loadingNumber(number, noAnimation) {
// The post with this number is being loaded. We want to scroll to where
// we think it will appear. We may be scrolling to the edge of the page,
// but we don't want to trigger any terminal post gaps to load by doing
// that. So, we'll disable the window's scroll handler for now.
this.paused(true);
if (this.$()) {
this.scrollToNumber(number, noAnimation);
}
}
/**
*/
loadedNumber(number, noAnimation) {
m.redraw(true);
// The post with this number has been loaded. After we scroll to this
// post, we want to resume scroll events.
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
}
/**
*/
unpause() {
this.paused(false);
this.scrollListener.update(true);
this.trigger('unpaused');
}
/**
*/
goToNumber(number, noAnimation) {
number = Math.max(number, 1);
// Let's start by telling our listeners that we're going to load
// posts near this number. Elsewhere we will listen and
// consequently scroll down to the appropriate position.
this.trigger('loadingNumber', number, noAnimation);
// Now we have to actually make sure the posts around this new start
// position are loaded. We will tell our listeners when they are.
// Again, a listener will scroll down to the appropriate post.
var promise = this.props.stream.loadNearNumber(number);
m.redraw();
return promise.then(() => this.trigger('loadedNumber', number, noAnimation));
}
/**
*/
goToIndex(index, backwards, noAnimation) {
// Let's start by telling our listeners that we're going to load
// posts at this index. Elsewhere we will listen and consequently
// scroll down to the appropriate position.
this.trigger('loadingIndex', index, noAnimation);
// Now we have to actually make sure the posts around this index
// are loaded. We will tell our listeners when they are. Again, a
// listener will scroll down to the appropriate post.
var promise = this.props.stream.loadNearIndex(index, backwards);
m.redraw();
return promise.then(() => this.trigger('loadedIndex', index, noAnimation));
}
/**
*/
goToFirst() {
return this.goToIndex(0);
}
/**
*/
goToLast() {
var promise = this.goToIndex(this.props.stream.count() - 1, true);
// If the post stream is loading some new posts, then after it's
// done we'll want to immediately scroll down to the bottom of the
// page.
var items = this.props.stream.content;
if (!items[items.length - 1].post) {
promise.then(() => $('html, body').stop(true).scrollTop($('body').height()));
}
return promise;
}
}

View File

@ -0,0 +1,112 @@
import Component from 'flarum/component';
import classList from 'flarum/utils/class-list';
import LoadingIndicator from 'flarum/components/loading-indicator';
export default class StreamItem extends Component {
/**
*/
constructor(props) {
super(props);
this.element = m.prop();
}
/**
*/
view() {
var component = this;
var item = this.props.item;
var gap = !item.post;
var direction = item.direction;
var loading = item.loading;
var count = item.end - item.start + 1;
var classes = { item: true, gap, loading, direction };
var attributes = {
className: classList(classes),
config: this.element,
'data-start': item.start,
'data-end': item.end
};
if (!gap) {
attributes['data-time'] = item.post.time();
attributes['data-number'] = item.post.number();
} else {
attributes['config'] = (element) => {
this.element(element);
element.instance = this;
};
attributes['onclick'] = this.load.bind(this);
attributes['onmouseenter'] = function(e) {
if (!item.loading) {
var $this = $(this);
var up = e.clientY > $this.offset().top - $(document).scrollTop() + $this.outerHeight(true) / 2;
$this.removeClass('up down').addClass(item.direction = up ? 'up' : 'down');
}
m.redraw.strategy('none');
};
}
var content;
if (gap) {
content = m('span', loading ? LoadingIndicator.component() : count+' more post'+(count !== 1 ? 's' : ''));
} else {
var PostComponent = app.postComponentRegistry[item.post.contentType()];
if (PostComponent) {
content = PostComponent.component({post: item.post, ondelete: this.props.ondelete});
}
}
return m('div', attributes, content);
}
/**
*/
load() {
var item = this.props.item;
// If this item is not a gap, or if we're already loading its posts,
// then we don't need to do anything.
if (item.post || item.loading) {
return false;
}
// If new posts are being loaded in an upwards direction, then when
// they are rendered, the rest of the posts will be pushed down the
// page. If loaded in a downwards direction from the end of a
// discussion, the terminal gap will disappear and the page will
// scroll up a bit before the new posts are rendered. In order to
// maintain the current scroll position relative to the content
// before/after the gap, we need to find item directly after the gap
// and use it as an anchor.
var siblingFunc = item.direction === 'up' ? 'nextAll' : 'prevAll';
var anchor = this.$()[siblingFunc]('.item:first');
// Tell the controller that we want to load the range of posts that this
// gap represents. We also specify which direction we want to load the
// posts from.
this.props.loadRange(item.start, item.end, item.direction === 'up').then(function() {
// Immediately after the posts have been loaded (but before they
// have been rendered,) we want to grab the distance from the top of
// the viewport to the top of the anchor element.
if (anchor.length) {
var scrollOffset = anchor.offset().top - $(document).scrollTop();
}
m.redraw(true);
// After they have been rendered, we scroll back to a position
// so that the distance from the top of the viewport to the top
// of the anchor element is the same as before. If there is no
// anchor (i.e. this gap is terminal,) then we'll scroll to the
// bottom of the document.
$('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
});
m.redraw();
}
}

View File

@ -0,0 +1,430 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
import ScrollListener from 'flarum/utils/scroll-listener';
import SubtreeRetainer from 'flarum/utils/subtree-retainer';
import computed from 'flarum/utils/computed';
/**
*/
export default class StreamScrubber extends Component {
/**
*/
constructor(props) {
super(props);
var streamContent = this.props.streamContent;
this.handlers = {};
// When the stream-content component begins loading posts at a certain
// index, we want our scrubber scrollbar to jump to that position.
streamContent.on('loadingIndex', this.handlers.loadingIndex = this.loadingIndex.bind(this));
streamContent.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this));
/**
Disable the scrubber if the stream's initial content isn't loaded, or
if all of the posts in the discussion are visible in the viewport.
*/
this.disabled = () => !streamContent.loaded() || this.visible() >= this.count();
/**
The integer index of the last item that is visible in the viewport. This
is display on the scrubber (i.e. X of 100 posts).
*/
this.visibleIndex = computed('index', 'visible', 'count', function(index, visible, count) {
return Math.min(count, Math.ceil(Math.max(0, index) + visible));
});
this.count = () => this.props.streamContent.props.stream.count();
this.index = m.prop(-1);
this.visible = m.prop(1);
this.description = m.prop();
// Define a handler to update the state of the scrollbar to reflect the
// current scroll position of the page.
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
this.subtree = new SubtreeRetainer(() => true);
}
unpaused() {
this.update(window.pageYOffset);
this.renderScrollbar(true);
}
/**
*/
view() {
var retain = this.subtree.retain();
var streamContent = this.props.streamContent;
return m('div.stream-scrubber.dropdown'+(this.disabled() ? '.disabled' : ''), {config: this.onload.bind(this)}, [
m('a.btn.btn-default.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', [
m('span.index', retain || this.visibleIndex()), ' of ', m('span.count', this.count()), ' posts ',
icon('sort icon-glyph')
]),
m('div.dropdown-menu', [
m('div.scrubber', [
m('a.scrubber-first[href=javascript:;]', {onclick: streamContent.goToFirst.bind(streamContent)}, [icon('angle-double-up'), ' Original Post']),
m('div.scrubber-scrollbar', [
m('div.scrubber-before'),
m('div.scrubber-slider', [
m('div.scrubber-handle'),
m('div.scrubber-info', [
m('strong', [m('span.index', retain || this.visibleIndex()), ' of ', m('span.count', this.count()), ' posts']),
m('span.description', retain || this.description())
])
]),
m('div.scrubber-after')
]),
m('a.scrubber-last[href=javascript:;]', {onclick: streamContent.goToLast.bind(streamContent)}, [icon('angle-double-down'), ' Now'])
])
])
])
}
onscroll(top) {
var streamContent = this.props.streamContent;
if (!streamContent.active() || !streamContent.$()) { return; }
this.update(top);
this.renderScrollbar();
}
/**
Update the index/visible/description properties according to the window's
current scroll position.
*/
update(top) {
var streamContent = this.props.streamContent;
var $window = $(window);
var marginTop = streamContent.getMarginTop();
var scrollTop = $window.scrollTop() + marginTop;
var windowHeight = $window.height() - marginTop;
// Before looping through all of the posts, we reset the scrollbar
// properties to a 'default' state. These values reflect what would be
// seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0.
var $items = streamContent.$('.item');
var index = $items.first().data('end') - 1;
var visible = 0;
var period = '';
// Now loop through each of the items in the discussion. An 'item' is
// either a single post or a 'gap' of one or more posts that haven't
// been loaded yet.
$items.each(function() {
var $this = $(this);
var top = $this.offset().top;
var height = $this.outerHeight(true);
// If this item is above the top of the viewport, skip to the next
// post. If it's below the bottom of the viewport, break out of the
// loop.
if (top + height < scrollTop) {
visible = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visible;
return;
}
if (top > scrollTop + windowHeight) {
return false;
}
// If the bottom half of this item is visible at the top of the
// viewport, then add the visible proportion to the visible
// counter, and set the scrollbar index to whatever the visible
// proportion represents. For example, if a gap represents indexes
// 0-9, and the bottom 50% of the gap is visible in the viewport,
// then the scrollbar index will be 5.
if (top <= scrollTop && top + height > scrollTop) {
visible = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visible;
}
// If the top half of this item is visible at the bottom of the
// viewport, then add the visible proportion to the visible
// counter.
else if (top + height >= scrollTop + windowHeight) {
visible += (scrollTop + windowHeight - top) / height;
}
// If the whole item is visible in the viewport, then increment the
// visible counter.
else {
visible++;
}
// If this item has a time associated with it, then set the
// scrollbar's current period to a formatted version of this time.
if ($this.data('time')) {
period = $this.data('time');
}
});
this.index(index);
this.visible(visible);
this.description(period ? moment(period).format('MMMM YYYY') : '');
}
/**
*/
onload(element, isInitialized, context) {
this.element(element);
if (isInitialized) { return; }
this.renderScrollbar();
context.onunload = this.ondestroy.bind(this);
this.scrollListener.start();
// Whenever the window is resized, adjust the height of the scrollbar
// so that it fills the height of the sidebar.
$(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
var self = this;
// When any part of the whole scrollbar is clicked, we want to jump to
// that position.
this.$('.scrubber-scrollbar')
.bind('click touchstart', function(e) {
if (!self.props.streamContent.active()) { return; }
// Calculate the index which we want to jump to based on the
// click position.
// 1. Get the offset of the click from the top of the
// scrollbar, as a percentage of the scrollbar's height.
var $this = $(this);
var offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $this.offset().top + $('body').scrollTop();
var offsetPercent = offsetPixels / $this.outerHeight() * 100;
// 2. We want the handle of the scrollbar to end up centered
// on the click position. Thus, we calculate the height of
// the handle in percent and use that to find a new
// offset percentage.
offsetPercent = offsetPercent - parseFloat($this.find('.scrubber-slider')[0].style.height) / 2;
// 3. Now we can convert the percentage into an index, and
// tell the stream-content component to jump to that index.
var offsetIndex = offsetPercent / self.percentPerPost().index;
offsetIndex = Math.max(0, Math.min(self.count() - 1, offsetIndex));
self.props.streamContent.goToIndex(Math.floor(offsetIndex));
self.$().removeClass('open');
});
// Now we want to make the scrollbar handle draggable. Let's start by
// preventing default browser events from messing things up.
this.$('.scrubber-scrollbar')
.css({
cursor: 'pointer',
'user-select': 'none'
})
.bind('dragstart mousedown touchstart', function(e) {
e.preventDefault();
});
// When the mouse is pressed on the scrollbar handle, we capture some
// information about its current position. We will store this
// information in an object and pass it on to the document's
// mousemove/mouseup events later.
this.mouseStart = 0;
this.indexStart = 0;
this.handle = null;
this.$('.scrubber-slider')
.css('cursor', 'move')
.bind('mousedown touchstart', function(e) {
self.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
self.indexStart = self.index();
self.handle = $(this);
self.props.streamContent.paused(true);
$('body').css('cursor', 'move');
})
// Exempt the scrollbar handle from the 'jump to' click event.
.click(function(e) {
e.stopPropagation();
});
// When the mouse moves and when it is released, we pass the
// information that we captured when the mouse was first pressed onto
// some event handlers. These handlers will move the scrollbar/stream-
// content as appropriate.
$(document)
.on('mousemove touchmove', this.handlers.onmousemove = this.onmousemove.bind(this))
.on('mouseup touchend', this.handlers.onmouseup = this.onmouseup.bind(this));
}
ondestroy() {
this.scrollListener.stop();
this.props.streamContent.off('loadingIndex', this.handlers.loadingIndex);
this.props.streamContent.off('unpaused', this.handlers.unpaused);
$(window)
.off('resize', this.handlers.onresize);
$(document)
.off('mousemove touchmove', this.handlers.onmousemove)
.off('mouseup touchend', this.handlers.onmouseup);
}
/**
Update the scrollbar's position to reflect the current values of the
index/visible properties.
*/
renderScrollbar(animate) {
var percentPerPost = this.percentPerPost();
var index = this.index();
var count = this.count();
var visible = this.visible();
var $scrubber = this.$();
$scrubber.find('.index').text(this.visibleIndex());
// $scrubber.find('.count').text(count);
$scrubber.find('.description').text(this.description());
$scrubber.toggleClass('disabled', this.disabled());
var heights = {};
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
heights.slider = Math.min(100 - heights.before, percentPerPost.visible * visible);
heights.after = 100 - heights.before - heights.slider;
var func = animate ? 'animate' : 'css';
for (var part in heights) {
var $part = $scrubber.find('.scrubber-'+part);
$part.stop(true, true)[func]({height: heights[part]+'%'}, 'fast');
// jQuery likes to put overflow:hidden, but because the scrollbar handle
// has a negative margin-left, we need to override.
if (func === 'animate') {
$part.css('overflow', 'visible');
}
}
}
/**
*/
percentPerPost() {
var count = this.count() || 1;
var visible = this.visible();
// To stop the slider of the scrollbar from getting too small when there
// are many posts, we define a minimum percentage height for the slider
// calculated from a 50 pixel limit. From this, we can calculate the
// minimum percentage per visible post. If this is greater than the actual
// percentage per post, then we need to adjust the 'before' percentage to
// account for it.
var minPercentVisible = 50 / this.$('.scrubber-scrollbar').outerHeight() * 100;
var percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
var percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
return {
index: percentPerPost,
visible: percentPerVisiblePost
};
}
/*
When the stream-content component begins loading posts at a certain
index, we want our scrubber scrollbar to jump to that position.
*/
loadingIndex(index) {
this.index(index);
this.renderScrollbar(true);
}
onresize(event) {
this.scrollListener.update(true);
// Adjust the height of the scrollbar so that it fills the height of
// the sidebar and doesn't overlap the footer.
var scrollbar = this.$('.scrubber-scrollbar');
scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - parseInt($('.global-page').css('padding-bottom')));
}
onmousemove(event) {
if (! this.handle) { return; }
// Work out how much the mouse has moved by - first in pixels, then
// convert it to a percentage of the scrollbar's height, and then
// finally convert it into an index. Add this delta index onto
// the index at which the drag was started, and then scroll there.
var deltaPixels = (event.clientY || event.originalEvent.touches[0].clientY) - this.mouseStart;
var deltaPercent = deltaPixels / this.$('.scrubber-scrollbar').outerHeight() * 100;
var deltaIndex = deltaPercent / this.percentPerPost().index;
var newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
this.index(Math.max(0, newIndex));
this.renderScrollbar();
if (! this.$().is('.open')) {
this.scrollToIndex(newIndex);
}
}
onmouseup(event) {
if (!this.handle) { return; }
this.mouseStart = 0;
this.indexStart = 0;
this.handle = null;
$('body').css('cursor', '');
if (this.$().is('.open')) {
this.scrollToIndex(this.index());
this.$().removeClass('open');
}
// If the index we've landed on is in a gap, then tell the stream-
// content that we want to load those posts.
var intIndex = Math.floor(this.index());
if (!this.props.streamContent.props.stream.findNearestToIndex(intIndex).content) {
this.props.streamContent.goToIndex(intIndex);
} else {
this.props.streamContent.paused(false);
}
}
/**
Instantly scroll to a certain index in the discussion. The index doesn't
have to be an integer; any fraction of a post will be scrolled to.
*/
scrollToIndex(index) {
var streamContent = this.props.streamContent;
index = Math.min(index, this.count() - 1);
// Find the item for this index, whether it's a post corresponding to
// the index, or a gap which the index is within.
var indexFloor = Math.max(0, Math.floor(index));
var $nearestItem = streamContent.findNearestToIndex(indexFloor);
// Calculate the position of this item so that we can scroll to it. If
// the item is a gap, then we will mark it as 'active' to indicate to
// the user that it will expand if they release their mouse.
// Otherwise, we will add a proportion of the item's height onto the
// scroll position.
var pos = $nearestItem.offset().top - streamContent.getMarginTop();
if ($nearestItem.is('.gap')) {
$nearestItem.addClass('active');
} else {
if (index >= 0) {
pos += $nearestItem.outerHeight(true) * (index - indexFloor);
} else {
pos += $nearestItem.offset().top * index;
}
}
// Remove the 'active' class from other gaps.
streamContent.$().find('.gap').not($nearestItem).removeClass('active');
$('html, body').scrollTop(pos);
}
}

View File

@ -0,0 +1,24 @@
import Component from 'flarum/component';
import humanTime from 'flarum/utils/human-time';
/**
Displays information about a the first or last post in a discussion.
@prop discussion {Discussion} The discussion to display the post for
@prop lastPost {Boolean} Whether or not to display the last/start post
@class TerminalPost
@constructor
@extends Component
*/
export default class TerminalPost extends Component {
view() {
var discussion = this.props.discussion;
var lastPost = this.props.lastPost && discussion.repliesCount();
return m('li', [
m('span.username', discussion[lastPost ? 'lastUser' : 'startUser']().username()),
lastPost ? ' replied ' : ' started ',
m('time', humanTime(discussion[lastPost ? 'lastTime' : 'startTime']()))
])
}
}

View File

@ -0,0 +1,64 @@
import Component from 'flarum/component';
import humanTime from 'flarum/utils/human-time';
import ItemList from 'flarum/utils/item-list';
import classList from 'flarum/utils/class-list';
import avatar from 'flarum/helpers/avatar';
import username from 'flarum/helpers/username';
import icon from 'flarum/helpers/icon';
import DropdownButton from 'flarum/components/dropdown-button';
import ActionButton from 'flarum/components/action-button';
import listItems from 'flarum/helpers/list-items';
export default class UserBio extends Component {
constructor(props) {
super(props);
this.editing = m.prop(false);
}
view() {
var user = this.props.user;
return m('div.user-bio', {
className: classList({editable: this.isEditable(), editing: this.editing()}),
onclick: this.edit.bind(this),
config: this.element
}, [
this.editing()
? m('textarea.form-control', {value: user.bio()})
: m('div.bio-content', [
user.bioHtml()
? m.trust(user.bioHtml())
: (this.props.editable ? m('p', 'Write something about yourself...') : '')
])
]);
}
isEditable() {
return this.props.user.canEdit() && this.props.editable;
}
edit() {
if (!this.isEditable()) { return; }
this.editing(true);
var height = this.$().height();
m.redraw();
var self = this;
var save = function(e) {
if (e.shiftKey) { return; }
e.preventDefault();
self.save($(this).val());
};
this.$('textarea').css('height', height).focus().bind('blur', save).bind('keydown', 'return', save);
}
save(value) {
this.editing(false);
this.props.user.save({bio: value}).then(() => m.redraw());
m.redraw();
}
}

View File

@ -0,0 +1,76 @@
import Component from 'flarum/component';
import humanTime from 'flarum/utils/human-time';
import ItemList from 'flarum/utils/item-list';
import avatar from 'flarum/helpers/avatar';
import username from 'flarum/helpers/username';
import icon from 'flarum/helpers/icon';
import DropdownButton from 'flarum/components/dropdown-button';
import ActionButton from 'flarum/components/action-button';
import UserBio from 'flarum/components/user-bio';
import AvatarEditor from 'flarum/components/avatar-editor';
import listItems from 'flarum/helpers/list-items';
export default class UserCard extends Component {
view() {
var user = this.props.user;
var controls = this.controlItems().toArray();
return m('div.user-card', {className: this.props.className, style: 'background-color: '+user.color()}, [
m('div.darken-overlay'),
m('div.container', [
controls.length ? DropdownButton.component({
items: controls,
className: 'contextual-controls',
menuClass: 'pull-right',
buttonClass: this.props.controlsButtonClass
}) : '',
m('div.user-profile', [
m('h2.user-identity', this.props.editable
? [AvatarEditor.component({user, className: 'user-avatar'}), username(user)]
: m('a', {href: app.route('user', user), config: m.route}, [
avatar(user, {className: 'user-avatar'}),
username(user)
])
),
m('ul.user-badges.badges', listItems(user.badges().toArray())),
m('ul.user-info', listItems(this.infoItems().toArray()))
])
])
]);
}
controlItems() {
var items = new ItemList();
items.add('edit', ActionButton.component({ icon: 'pencil', label: 'Edit' }));
items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete' }));
return items;
}
infoItems() {
var items = new ItemList();
var user = this.props.user;
var online = user.online();
items.add('bio',
UserBio.component({
user,
editable: this.props.editable,
wrapperClass: 'block-item'
})
);
if (user.lastSeenTime()) {
items.add('lastSeen',
m('span.user-last-seen', {className: online ? 'online' : ''}, online
? [icon('circle'), ' Online']
: [icon('clock-o'), ' ', humanTime(user.lastSeenTime())])
);
}
items.add('joined', ['Joined ', humanTime(user.joinTime())]);
return items;
}
}

View File

@ -0,0 +1,65 @@
import Component from 'flarum/component';
import avatar from 'flarum/helpers/avatar';
import username from 'flarum/helpers/username';
import DropdownButton from 'flarum/components/dropdown-button';
import ActionButton from 'flarum/components/action-button';
import ItemList from 'flarum/utils/item-list';
import Separator from 'flarum/components/separator';
export default class UserDropdown extends Component {
view() {
var user = this.props.user;
return DropdownButton.component({
buttonClass: 'btn btn-default btn-naked btn-rounded btn-user',
menuClass: 'pull-right',
buttonContent: [avatar(user), ' ', m('span.label', username(user))],
items: this.items().toArray()
});
}
items() {
var items = new ItemList();
var user = this.props.user;
items.add('profile',
ActionButton.component({
icon: 'user',
label: 'Profile',
href: app.route('user', user),
config: m.route
})
);
items.add('settings',
ActionButton.component({
icon: 'cog',
label: 'Settings',
href: app.route('settings'),
config: m.route
})
);
if (user.groups().some((group) => group.id() == 1)) {
items.add('administration',
ActionButton.component({
icon: 'wrench',
label: 'Administration',
href: app.config.baseURL+'/admin'
})
);
}
items.add('separator', Separator.component());
items.add('logOut',
ActionButton.component({
icon: 'sign-out',
label: 'Log Out',
onclick: app.session.logout.bind(app.session)
})
);
return items;
}
}

View File

@ -0,0 +1,72 @@
import Component from 'flarum/component';
import avatar from 'flarum/helpers/avatar';
import icon from 'flarum/helpers/icon';
import username from 'flarum/helpers/username';
import DropdownButton from 'flarum/components/dropdown-button';
import ActionButton from 'flarum/components/action-button';
import ItemList from 'flarum/utils/item-list';
import Separator from 'flarum/components/separator';
import LoadingIndicator from 'flarum/components/loading-indicator';
export default class UserNotifications extends Component {
constructor(props) {
super(props);
this.loading = m.prop(false);
}
view() {
var user = this.props.user;
return DropdownButton.component({
className: 'notifications'+(user.unreadNotificationsCount() ? ' unread' : ''),
buttonClass: 'btn btn-default btn-rounded btn-naked btn-icon',
menuClass: 'pull-right',
buttonContent: [
m('span.notifications-icon', user.unreadNotificationsCount() || icon('bell icon-glyph')),
m('span.label', 'Notifications')
],
buttonClick: this.load.bind(this),
menuContent: [
m('div.notifications-header', [
ActionButton.component({
className: 'btn btn-icon btn-link btn-sm',
icon: 'check',
title: 'Mark All as Read',
onclick: this.markAllAsRead.bind(this)
}),
m('h4', 'Notifications')
]),
m('ul.notifications-list', app.cache.notifications
? app.cache.notifications.map(notification => {
var NotificationComponent = app.notificationComponentRegistry[notification.contentType()];
return NotificationComponent ? m('li', NotificationComponent.component({notification})) : '';
})
: (!this.loading() ? m('li.no-notifications', 'No Notifications') : '')),
this.loading() ? LoadingIndicator.component() : ''
]
});
}
load() {
if (!app.cache.notifications) {
var component = this;
this.loading(true);
m.redraw();
app.store.find('notifications').then(notifications => {
this.props.user.pushData({unreadNotificationsCount: 0});
this.loading(false);
app.cache.notifications = notifications;
m.redraw();
})
}
}
markAllAsRead() {
app.cache.notifications.forEach(function(notification) {
if (!notification.isRead()) {
notification.save({isRead: true});
}
})
}
}

View File

@ -0,0 +1,147 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import IndexPage from 'flarum/components/index-page';
import DiscussionList from 'flarum/components/discussion-list';
import StreamContent from 'flarum/components/stream-content';
import StreamScrubber from 'flarum/components/stream-scrubber';
import UserCard from 'flarum/components/user-card';
import ComposerReply from 'flarum/components/composer-reply';
import ActionButton from 'flarum/components/action-button';
import LoadingIndicator from 'flarum/components/loading-indicator';
import DropdownSplit from 'flarum/components/dropdown-split';
import DropdownSelect from 'flarum/components/dropdown-select';
import NavItem from 'flarum/components/nav-item';
import Separator from 'flarum/components/separator';
import listItems from 'flarum/helpers/list-items';
export default class UserPage extends Component {
/**
*/
constructor(props) {
super(props);
app.history.push('user');
app.current = this;
}
/*
*/
setupUser(user) {
this.user(user);
}
onload(element, isInitialized, context) {
if (isInitialized) { return; }
$('body').addClass('user-page');
context.onunload = function() {
$('body').removeClass('user-page');
}
}
/**
*/
view() {
var user = this.user();
return m('div', {config: this.onload.bind(this)}, user ? [
UserCard.component({user, className: 'hero user-hero', editable: true, controlsButtonClass: 'btn btn-default'}),
m('div.container', [
m('nav.side-nav.user-nav', {config: this.affixSidebar}, [
m('ul', listItems(this.sidebarItems().toArray()))
]),
m('div.offset-content.user-content', this.content())
])
] : LoadingIndicator.component({className: 'loading-indicator-block'}));
}
/**
*/
sidebarItems() {
var items = new ItemList();
items.add('nav',
DropdownSelect.component({
items: this.navItems().toArray(),
wrapperClass: 'title-control'
})
);
return items;
}
/**
Build an item list for the navigation in the sidebar of the index page. By
default this is just the 'All Discussions' link.
@return {ItemList}
*/
navItems() {
var items = new ItemList();
var user = this.user();
items.add('activity',
NavItem.component({
href: app.route('user.activity', user),
label: 'Activity',
icon: 'user'
})
);
items.add('discussions',
NavItem.component({
href: app.route('user.discussions', user),
label: 'Discussions',
icon: 'reorder',
badge: user.discussionsCount()
})
);
items.add('posts',
NavItem.component({
href: app.route('user.posts', user),
label: 'Posts',
icon: 'comment-o',
badge: user.commentsCount()
})
);
if (app.session.user() === user) {
items.add('separator', Separator.component());
items.add('settings',
NavItem.component({
href: app.route('settings'),
label: 'Settings',
icon: 'cog'
})
);
}
return items;
}
/**
Setup the sidebar DOM element to be affixed to the top of the viewport
using Bootstrap's affix plugin.
@param {DOMElement} element
@param {Boolean} isInitialized
@return {void}
*/
affixSidebar(element, isInitialized, context) {
if (isInitialized) { return; }
var $sidebar = $(element);
console.log($sidebar.find('> ul'), $sidebar.find('> ul').data('bs.affix'));
$sidebar.find('> ul').affix({
offset: {
top: $sidebar.offset().top - $('.global-header').outerHeight(true) - parseInt($sidebar.css('margin-top')),
bottom: $('.global-footer').outerHeight(true)
}
});
}
}

View File

@ -0,0 +1,32 @@
import Component from 'flarum/component';
export default class WelcomeHero extends Component {
constructor(props) {
super(props);
this.title = m.prop('Mithril Forum')
this.description = m.prop('Hello')
this.hidden = m.prop(localStorage.getItem('welcomeHidden'))
}
hide() {
localStorage.setItem('welcomeHidden', 'true')
this.hidden(true)
}
view() {
var root = m.prop()
var self = this;
return this.hidden() ? m('') : m('header.hero.welcome-hero', {config: root}, [
m('div.container', [
m('button.close.btn.btn-icon.btn-link', {onclick: function() {
$(root()).slideUp(self.hide.bind(self))
}}, m('i.fa.fa-times')),
m('div.container-narrow', [
m('h2', this.title()),
m('p', this.description())
])
])
])
}
}

View File

@ -0,0 +1,43 @@
import ScrollListener from 'flarum/utils/scroll-listener';
import History from 'flarum/utils/history';
import Pane from 'flarum/utils/pane';
import mapRoutes from 'flarum/utils/map-routes';
import BackButton from 'flarum/components/back-button';
import HeaderPrimary from 'flarum/components/header-primary';
import HeaderSecondary from 'flarum/components/header-secondary';
import FooterPrimary from 'flarum/components/footer-primary';
import FooterSecondary from 'flarum/components/footer-secondary';
import Composer from 'flarum/components/composer';
import Modal from 'flarum/components/modal';
import Alerts from 'flarum/components/alerts';
import SignupModal from 'flarum/components/signup-modal';
import LoginModal from 'flarum/components/login-modal';
export default function(app) {
var id = id => document.getElementById(id);
app.history = new History();
app.pane = new Pane(id('page'));
app.cache = {};
app.signup = () => app.modal.show(new SignupModal());
app.login = () => app.modal.show(new LoginModal());
m.mount(id('back-control'), BackButton.component({ className: 'back-control', drawer: true }));
m.mount(id('back-button'), BackButton.component());
m.mount(id('header-primary'), HeaderPrimary.component());
m.mount(id('header-secondary'), HeaderSecondary.component());
m.mount(id('footer-primary'), FooterPrimary.component());
m.mount(id('footer-secondary'), FooterSecondary.component());
app.composer = m.mount(id('composer'), Composer.component());
app.modal = m.mount(id('modal'), Modal.component());
app.alerts = m.mount(id('alerts'), Alerts.component());
m.route.mode = 'hash';
m.route(id('content'), '/', mapRoutes(app.routes));
new ScrollListener(top => $('body').toggleClass('scrolled', top > 0)).start();
}

View File

@ -0,0 +1,21 @@
import PostComment from 'flarum/components/post-comment';
import PostDiscussionRenamed from 'flarum/components/post-discussion-renamed';
import ActivityPost from 'flarum/components/activity-post';
import ActivityJoin from 'flarum/components/activity-join';
import NotificationDiscussionRenamed from 'flarum/components/notification-discussion-renamed';
export default function(app) {
app.postComponentRegistry = {
comment: PostComment,
discussionRenamed: PostDiscussionRenamed
};
app.activityComponentRegistry = {
post: ActivityPost,
join: ActivityJoin
};
app.notificationComponentRegistry = {
discussionRenamed: NotificationDiscussionRenamed
};
}

View File

@ -0,0 +1,21 @@
import IndexPage from 'flarum/components/index-page';
import DiscussionPage from 'flarum/components/discussion-page';
import ActivityPage from 'flarum/components/activity-page';
import SettingsPage from 'flarum/components/settings-page';
export default function(app) {
app.routes = {
'index': ['/', IndexPage.component()],
'index.filter': ['/:filter', IndexPage.component()],
'discussion': ['/d/:id/:slug', DiscussionPage.component()],
'discussion.near': ['/d/:id/:slug/:near', DiscussionPage.component()],
'user': ['/u/:username', ActivityPage.component()],
'user.activity': ['/u/:username', ActivityPage.component()],
'user.discussions': ['/u/:username/discussions', ActivityPage.component({filter: 'discussion'})],
'user.posts': ['/u/:username/posts', ActivityPage.component({filter: 'post'})],
'settings': ['/settings', SettingsPage.component()]
};
}

View File

@ -0,0 +1,43 @@
export default class History {
constructor() {
this.stack = [];
this.push('index', '/');
}
top() {
return this.stack[this.stack.length - 1];
}
push(name, url) {
var url = url || m.route();
// maybe? prevents browser back button from breaking history
var secondTop = this.stack[this.stack.length - 2];
if (secondTop && secondTop.name === name) {
this.stack.pop();
}
var top = this.top();
if (top && top.name === name) {
top.url = url;
} else {
this.stack.push({name: name, url: url});
}
}
canGoBack() {
return this.stack.length > 1;
}
back() {
this.stack.pop();
var top = this.top();
m.route(top.url);
}
home() {
this.stack.splice(1);
var top = this.top();
m.route(top.url);
}
}

View File

@ -0,0 +1,50 @@
export default class Pane {
constructor(element) {
this.pinnedKey = 'panePinned';
this.$element = $(element);
this.pinned = localStorage.getItem(this.pinnedKey) !== 'false';
this.active = false;
this.showing = false;
this.render();
}
enable() {
this.active = true;
this.render();
}
disable() {
this.active = false;
this.showing = false;
this.render();
}
show() {
clearTimeout(this.hideTimeout);
this.showing = true;
this.render();
}
hide() {
this.showing = false;
this.render();
}
onmouseleave() {
this.hideTimeout = setTimeout(this.hide.bind(this), 250);
}
togglePinned() {
localStorage.setItem(this.pinnedKey, (this.pinned = !this.pinned) ? 'true' : 'false');
this.render();
}
render() {
this.$element
.toggleClass('pane-pinned', this.pinned)
.toggleClass('has-pane', this.active)
.toggleClass('pane-showing', this.showing);
}
}

View File

@ -0,0 +1,155 @@
export default class PostStream {
constructor(discussion) {
this.discussion = discussion
this.ids = this.discussion.data().links.posts.linkage.map((link) => link.id)
var item = this.makeItem(0, this.ids.length - 1)
item.loading = true
this.content = [item]
this.postLoadCount = 20
}
count() {
return this.ids.length;
}
loadedCount() {
return this.content.filter((item) => item.post).length;
}
loadRange(start, end, backwards) {
// Find the appropriate gap objects in the post stream. When we find
// one, we will turn on its loading flag.
this.content.forEach(function(item) {
if (!item.post && ((item.start >= start && item.start <= end) || (item.end >= start && item.end <= end))) {
item.loading = true
item.direction = backwards ? 'up' : 'down'
}
});
// Get a list of post numbers that we'll want to retrieve. If there are
// more post IDs than the number of posts we want to load, then take a
// slice of the array in the appropriate direction.
var ids = this.ids.slice(start, end + 1);
var limit = this.postLoadCount
ids = backwards ? ids.slice(-limit) : ids.slice(0, limit)
return this.loadPosts(ids)
}
loadPosts(ids) {
if (!ids.length) {
return m.deferred().resolve().promise;
}
return app.store.find('posts', ids).then(this.addPosts.bind(this));
}
loadNearNumber(number) {
// Find the item in the post stream which is nearest to this number. If
// it turns out the be the actual post we're trying to load, then we can
// return a resolved promise (i.e. we don't need to make an API
// request.) Or, if it's a gap, we'll switch on its loading flag.
var item = this.findNearestToNumber(number)
if (item) {
if (item.post && item.post.number() === number) {
return m.deferred().resolve([item.post]).promise;
} else if (!item.post) {
item.direction = 'down'
item.loading = true;
}
}
var stream = this
return app.store.find('posts', {
discussions: this.discussion.id(),
near: number,
count: this.postLoadCount
}).then(this.addPosts.bind(this))
}
loadNearIndex(index, backwards) {
// Find the item in the post stream which is nearest to this index. If
// it turns out the be the actual post we're trying to load, then we can
// return a resolved promise (i.e. we don't need to make an API
// request.) Or, if it's a gap, we'll switch on its loading flag.
var item = this.findNearestToIndex(index)
if (item) {
if (item.post) {
return m.deferred().resolve([item.post]).promise;
}
return this.loadRange(Math.max(item.start, index - this.postLoadCount / 2), item.end, backwards);
}
}
addPosts(posts) {
posts.forEach(this.addPost.bind(this))
}
addPost(post) {
var index = this.ids.indexOf(post.id())
var content = this.content
var makeItem = this.makeItem
// Here we loop through each item in the post stream, and find the gap
// in which this post should be situated. When we find it, we can replace
// it with the post, and new gaps either side if appropriate.
content.some(function(item, i) {
if (item.start <= index && item.end >= index) {
var newItems = []
if (item.start < index) {
newItems.push(makeItem(item.start, index - 1))
}
newItems.push(makeItem(index, index, post))
if (item.end > index) {
newItems.push(makeItem(index + 1, item.end))
}
var args = [i, 1].concat(newItems);
[].splice.apply(content, args)
return true
}
})
}
addPostToEnd(post) {
var index = this.ids.length
this.ids.push(post.id())
this.content.push(this.makeItem(index, index, post))
}
removePost(post) {
this.ids.splice(this.ids.indexOf(post.id()), 1);
this.content.some((item, i) => {
if (item.post === post) {
this.content.splice(i, 1);
return true;
}
});
}
makeItem(start, end, post) {
var item = {start, end}
if (post) {
item.post = post
}
return item
}
findNearestTo(index, property) {
var nearestItem
this.content.some(function(item) {
if (property(item) > index) { return true }
nearestItem = item
})
return nearestItem
}
findNearestToNumber(number) {
return this.findNearestTo(number, (item) => item.post && item.post.number())
}
findNearestToIndex(index) {
return this.findNearestTo(index, (item) => item.start)
}
}

42
js/lib/component.js Normal file
View File

@ -0,0 +1,42 @@
/**
*/
export default class Component {
/**
*/
constructor(props) {
this.props = props || {};
this.element = m.prop();
}
/**
*/
$(selector) {
return selector ? $(this.element()).find(selector) : $(this.element());
}
/**
*/
static component(props) {
props = props || {};
var view = function(component) {
component.props = props;
return component.view();
};
view.$original = this.prototype.view;
var output = {
props: props,
component: this,
controller: this.bind(undefined, props),
view: view
};
if (props.key) {
output.attrs = {key: props.key};
}
return output;
}
}

View File

@ -0,0 +1,21 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
export default class ActionButton extends Component {
view() {
var attrs = {};
for (var i in this.props) { attrs[i] = this.props[i]; }
var iconName = attrs.icon;
delete attrs.icon;
var label = attrs.label;
delete attrs.label;
attrs.href = attrs.href || 'javascript:;';
return m('a', attrs, [
iconName ? icon(iconName+' icon-glyph') : '',
m('span.label', label)
]);
}
}

View File

@ -0,0 +1,33 @@
import Component from 'flarum/component';
import ActionButton from 'flarum/components/action-button';
import listItems from 'flarum/helpers/list-items';
export default class Alert extends Component {
view() {
var attrs = {};
for (var i in this.props) { attrs[i] = this.props[i]; }
attrs.className = (attrs.className || '') + ' alert-'+attrs.type;
delete attrs.type;
var message = attrs.message;
delete attrs.message;
var controlItems = attrs.controls.slice() || [];
delete attrs.controls;
if (attrs.dismissible || attrs.dismissible === undefined) {
controlItems.push(ActionButton.component({
icon: 'times',
className: 'btn btn-icon btn-link',
onclick: attrs.ondismiss.bind(this)
}));
}
delete attrs.dismissible;
return m('div.alert', attrs, [
m('span.alert-text', message),
m('ul.alert-controls', listItems(controlItems))
]);
}
}

View File

@ -0,0 +1,34 @@
import Component from 'flarum/component';
export default class Alerts extends Component {
constructor(props) {
super(props);
this.components = [];
}
view() {
return m('div.alerts', this.components.map((component) => {
component.props.ondismiss = this.dismiss.bind(this, component);
return m('div.alert-wrapper', component);
}));
}
show(component) {
this.components.push(component);
m.redraw();
}
dismiss(component) {
var index = this.components.indexOf(component);
if (index !== -1) {
this.components.splice(index, 1);
}
m.redraw();
}
clear() {
this.components = [];
m.redraw();
}
}

View File

@ -0,0 +1,32 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
/**
The back/pin button group in the top-left corner of Flarum's interface.
*/
export default class BackButton extends Component {
view() {
var history = app.history;
var pane = app.pane;
return m('div.back-button', {
className: this.props.className || '',
onmouseenter: pane && pane.show.bind(pane),
onmouseleave: pane && pane.onmouseleave.bind(pane),
config: this.onload.bind(this)
}, history.canGoBack() ? m('div.btn-group', [
m('button.btn.btn-default.btn-icon.back', {onclick: history.back.bind(history)}, icon('chevron-left icon-glyph')),
pane && pane.active ? m('button.btn.btn-default.btn-icon.pin'+(pane.active ? '.active' : ''), {onclick: pane.togglePinned.bind(pane)}, icon('thumb-tack icon-glyph')) : '',
]) : (this.props.drawer ? [
m('button.btn.btn-default.btn-icon.drawer-toggle', {onclick: this.toggleDrawer.bind(this)}, icon('reorder icon-glyph'))
] : ''));
}
onload(element, isInitialized, context) {
context.retain = true;
}
toggleDrawer() {
$('body').toggleClass('drawer-open');
}
}

View File

@ -0,0 +1,19 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
export default class Badge extends Component {
view(ctrl) {
var iconName = this.props.icon;
var label = this.props.title = this.props.label;
delete this.props.icon, this.props.label;
this.props.config = function(element) {
$(element).tooltip();
};
this.props.className = 'badge '+(this.props.className || '');
return m('span', this.props, [
icon(iconName+' icon-glyph'),
m('span.label', label)
]);
}
}

View File

@ -0,0 +1,20 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
import listItems from 'flarum/helpers/list-items';
export default class DropdownButton extends Component {
view() {
return m('div', {className: 'dropdown btn-group '+(this.props.items ? 'item-count-'+this.props.items.length : '')+' '+(this.props.className || '')}, [
m('a[href=javascript:;]', {
className: 'dropdown-toggle '+(this.props.buttonClass || 'btn btn-default'),
'data-toggle': 'dropdown',
onclick: this.props.buttonClick
}, this.props.buttonContent || [
icon((this.props.icon || 'ellipsis-v')+' icon-glyph'),
m('span.label', this.props.label || 'Controls'),
icon('caret-down icon-caret')
]),
m(this.props.menuContent ? 'div' : 'ul', {className: 'dropdown-menu '+(this.props.menuClass || '')}, this.props.menuContent || listItems(this.props.items))
]);
}
}

View File

@ -0,0 +1,18 @@
import Component from 'flarum/component'
import icon from 'flarum/helpers/icon'
import listItems from 'flarum/helpers/list-items';
export default class DropdownSelect extends Component {
view() {
var activeItem = this.props.items.filter((item) => item.component.active && item.component.active(item.props))[0];
var label = activeItem && activeItem.props.label;
return m('div', {className: 'dropdown dropdown-select btn-group item-count-'+this.props.items.length+' '+this.props.className}, [
m('a[href=javascript:;]', {className: 'dropdown-toggle '+(this.props.buttonClass || 'btn btn-default'), 'data-toggle': 'dropdown'}, [
m('span.label', label), ' ',
icon('sort icon-caret')
]),
m('ul', {className: 'dropdown-menu '+this.props.menuClass}, listItems(this.props.items, true))
])
}
}

View File

@ -0,0 +1,30 @@
import Component from 'flarum/component';
import icon from 'flarum/helpers/icon';
import listItems from 'flarum/helpers/list-items';
import ActionButton from 'flarum/components/action-button';
/**
Given a list of items, this component displays a split button: the left side
is the first item in the list, while the right side is a dropdown-toggle
which shows a dropdown menu containing all of the items.
*/
export default class DropdownSplit extends Component {
view() {
var firstItem = this.props.items[0];
var items = listItems(this.props.items);
var buttonProps = { className: this.props.buttonClass || 'btn btn-default' };
for (var i in firstItem.props) {
buttonProps[i] = firstItem.props[i];
}
return m('div', {className: 'dropdown dropdown-split btn-group item-count-'+(items.length)+' '+this.props.className}, [
ActionButton.component(buttonProps),
m('a[href=javascript:;]', {className: 'dropdown-toggle '+this.props.buttonClass, 'data-toggle': 'dropdown'}, [
icon('caret-down icon-caret'),
icon((this.props.icon || 'ellipsis-v')+' icon-glyph'),
]),
m('ul', {className: 'dropdown-menu '+(this.props.menuClass || 'pull-right')}, items)
])
}
}

View File

@ -0,0 +1,11 @@
import Component from 'flarum/component';
import listItems from 'flarum/helpers/list-items';
export default class FieldSet extends Component {
view() {
return m('fieldset', {className: this.props.className}, [
m('legend', this.props.label),
m('ul', listItems(this.props.fields))
]);
}
}

View File

@ -0,0 +1,15 @@
import Component from 'flarum/component';
export default class LoadingIndicator extends Component {
view() {
var size = this.props.size || 'small';
delete this.props.size;
this.props.config = function(element) {
$.fn.spin.presets[size].zIndex = 'auto';
$(element).spin(size);
};
return m('div.loading-indicator', this.props, m.trust('&nbsp;'));
}
}

View File

@ -0,0 +1,35 @@
import Component from 'flarum/component';
export default class Modal extends Component {
view() {
return m('div.modal.fade', {config: this.onload.bind(this)}, this.component && this.component.view())
}
onload(element, isInitialized) {
if (isInitialized) { return; }
this.element(element);
this.$()
.on('hidden.bs.modal', this.destroy.bind(this))
.on('shown.bs.modal', this.ready.bind(this));
}
show(component) {
this.component = component;
m.redraw(true);
this.$().modal('show');
}
close() {
this.$().modal('hide');
}
destroy() {
this.component = null;
}
ready() {
this.component && this.component.ready && this.component.ready(this.$());
}
}

View File

@ -0,0 +1,17 @@
import Component from 'flarum/component'
import icon from 'flarum/helpers/icon'
export default class NavItem extends Component {
view() {
var active = NavItem.active(this.props);
return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route}, [
icon(this.props.icon),
this.props.label, ' ',
m('span.count', this.props.badge)
]))
}
static active(props) {
return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
}
}

View File

@ -0,0 +1,13 @@
import Component from 'flarum/component'
import icon from 'flarum/helpers/icon';
export default class SelectInput extends Component {
view(ctrl) {
return m('span.select-input', [
m('select.form-control', {onchange: m.withAttr('value', this.props.onchange.bind(ctrl)), value: this.props.value}, [
this.props.options.map(function(option) { return m('option', {value: option.key}, option.value) })
]),
icon('sort')
])
}
}

View File

@ -0,0 +1,14 @@
import Component from 'flarum/component';
/**
*/
class Separator extends Component {
view() {
return m('span');
}
}
Separator.wrapperClass = 'divider';
export default Separator;

View File

@ -0,0 +1,30 @@
import Component from 'flarum/component';
import LoadingIndicator from 'flarum/components/loading-indicator';
export default class SwitchInput extends Component {
constructor(props) {
super(props);
this.loading = m.prop(false);
}
view() {
return m('div.checkbox.checkbox-switch', [
m('label', [
m('div.switch-control', [
m('input[type=checkbox]', {
checked: this.props.state,
onchange: m.withAttr('checked', this.onchange.bind(this))
}),
m('div.switch', {className: this.loading() && 'loading'})
]),
this.props.label, ' ',
this.loading() ? LoadingIndicator.component({size: 'tiny'}) : ''
])
])
}
onchange(checked) {
this.props.onchange && this.props.onchange(checked, this);
}
}

View File

@ -0,0 +1,65 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import listItems from 'flarum/helpers/list-items';
import ActionButton from 'flarum/components/action-button';
/**
A text editor. Contains a textarea and an item list of `controls`, including
a submit button.
*/
export default class TextEditor extends Component {
constructor(props) {
props.submitLabel = props.submitLabel || 'Submit';
super(props);
this.value = m.prop(this.props.value || '');
}
view() {
return m('div.text-editor', {config: this.element}, [
m('textarea.form-control.flexible-height', {
config: this.configTextarea.bind(this),
onkeyup: m.withAttr('value', this.onkeyup.bind(this)),
placeholder: this.props.placeholder || '',
disabled: !!this.props.disabled,
value: this.props.value || ''
}),
m('ul.text-editor-controls.fade', listItems(this.controlItems().toArray()))
]);
}
configTextarea(element, isInitialized) {
if (isInitialized) { return; }
$(element).bind('keydown', 'meta+return', this.onsubmit.bind(this));
}
controlItems() {
var items = new ItemList();
items.add('submit',
ActionButton.component({
label: this.props.submitLabel,
icon: 'check',
className: 'btn btn-primary',
wrapperClass: 'primary-control',
onclick: this.onsubmit.bind(this)
})
);
return items;
}
onkeyup(value) {
this.value(value);
this.props.onchange(this.value());
this.$('.text-editor-controls').toggleClass('in', !!value);
m.redraw.strategy('none');
}
onsubmit() {
this.props.onsubmit(this.value());
}
}

View File

@ -0,0 +1,35 @@
import Component from 'flarum/component';
import LoadingIndicator from 'flarum/components/loading-indicator';
import classList from 'flarum/utils/class-list';
import icon from 'flarum/helpers/icon';
export default class YesNoInput extends Component {
constructor(props) {
super(props);
this.loading = m.prop(false);
}
view() {
return m('label.yesno-control', [
m('input[type=checkbox]', {
checked: this.props.state,
disabled: this.props.disabled,
onchange: m.withAttr('checked', this.onchange.bind(this))
}),
m('div.yesno', {className: classList({
loading: this.loading(),
disabled: this.props.disabled,
state: this.props.state ? 'yes' : 'no'
})}, [
this.loading()
? LoadingIndicator.component({size: 'tiny'})
: icon(this.props.state ? 'check' : 'times')
])
]);
}
onchange(checked) {
this.props.onchange && this.props.onchange(checked, this);
}
}

View File

@ -0,0 +1,8 @@
export function extend(object, func, extension) {
var oldFunc = object[func];
object[func] = function() {
var value = oldFunc.apply(this, arguments);
var args = [].slice.apply(arguments);
return extension.apply(this, [value].concat(args));
}
};

26
js/lib/helpers/avatar.js Normal file
View File

@ -0,0 +1,26 @@
export default function avatar(user, args) {
args = args || {}
args.className = 'avatar '+(args.className || '')
var content = ''
var title = typeof args.title === 'undefined' || args.title
if (!title) { delete args.title }
if (user) {
var username = user.username() || '?'
if (title) { args.title = args.title || username }
var avatarUrl = user.avatarUrl()
if (avatarUrl) {
args.src = avatarUrl
return m('img', args)
}
content = username.charAt(0).toUpperCase()
args.style = {background: user.color()}
}
if (!args.title) { delete args.title }
return m('span', args, content)
}

View File

@ -0,0 +1,7 @@
export default function fullTime(time) {
var time = moment(time);
var datetime = time.format();
var full = time.format('LLLL');
return m('time', {pubdate: '', datetime}, full);
}

View File

@ -0,0 +1,11 @@
import humanTime from 'flarum/utils/human-time';
export default function humanTimeHelper(time) {
var time = moment(time);
var datetime = time.format();
var full = time.format('LLLL');
var ago = humanTime(time);
return m('time', {pubdate: '', datetime, title: full, 'data-humantime': ''}, ago);
}

3
js/lib/helpers/icon.js Normal file
View File

@ -0,0 +1,3 @@
export default function icon(icon) {
return m('i.fa.fa-fw.fa-'+icon)
}

View File

@ -0,0 +1,21 @@
import Separator from 'flarum/components/separator';
function isSeparator(item) {
return item && item.component === Separator;
}
export default function listItems(array, noWrap) {
// Remove duplicate/unnecessary separators
var prevItem;
var newArray = [];
array.forEach(function(item, i) {
if ((!prevItem || isSeparator(prevItem) || i === array.length - 1) && isSeparator(item)) {
} else {
prevItem = item;
newArray.push(item);
}
});
return newArray.map(item => [(noWrap && !isSeparator(item)) ? item : m('li', {className: (item.props && item.props.wrapperClass) || (item.component && item.component.wrapperClass) || ''}, item), ' ']);
};

View File

@ -0,0 +1,5 @@
export default function username(user) {
var username = (user && user.username()) || '[deleted]';
return m('span.username', username);
}

View File

@ -0,0 +1,5 @@
export default function(app) {
if (app.preload.data) {
app.store.pushPayload({data: app.preload.data});
}
};

View File

@ -0,0 +1,10 @@
import Session from 'flarum/session';
export default function(app) {
app.session = new Session();
if (app.preload.session) {
app.session.token(app.preload.session.token);
app.session.user(app.store.getById('users', app.preload.session.userId));
}
}

View File

@ -0,0 +1,18 @@
import Store from 'flarum/store';
import User from 'flarum/models/user';
import Discussion from 'flarum/models/discussion';
import Post from 'flarum/models/post';
import Group from 'flarum/models/group';
import Activity from 'flarum/models/activity';
import Notification from 'flarum/models/notification';
export default function(app) {
app.store = new Store();
app.store.model('users', User);
app.store.model('discussions', Discussion);
app.store.model('posts', Post);
app.store.model('groups', Group);
app.store.model('activity', Activity);
app.store.model('notifications', Notification);
};

View File

@ -0,0 +1,138 @@
import humanTime from 'flarum/utils/human-time';
export default function(app) {
// Livestamp.js / v1.1.2 / (c) 2012 Matt Bradley / MIT License
// @todo rewrite this to be simpler and cleaner
(function($, moment) {
var updateInterval = 1e3,
paused = false,
$livestamps = $([]),
init = function() {
livestampGlobal.resume();
},
prep = function($el, timestamp) {
var oldData = $el.data('livestampdata');
if (typeof timestamp == 'number')
timestamp *= 1e3;
$el.removeAttr('data-humantime')
.removeData('humantime');
timestamp = moment(timestamp);
if (moment().diff(timestamp) > 60 * 60) {
return;
}
if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
var newData = $.extend({ }, { 'original': $el.contents() }, oldData);
newData.moment = moment(timestamp);
$el.data('livestampdata', newData).empty();
$livestamps.push($el[0]);
}
},
run = function() {
if (paused) return;
livestampGlobal.update();
setTimeout(run, updateInterval);
},
livestampGlobal = {
update: function() {
$('[data-humantime]').each(function() {
var $this = $(this);
prep($this, $this.attr('datetime'));
});
var toRemove = [];
$livestamps.each(function() {
var $this = $(this),
data = $this.data('livestampdata');
if (data === undefined)
toRemove.push(this);
else if (moment.isMoment(data.moment)) {
var from = $this.html(),
to = humanTime(data.moment);
// to = data.moment.fromNow();
if (from != to) {
var e = $.Event('change.livestamp');
$this.trigger(e, [from, to]);
if (!e.isDefaultPrevented())
$this.html(to);
}
}
});
$livestamps = $livestamps.not(toRemove);
},
pause: function() {
paused = true;
},
resume: function() {
paused = false;
run();
},
interval: function(interval) {
if (interval === undefined)
return updateInterval;
updateInterval = interval;
}
},
livestampLocal = {
add: function($el, timestamp) {
if (typeof timestamp == 'number')
timestamp *= 1e3;
timestamp = moment(timestamp);
if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
$el.each(function() {
prep($(this), timestamp);
});
livestampGlobal.update();
}
return $el;
},
destroy: function($el) {
$livestamps = $livestamps.not($el);
$el.each(function() {
var $this = $(this),
data = $this.data('livestampdata');
if (data === undefined)
return $el;
$this
.html(data.original ? data.original : '')
.removeData('livestampdata');
});
return $el;
},
isLivestamp: function($el) {
return $el.data('livestampdata') !== undefined;
}
};
$.livestamp = livestampGlobal;
$(init);
$.fn.livestamp = function(method, options) {
if (!livestampLocal[method]) {
options = method;
method = 'add';
}
return livestampLocal[method](this, options);
};
})(jQuery, moment);
}

88
js/lib/model.js Normal file
View File

@ -0,0 +1,88 @@
export default class Model {
constructor(data, store) {
this.data = m.prop(data || {});
this.freshness = new Date();
this.exists = false;
this.store = store;
}
pushData(newData) {
var data = this.data();
for (var i in newData) {
if (i === 'links') {
data[i] = data[i] || {};
for (var j in newData[i]) {
if (newData[i][j] instanceof Model) {
newData[i][j] = {linkage: {type: newData[i][j].data().type, id: newData[i][j].data().id}};
}
data[i][j] = newData[i][j];
}
} else {
data[i] = newData[i];
}
}
this.freshness = new Date();
}
save(data) {
if (data.links) {
for (var i in data.links) {
var model = data.links[i];
data.links[i] = {linkage: {type: model.data().type, id: model.data().id}};
}
}
this.pushData(data);
return m.request({
method: this.exists ? 'PUT' : 'POST',
url: app.config.apiURL+'/'+this.data().type+(this.exists ? '/'+this.data().id : ''),
data: {data},
background: true,
config: app.session.authorize.bind(app.session)
}).then(payload => {
this.store.data[payload.data.type][payload.data.id] = this;
return this.store.pushPayload(payload);
});
}
delete() {
if (!this.exists) { return; }
return m.request({
method: 'DELETE',
url: app.config.apiURL+'/'+this.data().type+'/'+this.data().id,
background: true,
config: app.session.authorize.bind(app.session)
}).then(() => this.exists = false);
}
static prop(name, transform) {
return function() {
var data = this.data()[name];
return transform ? transform(data) : data
}
}
static one(name) {
return function() {
var link = this.data().links[name];
return link && app.store.getById(link.linkage.type, link.linkage.id)
}
}
static many(name) {
return function() {
var link = this.data().links[name];
return link && link.linkage.map(function(link) {
return app.store.getById(link.type, link.id)
})
}
}
static date(data) {
return data ? new Date(data) : null;
}
}

14
js/lib/models/activity.js Normal file
View File

@ -0,0 +1,14 @@
import Model from 'flarum/model';
class Activity extends Model {}
Activity.prototype.id = Model.prop('id');
Activity.prototype.contentType = Model.prop('contentType');
Activity.prototype.content = Model.prop('content');
Activity.prototype.time = Model.prop('time', Model.date);
Activity.prototype.user = Model.one('user');
Activity.prototype.sender = Model.one('sender');
Activity.prototype.post = Model.one('post');
export default Activity;

View File

@ -0,0 +1,45 @@
import Model from 'flarum/model';
import computed from 'flarum/utils/computed';
import ItemList from 'flarum/utils/item-list';
class Discussion extends Model {}
Discussion.prototype.id = Model.prop('id');
Discussion.prototype.title = Model.prop('title');
Discussion.prototype.slug = computed('title', title => title.toLowerCase().replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').replace(/-$|^-/g, ''));
Discussion.prototype.startTime = Model.prop('startTime', Model.date);
Discussion.prototype.startUser = Model.one('startUser');
Discussion.prototype.startPost = Model.one('startPost');
Discussion.prototype.lastTime = Model.prop('lastTime', Model.date);
Discussion.prototype.lastUser = Model.one('lastUser');
Discussion.prototype.lastPost = Model.one('lastPost');
Discussion.prototype.lastPostNumber = Model.prop('lastPostNumber');
Discussion.prototype.canReply = Model.prop('canReply');
Discussion.prototype.canEdit = Model.prop('canEdit');
Discussion.prototype.canDelete = Model.prop('canDelete');
Discussion.prototype.commentsCount = Model.prop('commentsCount');
Discussion.prototype.repliesCount = computed('commentsCount', commentsCount => commentsCount - 1);
Discussion.prototype.posts = Model.many('posts');
Discussion.prototype.relevantPosts = Model.many('relevantPosts');
Discussion.prototype.addedPosts = Model.many('addedPosts');
Discussion.prototype.readTime = Model.prop('readTime', Model.date);
Discussion.prototype.readNumber = Model.prop('readNumber');
Discussion.prototype.unreadCount = function() {
var user = app.session.user();
if (user && user.readTime() < this.lastTime()) {
return Math.max(0, this.lastPostNumber() - (this.readNumber() || 0))
}
return 0
};
Discussion.prototype.isUnread = computed('unreadCount', unreadCount => !!unreadCount);
Discussion.prototype.badges = () => new ItemList();
export default Discussion;

8
js/lib/models/group.js Normal file
View File

@ -0,0 +1,8 @@
import Model from 'flarum/model';
class Group extends Model {}
Group.prototype.id = Model.prop('id');
Group.prototype.name = Model.prop('name');
export default Group;

View File

@ -0,0 +1,19 @@
import Model from 'flarum/model';
import computed from 'flarum/utils/computed';
class Notification extends Model {}
Notification.prototype.id = Model.prop('id');
Notification.prototype.contentType = Model.prop('contentType');
Notification.prototype.subjectId = Model.prop('subjectId');
Notification.prototype.content = Model.prop('content');
Notification.prototype.time = Model.prop('time', Model.date);
Notification.prototype.isRead = Model.prop('isRead');
Notification.prototype.unreadCount = Model.prop('unreadCount');
Notification.prototype.additionalUnreadCount = computed('unreadCount', unreadCount => Math.max(0, unreadCount - 1));
Notification.prototype.user = Model.one('user');
Notification.prototype.sender = Model.one('sender');
Notification.prototype.subject = Model.one('subject');
export default Notification;

Some files were not shown because too many files have changed in this diff Show More