mirror of
https://github.com/flarum/framework.git
synced 2025-05-23 15:19:56 +08:00
Replace Ember app with Mithril app
This commit is contained in:
4
js/admin/.gitignore
vendored
Normal file
4
js/admin/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
bower_components
|
||||
node_modules
|
||||
mithril.js
|
||||
dist
|
51
js/admin/Gulpfile.js
Normal file
51
js/admin/Gulpfile.js
Normal 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
13
js/admin/bower.json
Normal 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
15
js/admin/package.json
Normal 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
18
js/admin/src/app.js
Normal 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;
|
14
js/admin/src/components/admin-nav-item.js
Normal file
14
js/admin/src/components/admin-nav-item.js
Normal 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)
|
||||
]))
|
||||
}
|
||||
}
|
54
js/admin/src/components/admin-nav.js
Normal file
54
js/admin/src/components/admin-nav.js
Normal 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;
|
||||
}
|
||||
}
|
7
js/admin/src/components/appearance-page.js
Normal file
7
js/admin/src/components/appearance-page.js
Normal file
@ -0,0 +1,7 @@
|
||||
import Component from 'flarum/component';
|
||||
|
||||
export default class AppearancePage extends Component {
|
||||
view() {
|
||||
return m('div', 'appearance');
|
||||
}
|
||||
};
|
7
js/admin/src/components/basics-page.js
Normal file
7
js/admin/src/components/basics-page.js
Normal file
@ -0,0 +1,7 @@
|
||||
import Component from 'flarum/component';
|
||||
|
||||
export default class BasicsPage extends Component {
|
||||
view() {
|
||||
return m('div', 'basics');
|
||||
}
|
||||
};
|
7
js/admin/src/components/dashboard-page.js
Normal file
7
js/admin/src/components/dashboard-page.js
Normal file
@ -0,0 +1,7 @@
|
||||
import Component from 'flarum/component';
|
||||
|
||||
export default class DashboardPage extends Component {
|
||||
view() {
|
||||
return m('div', 'dashboard');
|
||||
}
|
||||
};
|
7
js/admin/src/components/extensions-page.js
Normal file
7
js/admin/src/components/extensions-page.js
Normal file
@ -0,0 +1,7 @@
|
||||
import Component from 'flarum/component';
|
||||
|
||||
export default class ExtensionsPage extends Component {
|
||||
view() {
|
||||
return m('div', 'extensions');
|
||||
}
|
||||
};
|
15
js/admin/src/components/header-primary.js
Normal file
15
js/admin/src/components/header-primary.js
Normal 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;
|
||||
}
|
||||
}
|
19
js/admin/src/components/header-secondary.js
Normal file
19
js/admin/src/components/header-secondary.js
Normal 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;
|
||||
}
|
||||
}
|
7
js/admin/src/components/permissions-page.js
Normal file
7
js/admin/src/components/permissions-page.js
Normal file
@ -0,0 +1,7 @@
|
||||
import Component from 'flarum/component';
|
||||
|
||||
export default class PermissionsPage extends Component {
|
||||
view() {
|
||||
return m('div', 'permissions');
|
||||
}
|
||||
};
|
35
js/admin/src/components/user-dropdown.js
Normal file
35
js/admin/src/components/user-dropdown.js
Normal 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;
|
||||
}
|
||||
}
|
38
js/admin/src/initializers/boot.js
Normal file
38
js/admin/src/initializers/boot.js
Normal 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();
|
||||
}
|
15
js/admin/src/initializers/routes.js
Normal file
15
js/admin/src/initializers/routes.js
Normal 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
4
js/forum/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
bower_components
|
||||
node_modules
|
||||
mithril.js
|
||||
dist
|
53
js/forum/Gulpfile.js
Normal file
53
js/forum/Gulpfile.js
Normal 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
13
js/forum/bower.json
Normal 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
15
js/forum/package.json
Normal 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
20
js/forum/src/app.js
Normal 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;
|
18
js/forum/src/components/activity-join.js
Normal file
18
js/forum/src/components/activity-join.js
Normal 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())
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
84
js/forum/src/components/activity-page.js
Normal file
84
js/forum/src/components/activity-page.js
Normal 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)
|
||||
})) : '')
|
||||
]);
|
||||
}
|
||||
}
|
28
js/forum/src/components/activity-post.js
Normal file
28
js/forum/src/components/activity-post.js
Normal 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()))
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
70
js/forum/src/components/avatar-editor.js
Normal file
70
js/forum/src/components/avatar-editor.js
Normal 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();
|
||||
}
|
||||
}
|
45
js/forum/src/components/composer-body.js
Normal file
45
js/forum/src/components/composer-body.js
Normal 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) {
|
||||
//
|
||||
}
|
||||
}
|
71
js/forum/src/components/composer-discussion.js
Normal file
71
js/forum/src/components/composer-discussion.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
44
js/forum/src/components/composer-edit.js
Normal file
44
js/forum/src/components/composer-edit.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
82
js/forum/src/components/composer-reply.js
Normal file
82
js/forum/src/components/composer-reply.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
295
js/forum/src/components/composer.js
Normal file
295
js/forum/src/components/composer.js
Normal 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;
|
197
js/forum/src/components/discussion-list.js
Normal file
197
js/forum/src/components/discussion-list.js
Normal 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;
|
||||
}
|
||||
}
|
280
js/forum/src/components/discussion-page.js
Normal file
280
js/forum/src/components/discussion-page.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
15
js/forum/src/components/footer-primary.js
Normal file
15
js/forum/src/components/footer-primary.js
Normal 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;
|
||||
}
|
||||
}
|
17
js/forum/src/components/footer-secondary.js
Normal file
17
js/forum/src/components/footer-secondary.js
Normal 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;
|
||||
}
|
||||
}
|
15
js/forum/src/components/header-primary.js
Normal file
15
js/forum/src/components/header-primary.js
Normal 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;
|
||||
}
|
||||
}
|
44
js/forum/src/components/header-secondary.js
Normal file
44
js/forum/src/components/header-secondary.js
Normal 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;
|
||||
}
|
||||
}
|
173
js/forum/src/components/index-page.js
Normal file
173
js/forum/src/components/index-page.js
Normal 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))
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
57
js/forum/src/components/login-modal.js
Normal file
57
js/forum/src/components/login-modal.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
31
js/forum/src/components/notification-discussion-renamed.js
Normal file
31
js/forum/src/components/notification-discussion-renamed.js
Normal 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});
|
||||
}
|
||||
}
|
97
js/forum/src/components/notification-grid.js
Normal file
97
js/forum/src/components/notification-grid.js
Normal 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);
|
||||
}
|
||||
}
|
20
js/forum/src/components/notification.js
Normal file
20
js/forum/src/components/notification.js
Normal 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});
|
||||
}
|
||||
}
|
133
js/forum/src/components/post-comment.js
Normal file
133
js/forum/src/components/post-comment.js
Normal 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);
|
||||
}
|
||||
}
|
59
js/forum/src/components/post-discussion-renamed.js
Normal file
59
js/forum/src/components/post-discussion-renamed.js
Normal 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);
|
||||
}
|
||||
}
|
20
js/forum/src/components/post-header-edited.js
Normal file
20
js/forum/src/components/post-header-edited.js
Normal 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'));
|
||||
}
|
||||
}
|
42
js/forum/src/components/post-header-meta.js
Normal file
42
js/forum/src/components/post-header-meta.js
Normal 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()})
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
13
js/forum/src/components/post-header-toggle.js
Normal file
13
js/forum/src/components/post-header-toggle.js
Normal 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'));
|
||||
}
|
||||
}
|
61
js/forum/src/components/post-header-user.js
Normal file
61
js/forum/src/components/post-header-user.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
126
js/forum/src/components/settings-page.js
Normal file
126
js/forum/src/components/settings-page.js
Normal 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;
|
||||
}
|
||||
}
|
88
js/forum/src/components/signup-modal.js
Normal file
88
js/forum/src/components/signup-modal.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
343
js/forum/src/components/stream-content.js
Normal file
343
js/forum/src/components/stream-content.js
Normal 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;
|
||||
}
|
||||
}
|
112
js/forum/src/components/stream-item.js
Normal file
112
js/forum/src/components/stream-item.js
Normal 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();
|
||||
}
|
||||
}
|
430
js/forum/src/components/stream-scrubber.js
Normal file
430
js/forum/src/components/stream-scrubber.js
Normal 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);
|
||||
}
|
||||
}
|
24
js/forum/src/components/terminal-post.js
Normal file
24
js/forum/src/components/terminal-post.js
Normal 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']()))
|
||||
])
|
||||
}
|
||||
}
|
64
js/forum/src/components/user-bio.js
Normal file
64
js/forum/src/components/user-bio.js
Normal 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();
|
||||
}
|
||||
}
|
76
js/forum/src/components/user-card.js
Normal file
76
js/forum/src/components/user-card.js
Normal 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;
|
||||
}
|
||||
}
|
65
js/forum/src/components/user-dropdown.js
Normal file
65
js/forum/src/components/user-dropdown.js
Normal 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;
|
||||
}
|
||||
}
|
72
js/forum/src/components/user-notifications.js
Normal file
72
js/forum/src/components/user-notifications.js
Normal 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});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
147
js/forum/src/components/user-page.js
Normal file
147
js/forum/src/components/user-page.js
Normal 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)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
32
js/forum/src/components/welcome-hero.js
Normal file
32
js/forum/src/components/welcome-hero.js
Normal 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())
|
||||
])
|
||||
])
|
||||
])
|
||||
}
|
||||
}
|
43
js/forum/src/initializers/boot.js
Normal file
43
js/forum/src/initializers/boot.js
Normal 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();
|
||||
}
|
21
js/forum/src/initializers/components.js
Normal file
21
js/forum/src/initializers/components.js
Normal 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
|
||||
};
|
||||
}
|
21
js/forum/src/initializers/routes.js
Normal file
21
js/forum/src/initializers/routes.js
Normal 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()]
|
||||
};
|
||||
}
|
43
js/forum/src/utils/history.js
Normal file
43
js/forum/src/utils/history.js
Normal 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);
|
||||
}
|
||||
}
|
50
js/forum/src/utils/pane.js
Normal file
50
js/forum/src/utils/pane.js
Normal 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);
|
||||
}
|
||||
}
|
155
js/forum/src/utils/post-stream.js
Normal file
155
js/forum/src/utils/post-stream.js
Normal 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
42
js/lib/component.js
Normal 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;
|
||||
}
|
||||
}
|
21
js/lib/components/action-button.js
Normal file
21
js/lib/components/action-button.js
Normal 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)
|
||||
]);
|
||||
}
|
||||
}
|
33
js/lib/components/alert.js
Normal file
33
js/lib/components/alert.js
Normal 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))
|
||||
]);
|
||||
}
|
||||
}
|
34
js/lib/components/alerts.js
Normal file
34
js/lib/components/alerts.js
Normal 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();
|
||||
}
|
||||
}
|
32
js/lib/components/back-button.js
Normal file
32
js/lib/components/back-button.js
Normal 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');
|
||||
}
|
||||
}
|
19
js/lib/components/badge.js
Normal file
19
js/lib/components/badge.js
Normal 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)
|
||||
]);
|
||||
}
|
||||
}
|
20
js/lib/components/dropdown-button.js
Normal file
20
js/lib/components/dropdown-button.js
Normal 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))
|
||||
]);
|
||||
}
|
||||
}
|
18
js/lib/components/dropdown-select.js
Normal file
18
js/lib/components/dropdown-select.js
Normal 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))
|
||||
])
|
||||
}
|
||||
}
|
30
js/lib/components/dropdown-split.js
Normal file
30
js/lib/components/dropdown-split.js
Normal 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)
|
||||
])
|
||||
}
|
||||
}
|
11
js/lib/components/field-set.js
Normal file
11
js/lib/components/field-set.js
Normal 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))
|
||||
]);
|
||||
}
|
||||
}
|
15
js/lib/components/loading-indicator.js
Normal file
15
js/lib/components/loading-indicator.js
Normal 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(' '));
|
||||
}
|
||||
}
|
35
js/lib/components/modal.js
Normal file
35
js/lib/components/modal.js
Normal 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.$());
|
||||
}
|
||||
}
|
17
js/lib/components/nav-item.js
Normal file
17
js/lib/components/nav-item.js
Normal 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;
|
||||
}
|
||||
}
|
13
js/lib/components/select-input.js
Normal file
13
js/lib/components/select-input.js
Normal 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')
|
||||
])
|
||||
}
|
||||
}
|
14
js/lib/components/separator.js
Normal file
14
js/lib/components/separator.js
Normal file
@ -0,0 +1,14 @@
|
||||
import Component from 'flarum/component';
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
class Separator extends Component {
|
||||
view() {
|
||||
return m('span');
|
||||
}
|
||||
}
|
||||
|
||||
Separator.wrapperClass = 'divider';
|
||||
|
||||
export default Separator;
|
30
js/lib/components/switch-input.js
Normal file
30
js/lib/components/switch-input.js
Normal 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);
|
||||
}
|
||||
}
|
65
js/lib/components/text-editor.js
Normal file
65
js/lib/components/text-editor.js
Normal 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());
|
||||
}
|
||||
}
|
35
js/lib/components/yesno-input.js
Normal file
35
js/lib/components/yesno-input.js
Normal 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);
|
||||
}
|
||||
}
|
8
js/lib/extension-utils.js
Normal file
8
js/lib/extension-utils.js
Normal 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
26
js/lib/helpers/avatar.js
Normal 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)
|
||||
}
|
7
js/lib/helpers/full-time.js
Normal file
7
js/lib/helpers/full-time.js
Normal 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);
|
||||
}
|
11
js/lib/helpers/human-time.js
Normal file
11
js/lib/helpers/human-time.js
Normal 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
3
js/lib/helpers/icon.js
Normal file
@ -0,0 +1,3 @@
|
||||
export default function icon(icon) {
|
||||
return m('i.fa.fa-fw.fa-'+icon)
|
||||
}
|
21
js/lib/helpers/list-items.js
Normal file
21
js/lib/helpers/list-items.js
Normal 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), ' ']);
|
||||
};
|
5
js/lib/helpers/username.js
Normal file
5
js/lib/helpers/username.js
Normal file
@ -0,0 +1,5 @@
|
||||
export default function username(user) {
|
||||
var username = (user && user.username()) || '[deleted]';
|
||||
|
||||
return m('span.username', username);
|
||||
}
|
5
js/lib/initializers/preload.js
Normal file
5
js/lib/initializers/preload.js
Normal file
@ -0,0 +1,5 @@
|
||||
export default function(app) {
|
||||
if (app.preload.data) {
|
||||
app.store.pushPayload({data: app.preload.data});
|
||||
}
|
||||
};
|
10
js/lib/initializers/session.js
Normal file
10
js/lib/initializers/session.js
Normal 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));
|
||||
}
|
||||
}
|
18
js/lib/initializers/store.js
Normal file
18
js/lib/initializers/store.js
Normal 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);
|
||||
};
|
138
js/lib/initializers/timestamps.js
Normal file
138
js/lib/initializers/timestamps.js
Normal 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
88
js/lib/model.js
Normal 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
14
js/lib/models/activity.js
Normal 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;
|
45
js/lib/models/discussion.js
Normal file
45
js/lib/models/discussion.js
Normal 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
8
js/lib/models/group.js
Normal 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;
|
19
js/lib/models/notification.js
Normal file
19
js/lib/models/notification.js
Normal 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
Reference in New Issue
Block a user