mirror of
https://github.com/flarum/framework.git
synced 2025-05-21 22:36:01 +08:00
Massive JavaScript cleanup
- Use JSX for templates - Docblock/comment everything - Mostly passes ESLint (still some work to do) - Lots of renaming, refactoring, etc. CSS hasn't been updated yet.
This commit is contained in:
@ -12,7 +12,7 @@ export default class HeaderSecondary extends Component {
|
||||
items() {
|
||||
var items = new ItemList();
|
||||
|
||||
items.add('user', UserDropdown.component({ user: app.session.user() }));
|
||||
items.add('user', UserDropdown.component({ user: app.session.user }));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "flarum",
|
||||
"dependencies": {
|
||||
"jquery": "2.1.3",
|
||||
"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#next",
|
||||
"loader.js": "~3.2.1",
|
||||
"es6-micro-loader": "caridy/es6-micro-loader#v0.2.1",
|
||||
"es6-promise-polyfill": "~1.0.0",
|
||||
"fastclick": "~1.0.6"
|
||||
}
|
||||
}
|
||||
|
@ -3,13 +3,21 @@ var gulp = require('flarum-gulp');
|
||||
gulp({
|
||||
files: [
|
||||
'node_modules/babel-core/external-helpers.js',
|
||||
'../bower_components/loader.js/loader.js',
|
||||
'../bower_components/es6-promise-polyfill/promise.js',
|
||||
'../bower_components/es6-micro-loader/dist/system-polyfill.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/bootstrap/js/affix.js',
|
||||
'../bower_components/bootstrap/js/dropdown.js',
|
||||
'../bower_components/bootstrap/js/modal.js',
|
||||
'../bower_components/bootstrap/js/tooltip.js',
|
||||
'../bower_components/bootstrap/js/transition.js',
|
||||
|
||||
'../bower_components/spin.js/spin.js',
|
||||
'../bower_components/spin.js/jquery.spin.js',
|
||||
'../bower_components/fastclick/lib/fastclick.js'
|
||||
|
86
js/forum/src/ForumApp.js
Normal file
86
js/forum/src/ForumApp.js
Normal file
@ -0,0 +1,86 @@
|
||||
import History from 'flarum/utils/History';
|
||||
import App from 'flarum/App';
|
||||
import Search from 'flarum/components/Search';
|
||||
import Composer from 'flarum/components/Composer';
|
||||
import ReplyComposer from 'flarum/components/ReplyComposer';
|
||||
import DiscussionPage from 'flarum/components/DiscussionPage';
|
||||
|
||||
export default class ForumApp extends App {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The app's history stack, which keeps track of which routes the user visits
|
||||
* so that they can easily navigate back to the previous route.
|
||||
*
|
||||
* @type {History}
|
||||
*/
|
||||
this.history = new History();
|
||||
|
||||
/**
|
||||
* An object which controls the state of the page's side pane.
|
||||
*
|
||||
* @type {Pane}
|
||||
*/
|
||||
this.pane = null;
|
||||
|
||||
/**
|
||||
* The page's search component instance.
|
||||
*
|
||||
* @type {SearchBox}
|
||||
*/
|
||||
this.search = new Search();
|
||||
|
||||
/**
|
||||
* An object which controls the state of the page's drawer.
|
||||
*
|
||||
* @type {Drawer}
|
||||
*/
|
||||
this.drawer = null;
|
||||
|
||||
/**
|
||||
* A map of post types to their components.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.postComponents = {};
|
||||
|
||||
/**
|
||||
* A map of activity types to their components.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.activityComponents = {};
|
||||
|
||||
/**
|
||||
* A map of notification types to their components.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.notificationComponents = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the user is currently composing a reply to a
|
||||
* discussion.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @return {Boolean}
|
||||
*/
|
||||
composingReplyTo(discussion) {
|
||||
return this.composer.component instanceof ReplyComposer &&
|
||||
this.composer.component.props.discussion === discussion &&
|
||||
this.composer.position !== Composer.PositionEnum.HIDDEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the user is currently viewing a discussion.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @return {Boolean}
|
||||
*/
|
||||
viewingDiscussion(discussion) {
|
||||
return this.current instanceof DiscussionPage &&
|
||||
this.current.discussion === discussion;
|
||||
}
|
||||
}
|
@ -1,26 +1,19 @@
|
||||
import App from 'flarum/utils/app';
|
||||
import ForumApp from 'flarum/ForumApp';
|
||||
import store from 'flarum/initializers/store';
|
||||
import stateHelpers from 'flarum/initializers/state-helpers';
|
||||
import discussionControls from 'flarum/initializers/discussion-controls';
|
||||
import postControls from 'flarum/initializers/post-controls';
|
||||
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 humanTime from 'flarum/initializers/humanTime';
|
||||
import boot from 'flarum/initializers/boot';
|
||||
|
||||
var app = new App();
|
||||
const app = new ForumApp();
|
||||
|
||||
app.initializers.add('store', store);
|
||||
app.initializers.add('state-helpers', stateHelpers);
|
||||
app.initializers.add('discussion-controls', discussionControls);
|
||||
app.initializers.add('post-controls', postControls);
|
||||
app.initializers.add('session', session);
|
||||
app.initializers.add('routes', routes);
|
||||
app.initializers.add('components', components);
|
||||
app.initializers.add('timestamps', timestamps);
|
||||
app.initializers.add('preload', preload, {last: true});
|
||||
app.initializers.add('boot', boot, {last: true});
|
||||
app.initializers.add('humanTime', humanTime);
|
||||
|
||||
app.initializers.add('preload', preload, -100);
|
||||
app.initializers.add('boot', boot, -100);
|
||||
|
||||
export default app;
|
||||
|
57
js/forum/src/components/Activity.js
Normal file
57
js/forum/src/components/Activity.js
Normal file
@ -0,0 +1,57 @@
|
||||
import Component from 'flarum/Component';
|
||||
import humanTime from 'flarum/helpers/humanTime';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
|
||||
/**
|
||||
* The `Activity` component represents a piece of activity of a user's activity
|
||||
* feed. Subclasses should implement the `description` and `content` methods.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `activity`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Activity extends Component {
|
||||
view() {
|
||||
const activity = this.props.activity;
|
||||
|
||||
return (
|
||||
<div className="activity">
|
||||
{avatar(this.user(), {className: 'activity-icon'})}
|
||||
|
||||
<div className="activity-info">
|
||||
<strong>{this.description()}</strong>
|
||||
{humanTime(activity.time())}
|
||||
</div>
|
||||
|
||||
{this.content()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user whose avatar should be displayed.
|
||||
*
|
||||
* @return {User}
|
||||
*/
|
||||
user() {
|
||||
return this.props.activity.user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description of the activity.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
*/
|
||||
description() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content to show below the activity description.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
*/
|
||||
content() {
|
||||
}
|
||||
}
|
140
js/forum/src/components/ActivityPage.js
Normal file
140
js/forum/src/components/ActivityPage.js
Normal file
@ -0,0 +1,140 @@
|
||||
import UserPage from 'flarum/components/UserPage';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
/**
|
||||
* The `ActivityPage` component shows a user's activity feed inside of their
|
||||
* profile.
|
||||
*/
|
||||
export default class ActivityPage extends UserPage {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Whether or not the activity feed is currently loading.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = true;
|
||||
|
||||
/**
|
||||
* Whether or not there are any more activity items that can be loaded.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.moreResults = false;
|
||||
|
||||
/**
|
||||
* The Activity models in the feed.
|
||||
* @type {Activity[]}
|
||||
*/
|
||||
this.activity = [];
|
||||
|
||||
/**
|
||||
* The number of activity items to load per request.
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
this.loadLimit = 20;
|
||||
|
||||
this.loadUser(m.route.param('username'));
|
||||
}
|
||||
|
||||
content() {
|
||||
let footer;
|
||||
|
||||
if (this.loading) {
|
||||
footer = LoadingIndicator.component();
|
||||
} else if (this.moreResults) {
|
||||
footer = (
|
||||
<div className="load-more">
|
||||
{Button.component({
|
||||
children: 'Load More',
|
||||
className: 'btn btn-default',
|
||||
onclick: this.loadMore.bind(this)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="user-activity">
|
||||
<ul className="activity-list">
|
||||
{this.activity.map(activity => {
|
||||
const ActivityComponent = app.activityComponents[activity.contentType()];
|
||||
return ActivityComponent ? <li>{ActivityComponent.component({activity})}</li> : '';
|
||||
})}
|
||||
</ul>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component with a user, and trigger the loading of their
|
||||
* activity feed.
|
||||
*/
|
||||
init(user) {
|
||||
super.init(user);
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear and reload the user's activity feed.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
refresh() {
|
||||
this.loading = true;
|
||||
this.activity = [];
|
||||
|
||||
m.redraw();
|
||||
|
||||
this.loadResults().then(this.parseResults.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a new page of the user's activity feed.
|
||||
*
|
||||
* @param {Integer} [offset] The position to start getting results from.
|
||||
* @return {Promise}
|
||||
* @protected
|
||||
*/
|
||||
loadResults(offset) {
|
||||
return app.store.find('activity', {
|
||||
filter: {
|
||||
user: this.user.id(),
|
||||
type: this.props.filter
|
||||
},
|
||||
page: {offset, limit: this.loadLimit}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the next page of results.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
loadMore() {
|
||||
this.loading = true;
|
||||
this.loadResults(this.activity.length).then(this.parseResults.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse results and append them to the activity feed.
|
||||
*
|
||||
* @param {Activity[]} results
|
||||
* @return {Activity[]}
|
||||
*/
|
||||
parseResults(results) {
|
||||
this.loading = false;
|
||||
|
||||
[].push.apply(this.activity, results);
|
||||
|
||||
this.moreResults = results.length >= this.loadLimit;
|
||||
m.redraw();
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
170
js/forum/src/components/AvatarEditor.js
Normal file
170
js/forum/src/components/AvatarEditor.js
Normal file
@ -0,0 +1,170 @@
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
|
||||
/**
|
||||
* The `AvatarEditor` component displays a user's avatar along with a dropdown
|
||||
* menu which allows the user to upload/remove the avatar.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `className`
|
||||
* - `user`
|
||||
*/
|
||||
export default class AvatarEditor extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Whether or not an avatar upload is in progress.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = props.className || '';
|
||||
}
|
||||
|
||||
view() {
|
||||
const user = this.props.user;
|
||||
|
||||
return (
|
||||
<div className={'avatar-editor dropdown ' + this.props.className + (this.loading ? ' loading' : '')}>
|
||||
{avatar(user)}
|
||||
<a className="dropdown-toggle"
|
||||
href="javascript:;"
|
||||
data-toggle="dropdown"
|
||||
onclick={this.quickUpload.bind(this)}>
|
||||
{this.loading ? LoadingIndicator.component() : icon('pencil', {className: 'icon'})}
|
||||
</a>
|
||||
<ul className="dropdown-menu">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the items in the edit avatar dropdown menu.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
controlItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('upload',
|
||||
Button.component({
|
||||
icon: 'upload',
|
||||
children: 'Upload',
|
||||
onclick: this.upload.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
items.add('remove',
|
||||
Button.component({
|
||||
icon: 'times',
|
||||
children: 'Remove',
|
||||
onclick: this.remove.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user doesn't have an avatar, there's no point in showing the
|
||||
* controls dropdown, because only one option would be viable: uploading.
|
||||
* Thus, when the avatar editor's dropdown toggle button is clicked, we prompt
|
||||
* the user to upload an avatar immediately.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
quickUpload(e) {
|
||||
if (!this.props.user.avatarUrl()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.upload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user to upload a new avatar.
|
||||
*/
|
||||
upload() {
|
||||
if (this.loading) return;
|
||||
|
||||
// Create a hidden HTML input element and click on it so the user can select
|
||||
// an avatar file. Once they have, we will upload it via the API.
|
||||
const user = this.props.user;
|
||||
const $input = $('<input type="file">');
|
||||
|
||||
$input.appendTo('body').hide().click().on('change', e => {
|
||||
const data = new FormData();
|
||||
data.append('avatar', $(e.target)[0].files[0]);
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar',
|
||||
serialize: raw => raw,
|
||||
data
|
||||
}).then(
|
||||
this.success.bind(this),
|
||||
this.failure.bind(this)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the user's avatar.
|
||||
*/
|
||||
remove() {
|
||||
const user = this.props.user;
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl') + '/users/' + user.id() + '/avatar'
|
||||
}).then(
|
||||
this.success.bind(this),
|
||||
this.failure.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful upload/removal, push the updated user data into the
|
||||
* store, and force a recomputation of the user's avatar color.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
success(response) {
|
||||
app.store.pushPayload(response);
|
||||
delete this.props.user.avatarColor;
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* If avatar upload/removal fails, stop loading.
|
||||
*
|
||||
* @param {Object} response
|
||||
* @protected
|
||||
*/
|
||||
failure() {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
91
js/forum/src/components/ChangeEmailModal.js
Normal file
91
js/forum/src/components/ChangeEmailModal.js
Normal file
@ -0,0 +1,91 @@
|
||||
import Modal from 'flarum/components/Modal';
|
||||
|
||||
/**
|
||||
* The `ChangeEmailModal` component shows a modal dialog which allows the user
|
||||
* to change their email address.
|
||||
*/
|
||||
export default class ChangeEmailModal extends Modal {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Whether or not the email has been changed successfully.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.success = false;
|
||||
|
||||
/**
|
||||
* The value of the email input.
|
||||
*
|
||||
* @type {function}
|
||||
*/
|
||||
this.email = m.prop(app.session.user.email());
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'modal-sm change-email-modal';
|
||||
}
|
||||
|
||||
title() {
|
||||
return 'Change Email';
|
||||
}
|
||||
|
||||
content() {
|
||||
if (this.success) {
|
||||
const emailProviderName = this.email().split('@')[1];
|
||||
|
||||
return (
|
||||
<div className="modal-body">
|
||||
<div class="form-centered">
|
||||
<p class="help-text">We've sent a confirmation email to <strong>{this.email()}</strong>. If it doesn't arrive soon, check your spam folder.</p>
|
||||
<div class="form-group">
|
||||
<a href={'http://' + emailProviderName} className="btn btn-primary btn-block">Go to {emailProviderName}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-body">
|
||||
<div class="form-centered">
|
||||
<div class="form-group">
|
||||
<input type="email" name="email" className="form-control"
|
||||
placeholder={app.session.user.email()}
|
||||
value={this.email()}
|
||||
onchange={m.withAttr('value', this.email)}
|
||||
disabled={this.loading}/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" className="btn btn-primary btn-block" disabled={this.loading}>Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// If the user hasn't actually entered a different email address, we don't
|
||||
// need to do anything. Woot!
|
||||
if (this.email() === app.session.user.email()) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
app.session.user.save({email: this.email()}).then(
|
||||
() => {
|
||||
this.loading = false;
|
||||
this.success = true;
|
||||
m.redraw();
|
||||
},
|
||||
() => {
|
||||
this.loading = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
43
js/forum/src/components/ChangePasswordModal.js
Normal file
43
js/forum/src/components/ChangePasswordModal.js
Normal file
@ -0,0 +1,43 @@
|
||||
import Modal from 'flarum/components/Modal';
|
||||
|
||||
/**
|
||||
* The `ChangePasswordModal` component shows a modal dialog which allows the
|
||||
* user to send themself a password reset email.
|
||||
*/
|
||||
export default class ChangePasswordModal extends Modal {
|
||||
className() {
|
||||
return 'modal-sm change-password-modal';
|
||||
}
|
||||
|
||||
title() {
|
||||
return 'Change Password';
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="modal-body">
|
||||
<div className="form-centered">
|
||||
<p className="help-text">Click the button below and check your email for a link to change your password.</p>
|
||||
<div className="form-group">
|
||||
<button type="submit" className="btn btn-primary btn-block" disabled={this.loading}>Send Password Reset Email</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/forgot',
|
||||
data: {email: app.session.user.email()}
|
||||
}).then(
|
||||
() => this.hide(),
|
||||
() => this.loading = false
|
||||
);
|
||||
}
|
||||
}
|
120
js/forum/src/components/CommentPost.js
Normal file
120
js/forum/src/components/CommentPost.js
Normal file
@ -0,0 +1,120 @@
|
||||
import Post from 'flarum/components/Post';
|
||||
import classList from 'flarum/utils/classList';
|
||||
import PostUser from 'flarum/components/PostUser';
|
||||
import PostMeta from 'flarum/components/PostMeta';
|
||||
import PostEdited from 'flarum/components/PostEdited';
|
||||
import EditPostComposer from 'flarum/components/EditPostComposer';
|
||||
import Composer from 'flarum/components/Composer';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
/**
|
||||
* The `CommentPost` component displays a standard `comment`-typed post. This
|
||||
* includes a number of item lists (controls, header, and footer) surrounding
|
||||
* the post's HTML content.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class CommentPost extends Post {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* If the post has been hidden, then this flag determines whether or not its
|
||||
* content has been expanded.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.revealContent = false;
|
||||
|
||||
// Create an instance of the component that displays the post's author so
|
||||
// that we can force the post to rerender when the user card is shown.
|
||||
this.postUser = new PostUser({post: this.props.post});
|
||||
this.subtree.check(() => this.postUser.cardVisible);
|
||||
}
|
||||
|
||||
content() {
|
||||
return [
|
||||
<header className="post-header"><ul>{listItems(this.headerItems().toArray())}</ul></header>,
|
||||
<div className="post-body">{m.trust(this.props.post.contentHtml())}</div>,
|
||||
<aside className="post-footer"><ul>{listItems(this.footerItems().toArray())}</ul></aside>,
|
||||
<aside className="post-actions"><ul>{listItems(this.actionItems().toArray())}</ul></aside>
|
||||
];
|
||||
}
|
||||
|
||||
attrs() {
|
||||
const post = this.props.post;
|
||||
|
||||
return {
|
||||
className: classList({
|
||||
'comment-post': true,
|
||||
'is-hidden': post.isHidden(),
|
||||
'is-edited': post.isEdited(),
|
||||
'reveal-content': this.revealContent,
|
||||
'editing': app.composer.component instanceof EditPostComposer &&
|
||||
app.composer.component.props.post === post &&
|
||||
app.composer.position !== Composer.PositionEnum.MINIMIZED
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility of a hidden post's content.
|
||||
*/
|
||||
toggleContent() {
|
||||
this.revealContent = !this.revealContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the post's header.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
headerItems() {
|
||||
const items = new ItemList();
|
||||
const post = this.props.post;
|
||||
const props = {post};
|
||||
|
||||
items.add('user', this.postUser.render(), 100);
|
||||
items.add('meta', PostMeta.component(props));
|
||||
|
||||
if (post.isEdited() && !post.isHidden()) {
|
||||
items.add('edited', PostEdited.component(props));
|
||||
}
|
||||
|
||||
// If the post is hidden, add a button that allows toggling the visibility
|
||||
// of the post's content.
|
||||
if (post.isHidden()) {
|
||||
items.add('toggle', (
|
||||
<button
|
||||
className="btn btn-default btn-more"
|
||||
onclick={this.toggleContent.bind(this)}>
|
||||
{icon('ellipsis-h')}
|
||||
</button>
|
||||
));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the post's footer.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
footerItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the post's actions.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
actionItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
107
js/forum/src/components/ComposerBody.js
Normal file
107
js/forum/src/components/ComposerBody.js
Normal file
@ -0,0 +1,107 @@
|
||||
import Component from 'flarum/Component';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import TextEditor from 'flarum/components/TextEditor';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `ComposerBody` component handles the body, or the content, of the
|
||||
* composer. Subclasses should implement the `onsubmit` method and override
|
||||
* `headerTimes`.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `originalContent`
|
||||
* - `submitLabel`
|
||||
* - `placeholder`
|
||||
* - `user`
|
||||
* - `confirmExit`
|
||||
* - `disabled`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class ComposerBody extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
/**
|
||||
* Whether or not the component is loading.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = false;
|
||||
|
||||
/**
|
||||
* The content of the text editor.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.content = m.prop(this.props.originalContent);
|
||||
|
||||
/**
|
||||
* The text editor component instance.
|
||||
*
|
||||
* @type {TextEditor}
|
||||
*/
|
||||
this.editor = new TextEditor({
|
||||
submitLabel: this.props.submitLabel,
|
||||
placeholder: this.props.placeholder,
|
||||
onchange: this.content,
|
||||
onsubmit: this.onsubmit.bind(this),
|
||||
value: this.content()
|
||||
});
|
||||
}
|
||||
|
||||
view() {
|
||||
// If the component is loading, we should disable the text editor.
|
||||
this.editor.props.disabled = this.loading;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{avatar(this.props.user, {className: 'composer-avatar'})}
|
||||
<div className="composer-body">
|
||||
<ul className="composer-header">{listItems(this.headerItems().toArray())}</ul>
|
||||
<div className="composer-editor">{this.editor.render()}</div>
|
||||
</div>
|
||||
{LoadingIndicator.component({className: 'composer-loading' + (this.loading ? ' active' : '')})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw focus to the text editor.
|
||||
*/
|
||||
focus() {
|
||||
this.$(':input:enabled:visible:first').focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is any unsaved data – if there is, return a confirmation
|
||||
* message to prompt the user with.
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
preventExit() {
|
||||
const content = this.content();
|
||||
|
||||
return content && content !== this.props.originalContent && this.props.confirmExit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the composer's header.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
headerItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the submit event of the text editor.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
onsubmit() {
|
||||
}
|
||||
}
|
13
js/forum/src/components/ComposerButton.js
Normal file
13
js/forum/src/components/ComposerButton.js
Normal file
@ -0,0 +1,13 @@
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
/**
|
||||
* The `ComposerButton` component displays a button suitable for the composer
|
||||
* controls.
|
||||
*/
|
||||
export default class ComposerButton extends Button {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.className = props.className || 'btn btn-icon btn-link';
|
||||
}
|
||||
}
|
67
js/forum/src/components/DeleteAccountModal.js
Normal file
67
js/forum/src/components/DeleteAccountModal.js
Normal file
@ -0,0 +1,67 @@
|
||||
import Modal from 'flarum/components/Modal';
|
||||
|
||||
/**
|
||||
* The `DeleteAccountModal` component shows a modal dialog which allows the user
|
||||
* to delete their account.
|
||||
*
|
||||
* @todo require typing password instead of DELETE
|
||||
*/
|
||||
export default class DeleteAccountModal extends Modal {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
/**
|
||||
* The value of the confirmation input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.confirmation = m.prop();
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'modal-sm delete-account-modal';
|
||||
}
|
||||
|
||||
title() {
|
||||
return 'Delete Account';
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="modal-body">
|
||||
<div className="form-centered">
|
||||
<div className="help-text">
|
||||
<p>Hold up! If you delete your account, there's no going back. Keep in mind:</p>
|
||||
<ul>
|
||||
<li>Your username will be released, so someone else will be able to sign up with your name.</li>
|
||||
<li>All of your posts will remain, but no longer associated with your account.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input className="form-control"
|
||||
name="confirm"
|
||||
placeholder="Type "DELETE" to proceed"
|
||||
oninput={m.withAttr('value', this.confirmation)}/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<button type="submit"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={this.loading || this.confirmation() !== 'DELETE'}>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.confirmation() !== 'DELETE') return;
|
||||
|
||||
this.loading = true;
|
||||
|
||||
app.session.user.delete().then(() => app.session.logout());
|
||||
}
|
||||
}
|
114
js/forum/src/components/DiscussionComposer.js
Normal file
114
js/forum/src/components/DiscussionComposer.js
Normal file
@ -0,0 +1,114 @@
|
||||
import ComposerBody from 'flarum/components/ComposerBody';
|
||||
|
||||
/**
|
||||
* The `DiscussionComposer` component displays the composer content for starting
|
||||
* a new discussion. It adds a text field as a header control so the user can
|
||||
* enter the title of their discussion. It also overrides the `submit` and
|
||||
* `willExit` actions to account for the title.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - All of the props for ComposerBody
|
||||
* - `titlePlaceholder`
|
||||
*/
|
||||
export default class DiscussionComposer extends ComposerBody {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The value of the title input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.title = m.prop('');
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.placeholder = props.placeholder || 'Write a Post...';
|
||||
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';
|
||||
}
|
||||
|
||||
headerItems() {
|
||||
const items = super.headerItems();
|
||||
|
||||
items.add('title', (
|
||||
<h3>
|
||||
<input className="form-control"
|
||||
value={this.title()}
|
||||
oninput={m.withAttr('value', this.title)}
|
||||
placeholder={this.props.titlePlaceholder}
|
||||
disabled={!!this.props.disabled}
|
||||
onkeydown={this.onkeydown}/>
|
||||
</h3>
|
||||
));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the title input's keydown event. When the return key is pressed,
|
||||
* move the focus to the start of the text editor.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
onkeydown(e) {
|
||||
if (e.which === 13) { // Return
|
||||
e.preventDefault();
|
||||
this.editor.setSelectionRange(0, 0);
|
||||
}
|
||||
|
||||
m.redraw.strategy('none');
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
super.config(isInitialized, context);
|
||||
|
||||
// If the user presses the backspace key in the text editor, and the cursor
|
||||
// is already at the start, then we'll move the focus back into the title
|
||||
// input.
|
||||
this.editor.$('textarea').keydown((e) => {
|
||||
if (e.which === 8 && e.target.selectionStart === 0 && e.target.selectionEnd === 0) {
|
||||
e.preventDefault();
|
||||
|
||||
const $title = this.$(':input:enabled:visible:first')[0];
|
||||
$title.focus();
|
||||
$title.selectionStart = $title.selectionEnd = $title.value.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
preventExit() {
|
||||
return (this.title() || this.content()) && this.props.confirmExit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to submit to the server when the discussion is saved.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
data() {
|
||||
return {
|
||||
title: this.title(),
|
||||
content: this.content()
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit() {
|
||||
this.loading = true;
|
||||
|
||||
const data = this.data();
|
||||
|
||||
app.store.createRecord('discussions').save(data).then(
|
||||
discussion => {
|
||||
app.composer.hide();
|
||||
app.cache.discussionList.addDiscussion(discussion);
|
||||
m.route(app.route.discussion(discussion));
|
||||
},
|
||||
() => this.loading = false
|
||||
);
|
||||
}
|
||||
}
|
41
js/forum/src/components/DiscussionHero.js
Normal file
41
js/forum/src/components/DiscussionHero.js
Normal file
@ -0,0 +1,41 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `DiscussionHero` component displays the hero on a discussion page.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `discussion`
|
||||
*/
|
||||
export default class DiscussionHero extends Component {
|
||||
view() {
|
||||
return (
|
||||
<header className="hero discussion-hero">
|
||||
<div className="container">
|
||||
<ul className="discussion-hero-items">{listItems(this.items().toArray())}</ul>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the contents of the discussion hero.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
const discussion = this.props.discussion;
|
||||
const badges = discussion.badges().toArray();
|
||||
|
||||
if (badges.length) {
|
||||
items.add('badges', <ul className="badges">{listItems(badges)}</ul>);
|
||||
}
|
||||
|
||||
items.add('title', <h2 className="discussion-title">{discussion.title()}</h2>);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
219
js/forum/src/components/DiscussionList.js
Normal file
219
js/forum/src/components/DiscussionList.js
Normal file
@ -0,0 +1,219 @@
|
||||
import Component from 'flarum/Component';
|
||||
import DiscussionListItem from 'flarum/components/DiscussionListItem';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
|
||||
/**
|
||||
* The `DiscussionList` component displays a list of discussions.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `params` A map of parameters used to construct a refined parameter object
|
||||
* to send along in the API request to get discussion results.
|
||||
*/
|
||||
export default class DiscussionList extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Whether or not discussion results are loading.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = true;
|
||||
|
||||
/**
|
||||
* Whether or not there are more results that can be loaded.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.moreResults = false;
|
||||
|
||||
/**
|
||||
* The discussions in the discussion list.
|
||||
*
|
||||
* @type {Discussion[]}
|
||||
*/
|
||||
this.discussions = [];
|
||||
|
||||
this.refresh();
|
||||
|
||||
app.session.on('loggedIn', this.loggedInHandler = this.refresh.bind(this));
|
||||
}
|
||||
|
||||
onunload() {
|
||||
app.session.off('loggedIn', this.loggedInHandler);
|
||||
}
|
||||
|
||||
view() {
|
||||
const params = this.props.params;
|
||||
let loading;
|
||||
|
||||
if (this.loading) {
|
||||
loading = LoadingIndicator.component();
|
||||
} else if (this.moreResults) {
|
||||
loading = (
|
||||
<div className="load-more">
|
||||
{Button.component({
|
||||
children: 'Load More',
|
||||
className: 'btn btn-default',
|
||||
onclick: this.loadMore.bind(this)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="discussion-list">
|
||||
<ul>
|
||||
{this.discussions.map(discussion => {
|
||||
return (
|
||||
<li key={discussion.id()} data-id={discussion.id()}>
|
||||
{DiscussionListItem.component({discussion, params})}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{loading}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parameters that should be passed in the API request to get
|
||||
* discussion results.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
params() {
|
||||
const params = Object.assign({include: ['startUser', 'lastUser']}, this.props.params);
|
||||
|
||||
params.sort = this.sortMap()[params.sort];
|
||||
|
||||
if (params.q) {
|
||||
params.filter = params.filter || {};
|
||||
params.filter.q = params.q;
|
||||
delete params.q;
|
||||
|
||||
params.include.push('relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user');
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a map of sort keys (which appear in the URL, and are used for
|
||||
* translation) to the API sort value that they represent.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
sortMap() {
|
||||
const map = {};
|
||||
|
||||
if (this.props.params.q) {
|
||||
map.relevance = '';
|
||||
}
|
||||
map.recent = '-lastTime';
|
||||
map.replies = '-commentsCount';
|
||||
map.newest = '-startTime';
|
||||
map.oldest = '+startTime';
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear and reload the discussion list.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
refresh() {
|
||||
this.loading = true;
|
||||
this.discussions = [];
|
||||
|
||||
this.loadResults().then(
|
||||
this.parseResults.bind(this),
|
||||
() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a new page of discussion results.
|
||||
*
|
||||
* @param {Integer} offset The index to start the page at.
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadResults(offset) {
|
||||
const preloadedDiscussions = app.preloadedDocument();
|
||||
|
||||
if (preloadedDiscussions) {
|
||||
return m.deferred().resolve(preloadedDiscussions).promise;
|
||||
}
|
||||
|
||||
const params = this.params();
|
||||
params.page = {offset};
|
||||
params.include = params.include.join(',');
|
||||
|
||||
return app.store.find('discussions', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the next page of discussion results.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
loadMore() {
|
||||
this.loading = true;
|
||||
|
||||
this.loadResults(this.discussions.length)
|
||||
.then(this.parseResults.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse results and append them to the discussion list.
|
||||
*
|
||||
* @param {Discussion[]} results
|
||||
* @return {Discussion[]}
|
||||
*/
|
||||
parseResults(results) {
|
||||
[].push.apply(this.discussions, results);
|
||||
|
||||
this.loading = false;
|
||||
this.moreResults = !!results.payload.links.next;
|
||||
|
||||
// Since this may be called during the component's constructor, i.e. in the
|
||||
// middle of a redraw, forcing another redraw would not bode well. Instead
|
||||
// we start/end a computation so Mithril will only redraw if it isn't
|
||||
// already doing so.
|
||||
m.startComputation();
|
||||
m.endComputation();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a discussion from the list if it is present.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @public
|
||||
*/
|
||||
removeDiscussion(discussion) {
|
||||
const index = this.discussions.indexOf(discussion);
|
||||
|
||||
if (index !== -1) {
|
||||
this.discussions.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a discussion to the top of the list.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
* @public
|
||||
*/
|
||||
addDiscussion(discussion) {
|
||||
this.discussions.unshift(discussion);
|
||||
}
|
||||
}
|
178
js/forum/src/components/DiscussionListItem.js
Normal file
178
js/forum/src/components/DiscussionListItem.js
Normal file
@ -0,0 +1,178 @@
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import humanTime from 'flarum/utils/humanTime';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import abbreviateNumber from 'flarum/utils/abbreviateNumber';
|
||||
import Dropdown from 'flarum/components/Dropdown';
|
||||
import TerminalPost from 'flarum/components/TerminalPost';
|
||||
import PostPreview from 'flarum/components/PostPreview';
|
||||
import SubtreeRetainer from 'flarum/utils/SubtreeRetainer';
|
||||
import DiscussionControls from 'flarum/utils/DiscussionControls';
|
||||
import slidable from 'flarum/utils/slidable';
|
||||
|
||||
/**
|
||||
* The `DiscussionListItem` component shows a single discussion in the
|
||||
* discussion list.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `discussion`
|
||||
* - `params`
|
||||
*/
|
||||
export default class DiscussionListItem extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Set up a subtree retainer so that the discussion will not be redrawn
|
||||
* unless new data comes in.
|
||||
*
|
||||
* @type {SubtreeRetainer}
|
||||
*/
|
||||
this.subtree = new SubtreeRetainer(
|
||||
() => this.props.discussion.freshness,
|
||||
() => app.session.user && app.session.user.readTime(),
|
||||
() => this.active()
|
||||
);
|
||||
}
|
||||
|
||||
view() {
|
||||
const discussion = this.props.discussion;
|
||||
const startUser = discussion.startUser();
|
||||
const isUnread = discussion.isUnread();
|
||||
const showUnread = !this.showRepliesCount() && isUnread;
|
||||
const jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1);
|
||||
const relevantPosts = this.props.params.q ? discussion.relevantPosts() : '';
|
||||
const controls = DiscussionControls.controls(discussion, this).toArray();
|
||||
|
||||
return this.subtree.retain() || (
|
||||
<div className={'discussion-list-item' + (this.active() ? ' active' : '')}>
|
||||
|
||||
{controls.length ? Dropdown.component({
|
||||
icon: 'ellipsis-v',
|
||||
children: controls,
|
||||
className: 'contextual-controls',
|
||||
buttonClassName: 'btn btn-default btn-naked btn-controls slidable-underneath slidable-underneath-right',
|
||||
menuClassName: 'dropdown-menu-right'
|
||||
}) : ''}
|
||||
|
||||
<a className={'slidable-underneath slidable-underneath-left elastic' + (isUnread ? '' : ' disabled')}
|
||||
onclick={this.markAsRead.bind(this)}>
|
||||
{icon('check', {className: 'icon'})}
|
||||
</a>
|
||||
|
||||
<div className={'discussion-summary slidable-slider' + (isUnread ? ' unread' : '')}>
|
||||
<a href={startUser ? app.route.user(startUser) : '#'}
|
||||
className="author"
|
||||
title={'Started by ' + (startUser ? startUser.username() : '[deleted]') + ' ' + humanTime(discussion.startTime())}
|
||||
config={function(element) {
|
||||
$(element).tooltip({placement: 'right'});
|
||||
m.route.apply(this, arguments);
|
||||
}}>
|
||||
{avatar(startUser, {title: ''})}
|
||||
</a>
|
||||
|
||||
<ul className="badges">{listItems(discussion.badges().toArray())}</ul>
|
||||
|
||||
<a href={app.route.discussion(discussion, jumpTo)}
|
||||
config={m.route}
|
||||
className="main">
|
||||
<h3 className="title">{highlight(discussion.title(), this.props.params.q)}</h3>
|
||||
<ul className="info">{listItems(this.infoItems().toArray())}</ul>
|
||||
</a>
|
||||
|
||||
<span className="count"
|
||||
onclick={this.markAsRead.bind(this)}
|
||||
title={showUnread ? 'Mark as Read' : ''}>
|
||||
{abbreviateNumber(discussion[showUnread ? 'unreadCount' : 'repliesCount']())}
|
||||
</span>
|
||||
|
||||
{relevantPosts && relevantPosts.length
|
||||
? <div className="relevant-posts">
|
||||
{relevantPosts.map(post => PostPreview.component({post, highlight: this.props.params.q}))}
|
||||
</div>
|
||||
: ''}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
// If we're on a touch device, set up the discussion row to be slidable.
|
||||
// This allows the user to drag the row to either side of the screen to
|
||||
// reveal controls.
|
||||
if ('ontouchstart' in window) {
|
||||
const slidableInstance = slidable(this.$().addClass('slidable'));
|
||||
|
||||
this.$('.contextual-controls')
|
||||
.on('hidden.bs.dropdown', () => slidableInstance.reset());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the discussion is currently being viewed.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
active() {
|
||||
return m.route.param('id') === this.props.discussion.id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not information about who started the discussion
|
||||
* should be displayed instead of information about the most recent reply to
|
||||
* the discussion.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
showStartPost() {
|
||||
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether or not the number of replies should be shown instead of
|
||||
* the number of unread posts.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
showRepliesCount() {
|
||||
return this.props.params.sort === 'replies';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the discussion as read.
|
||||
*/
|
||||
markAsRead() {
|
||||
const discussion = this.props.discussion;
|
||||
|
||||
if (discussion.isUnread()) {
|
||||
discussion.save({readNumber: discussion.lastPostNumber()});
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of info for a discussion listing. By default this is
|
||||
* just the first/last post indicator.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
infoItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('terminalPost',
|
||||
TerminalPost.component({
|
||||
discussion: this.props.discussion,
|
||||
lastPost: !this.showStartPost()
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
309
js/forum/src/components/DiscussionPage.js
Normal file
309
js/forum/src/components/DiscussionPage.js
Normal file
@ -0,0 +1,309 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import DiscussionHero from 'flarum/components/DiscussionHero';
|
||||
import PostStream from 'flarum/components/PostStream';
|
||||
import PostStreamScrubber from 'flarum/components/PostStreamScrubber';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import SplitDropdown from 'flarum/components/SplitDropdown';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import mixin from 'flarum/utils/mixin';
|
||||
import evented from 'flarum/utils/evented';
|
||||
import DiscussionControls from 'flarum/utils/DiscussionControls';
|
||||
|
||||
/**
|
||||
* The `DiscussionPage` component displays a whole discussion page, including
|
||||
* the discussion list pane, the hero, the posts, and the sidebar.
|
||||
*/
|
||||
export default class DiscussionPage extends mixin(Component, evented) {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The discussion that is being viewed.
|
||||
*
|
||||
* @type {Discussion}
|
||||
*/
|
||||
this.discussion = null;
|
||||
|
||||
/**
|
||||
* The number of the first post that is currently visible in the viewport.
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
this.near = null;
|
||||
|
||||
this.refresh();
|
||||
|
||||
// If the discussion list has been loaded, then we'll enable the pane (and
|
||||
// hide it by default). Also, if we've just come from another discussion
|
||||
// page, then we don't want Mithril to redraw the whole page – if it did,
|
||||
// then the pane would which would be slow and would cause problems with
|
||||
// event handlers.
|
||||
if (app.cache.discussionList) {
|
||||
app.pane.enable();
|
||||
app.pane.hide();
|
||||
|
||||
if (app.current instanceof DiscussionPage) {
|
||||
m.redraw.strategy('diff');
|
||||
}
|
||||
}
|
||||
|
||||
// Push onto the history stack, but use a generalised key so that navigating
|
||||
// to a few different discussions won't override the behaviour of the back
|
||||
// button.
|
||||
app.history.push('discussion');
|
||||
app.current = this;
|
||||
|
||||
app.session.on('loggedIn', this.loggedInHandler = this.refresh.bind(this));
|
||||
}
|
||||
|
||||
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.
|
||||
if (this.discussion) {
|
||||
if (m.route.param('id') === this.discussion.id()) {
|
||||
e.preventDefault();
|
||||
|
||||
if (Number(m.route.param('near')) !== Number(this.near)) {
|
||||
this.stream.goToNumber(m.route.param('near') || 1);
|
||||
}
|
||||
|
||||
this.near = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we are indeed navigating away from this discussion, then disable the
|
||||
// discussion list pane. Also, if we're composing a reply to this
|
||||
// discussion, minimize the composer – unless it's empty, in which case
|
||||
// we'll just close it.
|
||||
app.pane.disable();
|
||||
app.session.off('loggedIn', this.loggedInHandler);
|
||||
|
||||
if (app.composingReplyTo(this.discussion) && !app.composer.component.content()) {
|
||||
app.composer.hide();
|
||||
} else {
|
||||
app.composer.minimize();
|
||||
}
|
||||
}
|
||||
|
||||
view() {
|
||||
const discussion = this.discussion;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{app.cache.discussionList
|
||||
? <div className="index-area paned" config={this.configPane.bind(this)}>
|
||||
{app.cache.discussionList.render()}
|
||||
</div>
|
||||
: ''}
|
||||
|
||||
<div className="discussion-area">
|
||||
{discussion
|
||||
? [
|
||||
DiscussionHero.component({discussion}),
|
||||
<div className="container">
|
||||
<nav className="discussion-nav">
|
||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||
</nav>
|
||||
{this.stream.render()}
|
||||
</div>
|
||||
]
|
||||
: LoadingIndicator.component({className: 'loading-indicator-block'})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
context.retain = true;
|
||||
|
||||
$('body').addClass('discussion-page');
|
||||
context.onunload = () => $('body').removeClass('discussion-page');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear and reload the discussion.
|
||||
*/
|
||||
refresh() {
|
||||
this.near = m.route.param('near') || 0;
|
||||
this.discussion = null;
|
||||
|
||||
const preloadedDiscussion = app.preloadedDocument();
|
||||
if (preloadedDiscussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
// component for the first time on page load, then any calls to m.redraw
|
||||
// will be ineffective and thus any configs (scroll code) will be run
|
||||
// before stuff is drawn to the page.
|
||||
setTimeout(this.init.bind(this, preloadedDiscussion));
|
||||
} else {
|
||||
const params = this.params();
|
||||
params.include = params.include.join(',');
|
||||
|
||||
app.store.find('discussions', m.route.param('id'), params)
|
||||
.then(this.init.bind(this));
|
||||
}
|
||||
|
||||
// Since this may be called during the component's constructor, i.e. in the
|
||||
// middle of a redraw, forcing another redraw would not bode well. Instead
|
||||
// we start/end a computation so Mithril will only redraw if it isn't
|
||||
// already doing so.
|
||||
m.startComputation();
|
||||
m.endComputation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parameters that should be passed in the API request to get the
|
||||
* discussion.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
params() {
|
||||
return {
|
||||
page: {near: this.near},
|
||||
include: ['posts', 'posts.user', 'posts.user.groups']
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component to display the given discussion.
|
||||
*
|
||||
* @param {Discussion} discussion
|
||||
*/
|
||||
init(discussion) {
|
||||
// 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()) {
|
||||
m.route(app.route.discussion(discussion, m.route.param('near')), null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.discussion = discussion;
|
||||
|
||||
app.setTitle(discussion.title());
|
||||
|
||||
// When the API responds with a discussion, it will also include a number of
|
||||
// posts. Some of these posts are included because they are on the first
|
||||
// page of posts we want to display (determined by the `near` parameter) –
|
||||
// others may be included because due to other relationships introduced by
|
||||
// extensions. We need to distinguish the two so we don't end up displaying
|
||||
// the wrong posts. We do so by filtering out the posts that don't have
|
||||
// the 'discussion' relationship linked, then sorting and splicing.
|
||||
let includedPosts = [];
|
||||
if (discussion.payload && discussion.payload.included) {
|
||||
includedPosts = discussion.payload.included
|
||||
.filter(record => record.type === 'posts' && record.relationships && record.relationships.discussion)
|
||||
.map(record => app.store.getById('posts', record.id))
|
||||
.sort((a, b) => a.id() - b.id())
|
||||
.splice(20);
|
||||
}
|
||||
|
||||
// Set up the post stream for this discussion, along with the first page of
|
||||
// posts we want to display. Tell the stream to scroll down and highlight
|
||||
// the specific post that was routed to.
|
||||
this.stream = new PostStream({discussion, includedPosts});
|
||||
this.stream.on('positionChanged', this.positionChanged.bind(this));
|
||||
this.stream.goToNumber(m.route.param('near') || 1, true);
|
||||
|
||||
this.trigger('loaded', discussion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the discussion list pane.
|
||||
*
|
||||
* @param {DOMElement} element
|
||||
* @param {Boolean} isInitialized
|
||||
* @param {Object} context
|
||||
*/
|
||||
configPane(element, isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
context.retain = true;
|
||||
|
||||
const $list = $(element);
|
||||
|
||||
// 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.
|
||||
const pane = app.pane;
|
||||
$list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
|
||||
|
||||
const hotEdge = e => {
|
||||
if (e.pageX < 10) pane.show();
|
||||
};
|
||||
$(document).on('mousemove', hotEdge);
|
||||
context.onunload = () => $(document).off('mousemove', hotEdge);
|
||||
|
||||
// If the discussion we are viewing is listed in the discussion list, then
|
||||
// we will make sure it is visible in the viewport – if it is not we will
|
||||
// scroll the list down to it.
|
||||
const $discussion = $list.find('.discussion-list-item.active');
|
||||
if ($discussion.length) {
|
||||
const listTop = $list.offset().top;
|
||||
const listBottom = listTop + $list.outerHeight();
|
||||
const discussionTop = $discussion.offset().top;
|
||||
const discussionBottom = discussionTop + $discussion.outerHeight();
|
||||
|
||||
if (discussionTop < listTop || discussionBottom > listBottom) {
|
||||
$list.scrollTop($list.scrollTop() - listTop + discussionTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the contents of the sidebar.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
sidebarItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('controls',
|
||||
SplitDropdown.component({
|
||||
children: DiscussionControls.controls(this.discussion, this).toArray(),
|
||||
icon: 'ellipsis-v',
|
||||
className: 'primary-control',
|
||||
buttonClassName: 'btn btn-primary'
|
||||
})
|
||||
);
|
||||
|
||||
items.add('scrubber',
|
||||
PostStreamScrubber.component({
|
||||
stream: this.stream,
|
||||
className: 'title-control'
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the posts that are visible in the post stream change (i.e. the user
|
||||
* scrolls up or down), then we update the URL and mark the posts as read.
|
||||
*
|
||||
* @param {Integer} startNumber
|
||||
* @param {Integer} endNumber
|
||||
*/
|
||||
positionChanged(startNumber, endNumber) {
|
||||
const discussion = this.discussion;
|
||||
|
||||
// Construct a URL to this discussion with the updated position, then
|
||||
// replace it into the window's history and our own history stack.
|
||||
const url = app.route.discussion(discussion, this.near = startNumber);
|
||||
|
||||
m.route(url, true);
|
||||
window.history.replaceState(null, document.title, url);
|
||||
|
||||
app.history.push('discussion');
|
||||
|
||||
// If the user hasn't read past here before, then we'll update their read
|
||||
// state and redraw.
|
||||
if (app.session.user && endNumber > (discussion.readNumber() || 0)) {
|
||||
discussion.save({readNumber: endNumber});
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
}
|
26
js/forum/src/components/DiscussionRenamedNotification.js
Normal file
26
js/forum/src/components/DiscussionRenamedNotification.js
Normal file
@ -0,0 +1,26 @@
|
||||
import Notification from 'flarum/components/Notification';
|
||||
import username from 'flarum/helpers/username';
|
||||
|
||||
/**
|
||||
* The `DiscussionRenamedNotification` component displays a notification which
|
||||
* indicates that a discussion has had its title changed.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - All of the props for Notification
|
||||
*/
|
||||
export default class DiscussionRenamedNotification extends Notification {
|
||||
icon() {
|
||||
return 'pencil';
|
||||
}
|
||||
|
||||
href() {
|
||||
const notification = this.props.notification;
|
||||
|
||||
return app.route.discussion(notification.subject(), notification.content().postNumber);
|
||||
}
|
||||
|
||||
content() {
|
||||
return [username(this.props.notification.sender()), ' changed the title'];
|
||||
}
|
||||
}
|
23
js/forum/src/components/DiscussionRenamedPost.js
Normal file
23
js/forum/src/components/DiscussionRenamedPost.js
Normal file
@ -0,0 +1,23 @@
|
||||
import EventPost from 'flarum/components/EventPost';
|
||||
|
||||
/**
|
||||
* The `DiscussionRenamedPost` component displays a discussion event post
|
||||
* indicating that the discussion has been renamed.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - All of the props for EventPost
|
||||
*/
|
||||
export default class DiscussionRenamedPost extends EventPost {
|
||||
icon() {
|
||||
return 'pencil';
|
||||
}
|
||||
|
||||
description() {
|
||||
const post = this.props.post;
|
||||
const oldTitle = post.content()[0];
|
||||
const newTitle = post.content()[1];
|
||||
|
||||
return ['changed the title from ', m('strong.old-title', oldTitle), ' to ', m('strong.new-title', newTitle), '.'];
|
||||
}
|
||||
}
|
55
js/forum/src/components/DiscussionsSearchSource.js
Normal file
55
js/forum/src/components/DiscussionsSearchSource.js
Normal file
@ -0,0 +1,55 @@
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
/**
|
||||
* The `DiscussionsSearchSource` finds and displays discussion search results in
|
||||
* the search dropdown.
|
||||
*
|
||||
* @implements SearchSource
|
||||
*/
|
||||
export default class DiscussionsSearchSource {
|
||||
constructor() {
|
||||
this.results = {};
|
||||
}
|
||||
|
||||
search(query) {
|
||||
this.results[query] = [];
|
||||
|
||||
const params = {
|
||||
filter: {q: query},
|
||||
page: {limit: 3},
|
||||
include: 'relevantPosts,relevantPosts.discussion,relevantPosts.user'
|
||||
};
|
||||
|
||||
return app.store.find('discussions', params).then(results => this.results[query] = results);
|
||||
}
|
||||
|
||||
view(query) {
|
||||
const results = this.results[query] || [];
|
||||
|
||||
return [
|
||||
<li className="dropdown-header">Discussions</li>,
|
||||
<li>
|
||||
{Button.component({
|
||||
icon: 'search',
|
||||
children: 'Search all discussions for "' + query + '"',
|
||||
href: app.route('index', {q: query}),
|
||||
config: m.route
|
||||
})}
|
||||
</li>,
|
||||
results.map(discussion => {
|
||||
const relevantPosts = discussion.relevantPosts();
|
||||
const post = relevantPosts && relevantPosts[0];
|
||||
|
||||
return (
|
||||
<li className="discussion-search-result" data-index={'discussions' + discussion.id()}>
|
||||
<a href={app.route.discussion(discussion, post && post.number())} config={m.route}>
|
||||
<div className="title">{highlight(discussion.title(), query)}</div>
|
||||
{post ? <div className="excerpt">{highlight(post.contentPlain(), query, 100)}</div> : ''}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
64
js/forum/src/components/EditPostComposer.js
Normal file
64
js/forum/src/components/EditPostComposer.js
Normal file
@ -0,0 +1,64 @@
|
||||
import ComposerBody from 'flarum/components/ComposerBody';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
/**
|
||||
* The `EditPostComposer` component displays the composer content for editing a
|
||||
* post. It sets the initial content to the content of the post that is being
|
||||
* edited, and adds a header control to indicate which post is being edited.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - All of the props for ComposerBody
|
||||
* - `post`
|
||||
*/
|
||||
export default class EditComposer extends ComposerBody {
|
||||
static initProps(props) {
|
||||
super.initProps(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();
|
||||
}
|
||||
|
||||
headerItems() {
|
||||
const items = super.headerItems();
|
||||
const post = this.props.post;
|
||||
|
||||
items.add('title', (
|
||||
<h3>
|
||||
{icon('pencil')}
|
||||
<a href={app.route.discussion(post.discussion(), post.number())} config={m.route}>
|
||||
Post #{post.number()} in {post.discussion().title()}
|
||||
</a>
|
||||
</h3>
|
||||
));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to submit to the server when the post is saved.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
data() {
|
||||
return {
|
||||
content: this.content()
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit() {
|
||||
this.loading = true;
|
||||
|
||||
const data = this.data();
|
||||
|
||||
this.props.post.save(data).then(
|
||||
() => {
|
||||
app.composer.hide();
|
||||
m.redraw();
|
||||
},
|
||||
() => this.loading = false
|
||||
);
|
||||
}
|
||||
}
|
51
js/forum/src/components/EventPost.js
Normal file
51
js/forum/src/components/EventPost.js
Normal file
@ -0,0 +1,51 @@
|
||||
import Post from 'flarum/components/Post';
|
||||
import usernameHelper from 'flarum/helpers/username';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
/**
|
||||
* The `EventPost` component displays a post which indicating a discussion
|
||||
* event, like a discussion being renamed or stickied. Subclasses must implement
|
||||
* the `icon` and `description` methods.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - All of the props for `Post`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class EventPost extends Post {
|
||||
attrs() {
|
||||
return {
|
||||
className: 'event-post ' + this.props.post.contentType() + '-post'
|
||||
};
|
||||
}
|
||||
|
||||
content() {
|
||||
const user = this.props.post.user();
|
||||
const username = usernameHelper(user);
|
||||
|
||||
return [
|
||||
icon(this.icon(), {className: 'event-post-icon'}),
|
||||
<div class="event-post-info">
|
||||
{user ? <a className="post-user" href={app.route.user(user)} config={m.route}>{username}</a> : username}
|
||||
{this.description()}
|
||||
</div>
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the event icon.
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
icon() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description of the event.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
*/
|
||||
description() {
|
||||
}
|
||||
}
|
31
js/forum/src/components/FooterPrimary.js
Normal file
31
js/forum/src/components/FooterPrimary.js
Normal file
@ -0,0 +1,31 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `FooterPrimary` component displays primary footer controls, such as the
|
||||
* forum statistics. On the default skin, these are shown on the left side of
|
||||
* the footer.
|
||||
*/
|
||||
export default class FooterPrimary extends Component {
|
||||
view() {
|
||||
return (
|
||||
<ul className="footer-controls">
|
||||
{listItems(this.items().toArray())}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
// TODO: add forum statistics
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
35
js/forum/src/components/FooterSecondary.js
Normal file
35
js/forum/src/components/FooterSecondary.js
Normal file
@ -0,0 +1,35 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `FooterSecondary` component displays secondary footer controls, such as
|
||||
* the 'Powered by Flarum' message. On the default skin, these are shown on the
|
||||
* right side of the footer.
|
||||
*/
|
||||
export default class FooterSecondary extends Component {
|
||||
view() {
|
||||
return (
|
||||
<ul className="footer-controls">
|
||||
{listItems(this.items().toArray())}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('poweredBy', (
|
||||
<a href="http://flarum.org?r=forum" target="_blank">
|
||||
Powered by Flarum
|
||||
</a>
|
||||
));
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
99
js/forum/src/components/ForgotPasswordModal.js
Normal file
99
js/forum/src/components/ForgotPasswordModal.js
Normal file
@ -0,0 +1,99 @@
|
||||
import Modal from 'flarum/components/Modal';
|
||||
import Alert from 'flarum/components/Alert';
|
||||
|
||||
/**
|
||||
* The `ForgotPasswordModal` component displays a modal which allows the user to
|
||||
* enter their email address and request a link to reset their password.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `email`
|
||||
*/
|
||||
export default class ForgotPasswordModal extends Modal {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The value of the email input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.email = m.prop(this.props.email || '');
|
||||
|
||||
/**
|
||||
* Whether or not the password reset email was sent successfully.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.success = false;
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'modal-sm forgot-password';
|
||||
}
|
||||
|
||||
title() {
|
||||
return 'Forgot Password';
|
||||
}
|
||||
|
||||
body() {
|
||||
if (this.success) {
|
||||
const emailProviderName = this.email().split('@')[1];
|
||||
|
||||
return (
|
||||
<div className="form-centered">
|
||||
<p className="help-text">We've sent you an email containing a link to reset your password. Check your spam folder if you don't receive it within the next minute or two.</p>
|
||||
<div className="form-group">
|
||||
<a href={'http://' + emailProviderName} className="btn btn-primary btn-block">Go to {emailProviderName}</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-centered">
|
||||
<p className="help-text">Enter your email address and we will send you a link to reset your password.</p>
|
||||
<div className="form-group">
|
||||
<input className="form-control" name="email" type="email" placeholder="Email"
|
||||
value={this.email()}
|
||||
onchange={m.withAttr('value', this.email)}
|
||||
disabled={this.loading} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<button type="submit" className="btn btn-primary btn-block" disabled={this.loading}>
|
||||
Recover Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
app.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl') + '/forgot',
|
||||
data: {email: this.email()},
|
||||
handlers: {
|
||||
404: () => {
|
||||
this.alert = new Alert({type: 'warning', message: 'That email wasn\'t found in our database.'});
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
}).then(
|
||||
() => {
|
||||
this.loading = false;
|
||||
this.success = true;
|
||||
this.alert = null;
|
||||
m.redraw();
|
||||
},
|
||||
response => {
|
||||
this.loading = false;
|
||||
this.handleErrors(response.errors);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
26
js/forum/src/components/HeaderPrimary.js
Normal file
26
js/forum/src/components/HeaderPrimary.js
Normal file
@ -0,0 +1,26 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderPrimary` component displays primary header controls. On the
|
||||
* default skin, these are shown just to the right of the forum title.
|
||||
*/
|
||||
export default class HeaderPrimary extends Component {
|
||||
view() {
|
||||
return (
|
||||
<ul className="header-controls">
|
||||
{listItems(this.items().toArray())}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
57
js/forum/src/components/HeaderSecondary.js
Normal file
57
js/forum/src/components/HeaderSecondary.js
Normal file
@ -0,0 +1,57 @@
|
||||
import Component from 'flarum/Component';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LogInModal from 'flarum/components/LogInModal';
|
||||
import SignUpModal from 'flarum/components/SignUpModal';
|
||||
import SessionDropdown from 'flarum/components/SessionDropdown';
|
||||
import NotificationsDropdown from 'flarum/components/NotificationsDropdown';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `HeaderSecondary` component displays secondary footer controls, such as
|
||||
* the search box and the user menu. On the default skin, these are shown on the
|
||||
* right side of the header.
|
||||
*/
|
||||
export default class HeaderSecondary extends Component {
|
||||
view() {
|
||||
return (
|
||||
<ul className="header-controls">
|
||||
{listItems(this.items().toArray())}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('search', app.search.render());
|
||||
|
||||
if (app.session.user) {
|
||||
items.add('notifications', NotificationsDropdown.component());
|
||||
items.add('session', SessionDropdown.component());
|
||||
} else {
|
||||
items.add('signUp',
|
||||
Button.component({
|
||||
children: 'Sign Up',
|
||||
className: 'btn btn-link',
|
||||
onclick: () => app.modal.show(new SignUpModal())
|
||||
})
|
||||
);
|
||||
|
||||
items.add('logIn',
|
||||
Button.component({
|
||||
children: 'Log In',
|
||||
className: 'btn btn-link',
|
||||
onclick: () => app.modal.show(new LogInModal())
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@ -1,89 +1,127 @@
|
||||
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 DiscussionComposer from 'flarum/components/discussion-composer';
|
||||
import LoginModal from 'flarum/components/login-modal';
|
||||
import DiscussionPage from 'flarum/components/discussion-page';
|
||||
|
||||
import SelectInput from 'flarum/components/select-input';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
import IndexNavItem from 'flarum/components/index-nav-item';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
import DropdownSelect from 'flarum/components/dropdown-select';
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import affixSidebar from 'flarum/utils/affixSidebar';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import DiscussionList from 'flarum/components/DiscussionList';
|
||||
import WelcomeHero from 'flarum/components/WelcomeHero';
|
||||
import DiscussionComposer from 'flarum/components/DiscussionComposer';
|
||||
import LogInModal from 'flarum/components/LogInModal';
|
||||
import DiscussionPage from 'flarum/components/DiscussionPage';
|
||||
import Select from 'flarum/components/Select';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LinkButton from 'flarum/components/LinkButton';
|
||||
import SelectDropdown from 'flarum/components/SelectDropdown';
|
||||
|
||||
/**
|
||||
* The `IndexPage` component displays the index page, including the welcome
|
||||
* hero, the sidebar, and the discussion list.
|
||||
*/
|
||||
export default class IndexPage extends Component {
|
||||
/**
|
||||
* @param {Object} props
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
// If the user is returning from a discussion page, then take note of which
|
||||
// discussion they have just visited. After the view is rendered, we will
|
||||
// scroll down so that this discussion is in view.
|
||||
if (app.current instanceof DiscussionPage) {
|
||||
this.lastDiscussion = app.current.discussion();
|
||||
this.lastDiscussion = app.current.discussion;
|
||||
}
|
||||
|
||||
var params = this.params();
|
||||
const params = this.params();
|
||||
|
||||
if (app.cache.discussionList) {
|
||||
// Compare the requested parameters (sort, search query) to the ones that
|
||||
// are currently present in the cached discussion list. If they differ, we
|
||||
// will clear the cache and set up a new discussion list component with
|
||||
// the new parameters.
|
||||
if (app.cache.discussionList.forceReload) {
|
||||
app.cache.discussionList = null;
|
||||
} else {
|
||||
Object.keys(params).some(key => {
|
||||
if (app.cache.discussionList.props.params[key] !== params[key]) {
|
||||
app.cache.discussionList = null;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
Object.keys(params).some(key => {
|
||||
if (app.cache.discussionList.props.params[key] !== params[key]) {
|
||||
app.cache.discussionList = null;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!app.cache.discussionList) {
|
||||
app.cache.discussionList = new DiscussionList({ params });
|
||||
app.cache.discussionList = new DiscussionList({params});
|
||||
}
|
||||
|
||||
app.history.push('index');
|
||||
app.current = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
onunload() {
|
||||
// Save the scroll position so we can restore it when we return to the
|
||||
// discussion list.
|
||||
app.cache.scrollTop = $(window).scrollTop();
|
||||
app.composer.minimize();
|
||||
}
|
||||
|
||||
view() {
|
||||
return m('div.index-area', {config: this.onload.bind(this)}, [
|
||||
this.hero(),
|
||||
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('ul.index-toolbar-view', listItems(this.viewItems().toArray())),
|
||||
m('ul.index-toolbar-action', listItems(this.actionItems().toArray()))
|
||||
]),
|
||||
app.cache.discussionList.render()
|
||||
])
|
||||
])
|
||||
]);
|
||||
return (
|
||||
<div className="index-area">
|
||||
{this.hero()}
|
||||
<div className="container">
|
||||
<nav className="side-nav index-nav" config={affixSidebar}>
|
||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||
</nav>
|
||||
<div className="offset-content index-results">
|
||||
<div className="index-toolbar">
|
||||
<ul className="index-toolbar-view">{listItems(this.viewItems().toArray())}</ul>
|
||||
<ul className="index-toolbar-action">{listItems(this.actionItems().toArray())}</ul>
|
||||
</div>
|
||||
{app.cache.discussionList.render()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
$('body').addClass('index-page');
|
||||
context.onunload = () => {
|
||||
$('body').removeClass('index-page');
|
||||
$('.global-page').css('min-height', '');
|
||||
};
|
||||
|
||||
app.setTitle('');
|
||||
|
||||
// Work out the difference between the height of this hero and that of the
|
||||
// previous hero. Maintain the same scroll position relative to the bottom
|
||||
// of the hero so that the 'fixed' sidebar doesn't jump around.
|
||||
const heroHeight = this.$('.hero').outerHeight();
|
||||
const scrollTop = app.cache.scrollTop;
|
||||
|
||||
$('.global-page').css('min-height', $(window).height() + heroHeight);
|
||||
$(window).scrollTop(scrollTop - (app.cache.heroHeight - heroHeight));
|
||||
|
||||
app.cache.heroHeight = heroHeight;
|
||||
|
||||
// If we've just returned from a discussion page, then the constructor will
|
||||
// have set the `lastDiscussion` property. If this is the case, we want to
|
||||
// scroll down to that discussion so that it's in view.
|
||||
if (this.lastDiscussion) {
|
||||
const $discussion = this.$('.discussion-summary[data-id=' + this.lastDiscussion.id() + ']');
|
||||
|
||||
if ($discussion.length) {
|
||||
const indexTop = $('#header').outerHeight();
|
||||
const indexBottom = $(window).height();
|
||||
const discussionTop = $discussion.offset().top;
|
||||
const discussionBottom = discussionTop + $discussion.outerHeight();
|
||||
|
||||
if (discussionTop < scrollTop + indexTop || discussionBottom > scrollTop + indexBottom) {
|
||||
$(window).scrollTop(discussionTop - indexTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the component to display as the hero.
|
||||
*
|
||||
* @return {Object}
|
||||
* @return {MithrilComponent}
|
||||
*/
|
||||
hero() {
|
||||
return WelcomeHero.component();
|
||||
@ -92,27 +130,27 @@ export default class IndexPage extends Component {
|
||||
/**
|
||||
* 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).
|
||||
* list of navigation items.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
sidebarItems() {
|
||||
var items = new ItemList();
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('newDiscussion',
|
||||
ActionButton.component({
|
||||
label: 'Start a Discussion',
|
||||
Button.component({
|
||||
children: 'Start a Discussion',
|
||||
icon: 'edit',
|
||||
className: 'btn btn-primary new-discussion',
|
||||
wrapperClass: 'primary-control',
|
||||
itemClassName: 'primary-control',
|
||||
onclick: this.newDiscussion.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
items.add('nav',
|
||||
DropdownSelect.component({
|
||||
items: this.navItems(this).toArray(),
|
||||
wrapperClass: 'title-control'
|
||||
SelectDropdown.component({
|
||||
children: this.navItems(this).toArray(),
|
||||
itemClassName: 'title-control'
|
||||
})
|
||||
);
|
||||
|
||||
@ -126,13 +164,13 @@ export default class IndexPage extends Component {
|
||||
* @return {ItemList}
|
||||
*/
|
||||
navItems() {
|
||||
var items = new ItemList();
|
||||
var params = this.stickyParams();
|
||||
const items = new ItemList();
|
||||
const params = this.stickyParams();
|
||||
|
||||
items.add('allDiscussions',
|
||||
IndexNavItem.component({
|
||||
LinkButton.component({
|
||||
href: app.route('index', params),
|
||||
label: 'All Discussions',
|
||||
children: 'All Discussions',
|
||||
icon: 'comments-o'
|
||||
})
|
||||
);
|
||||
@ -148,23 +186,23 @@ export default class IndexPage extends Component {
|
||||
* @return {ItemList}
|
||||
*/
|
||||
viewItems() {
|
||||
var items = new ItemList();
|
||||
const items = new ItemList();
|
||||
|
||||
var sortOptions = {};
|
||||
for (var i in app.cache.discussionList.sortMap()) {
|
||||
sortOptions[i] = i.substr(0, 1).toUpperCase()+i.substr(1);
|
||||
const sortOptions = {};
|
||||
for (const i in app.cache.discussionList.sortMap()) {
|
||||
sortOptions[i] = i.substr(0, 1).toUpperCase() + i.substr(1);
|
||||
}
|
||||
|
||||
items.add('sort',
|
||||
SelectInput.component({
|
||||
Select.component({
|
||||
options: sortOptions,
|
||||
value: this.params().sort,
|
||||
onchange: this.reorder.bind(this)
|
||||
onchange: this.changeSort.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
items.add('refresh',
|
||||
ActionButton.component({
|
||||
Button.component({
|
||||
title: 'Refresh',
|
||||
icon: 'refresh',
|
||||
className: 'btn btn-default btn-icon',
|
||||
@ -182,14 +220,14 @@ export default class IndexPage extends Component {
|
||||
* @return {ItemList}
|
||||
*/
|
||||
actionItems() {
|
||||
var items = new ItemList();
|
||||
const items = new ItemList();
|
||||
|
||||
if (app.session.user()) {
|
||||
if (app.session.user) {
|
||||
items.add('markAllAsRead',
|
||||
ActionButton.component({
|
||||
Button.component({
|
||||
title: 'Mark All as Read',
|
||||
icon: 'check',
|
||||
className: 'control-markAllAsRead btn btn-default btn-icon',
|
||||
className: 'btn btn-default btn-icon',
|
||||
onclick: this.markAllAsRead.bind(this)
|
||||
})
|
||||
);
|
||||
@ -202,7 +240,7 @@ export default class IndexPage extends Component {
|
||||
* Return the current search query, if any. This is implemented to activate
|
||||
* the search box in the header.
|
||||
*
|
||||
* @see module:flarum/components/search-box
|
||||
* @see Search
|
||||
* @return {String}
|
||||
*/
|
||||
searching() {
|
||||
@ -213,27 +251,29 @@ export default class IndexPage extends Component {
|
||||
* Redirect to the index page without a search filter. This is called when the
|
||||
* 'x' is clicked in the search box in the header.
|
||||
*
|
||||
* @see module:flarum/components/search-box
|
||||
* @return void
|
||||
* @see Search
|
||||
*/
|
||||
clearSearch() {
|
||||
var params = this.params();
|
||||
const params = this.params();
|
||||
delete params.q;
|
||||
|
||||
m.route(app.route(this.props.routeName, params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to
|
||||
* @param {[type]} sort [description]
|
||||
* @return {[type]}
|
||||
* Redirect to the index page using the given sort parameter.
|
||||
*
|
||||
* @param {String} sort
|
||||
*/
|
||||
reorder(sort) {
|
||||
var params = this.params();
|
||||
changeSort(sort) {
|
||||
const params = this.params();
|
||||
|
||||
if (sort === Object.keys(app.cache.discussionList.sortMap())[0]) {
|
||||
delete params.sort;
|
||||
} else {
|
||||
params.sort = sort;
|
||||
}
|
||||
|
||||
m.route(app.route(this.props.routeName, params));
|
||||
}
|
||||
|
||||
@ -246,7 +286,7 @@ export default class IndexPage extends Component {
|
||||
return {
|
||||
sort: m.route.param('sort'),
|
||||
q: m.route.param('q')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -255,7 +295,7 @@ export default class IndexPage extends Component {
|
||||
* @return {Object}
|
||||
*/
|
||||
params() {
|
||||
var params = this.stickyParams();
|
||||
const params = this.stickyParams();
|
||||
|
||||
params.filter = m.route.param('filter');
|
||||
|
||||
@ -263,111 +303,40 @@ export default class IndexPage extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the DOM.
|
||||
*
|
||||
* @param {DOMElement} element
|
||||
* @param {Boolean} isInitialized
|
||||
* @param {Object} context
|
||||
* @return {void}
|
||||
*/
|
||||
onload(element, isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
this.element(element);
|
||||
|
||||
$('body').addClass('index-page');
|
||||
context.onunload = function() {
|
||||
$('body').removeClass('index-page');
|
||||
$('.global-page').css('min-height', '');
|
||||
};
|
||||
|
||||
app.setTitle('');
|
||||
|
||||
// Work out the difference between the height of this hero and that of the
|
||||
// previous hero. Maintain the same scroll position relative to the bottom
|
||||
// of the hero so that the 'fixed' sidebar doesn't jump around.
|
||||
var heroHeight = this.$('.hero').outerHeight();
|
||||
var scrollTop = app.cache.scrollTop;
|
||||
|
||||
$('.global-page').css('min-height', $(window).height() + heroHeight);
|
||||
$(window).scrollTop(scrollTop - (app.cache.heroHeight - heroHeight));
|
||||
|
||||
app.cache.heroHeight = heroHeight;
|
||||
|
||||
// If we've just returned from a discussion page, then the constructor will
|
||||
// have set the `lastDiscussion` property. If this is the case, we want to
|
||||
// scroll down to that discussion so that it's in view.
|
||||
if (this.lastDiscussion) {
|
||||
var $discussion = this.$('.discussion-summary[data-id='+this.lastDiscussion.id()+']');
|
||||
if ($discussion.length) {
|
||||
var indexTop = $('#header').outerHeight();
|
||||
var discussionTop = $discussion.offset().top;
|
||||
if (discussionTop < scrollTop + indexTop || discussionTop + $discussion.outerHeight() > scrollTop + $(window).height()) {
|
||||
$(window).scrollTop(discussionTop - indexTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mithril hook, called when the controller is destroyed. Save the scroll
|
||||
* position, and minimize the composer.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
onunload() {
|
||||
app.cache.scrollTop = $(window).scrollTop();
|
||||
app.composer.minimize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// Don't affix the sidebar if it is taller than the viewport (otherwise
|
||||
// there would be no way to scroll through its content).
|
||||
if ($sidebar.outerHeight(true) > $(window).height() - $('.global-header').outerHeight(true)) return;
|
||||
|
||||
$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))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the composer for a new discussion.
|
||||
* Log the user in and then open the composer for a new discussion.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
newDiscussion() {
|
||||
var deferred = m.deferred();
|
||||
const deferred = m.deferred();
|
||||
|
||||
if (app.session.user()) {
|
||||
if (app.session.user) {
|
||||
this.composeNewDiscussion(deferred);
|
||||
} else {
|
||||
app.modal.show(
|
||||
new LoginModal({ onlogin: this.composeNewDiscussion.bind(this, deferred) })
|
||||
new LogInModal({
|
||||
onlogin: this.composeNewDiscussion.bind(this, deferred)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the composer for a new discussion.
|
||||
*
|
||||
* @param {Deferred} deferred
|
||||
* @return {Promise}
|
||||
*/
|
||||
composeNewDiscussion(deferred) {
|
||||
// @todo check global permissions
|
||||
var component = new DiscussionComposer({ user: app.session.user() });
|
||||
// TODO: check global permissions
|
||||
|
||||
const component = new DiscussionComposer({user: app.session.user});
|
||||
|
||||
app.composer.load(component);
|
||||
app.composer.show();
|
||||
|
||||
deferred.resolve(component);
|
||||
|
||||
return deferred.promise;
|
||||
@ -379,6 +348,6 @@ export default class IndexPage extends Component {
|
||||
* @return void
|
||||
*/
|
||||
markAllAsRead() {
|
||||
app.session.user().save({ readTime: new Date() });
|
||||
app.session.user.save({readTime: new Date()});
|
||||
}
|
||||
};
|
||||
}
|
11
js/forum/src/components/JoinedActivity.js
Normal file
11
js/forum/src/components/JoinedActivity.js
Normal file
@ -0,0 +1,11 @@
|
||||
import Activity from 'flarum/components/Activity';
|
||||
|
||||
/**
|
||||
* The `JoinedActivity` component displays an activity feed item for when a user
|
||||
* joined the forum.
|
||||
*/
|
||||
export default class JoinedActivity extends Activity {
|
||||
description() {
|
||||
return 'Joined the forum';
|
||||
}
|
||||
}
|
25
js/forum/src/components/LoadingPost.js
Normal file
25
js/forum/src/components/LoadingPost.js
Normal file
@ -0,0 +1,25 @@
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
|
||||
/**
|
||||
* The `LoadingPost` component shows a placeholder that looks like a post,
|
||||
* indicating that the post is loading.
|
||||
*/
|
||||
export default class LoadingPost extends Component {
|
||||
view() {
|
||||
return (
|
||||
<div className="post comment-post loading-post">
|
||||
<header className="post-header">
|
||||
{avatar()}
|
||||
<div className="fake-text"/>
|
||||
</header>
|
||||
|
||||
<div className="post-body">
|
||||
<div className="fake-text"/>
|
||||
<div className="fake-text"/>
|
||||
<div className="fake-text"/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
140
js/forum/src/components/LogInModal.js
Normal file
140
js/forum/src/components/LogInModal.js
Normal file
@ -0,0 +1,140 @@
|
||||
import Modal from 'flarum/components/Modal';
|
||||
import ForgotPasswordModal from 'flarum/components/ForgotPasswordModal';
|
||||
import SignUpModal from 'flarum/components/SignUpModal';
|
||||
import Alert from 'flarum/components/Alert';
|
||||
|
||||
/**
|
||||
* The `LogInModal` component displays a modal dialog with a login form.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `email`
|
||||
* - `password`
|
||||
*/
|
||||
export default class LogInModal extends Modal {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The value of the email input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.email = m.prop(this.props.email || '');
|
||||
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.password = m.prop(this.props.password || '');
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'modal-sm login-modal';
|
||||
}
|
||||
|
||||
title() {
|
||||
return 'Log In';
|
||||
}
|
||||
|
||||
body() {
|
||||
return (
|
||||
<div className="form-centered">
|
||||
<div className="form-group">
|
||||
<input className="form-control" name="email" placeholder="Username or Email"
|
||||
value={this.email()}
|
||||
onchange={m.withAttr('value', this.email)}
|
||||
disabled={this.loading} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<input className="form-control" name="password" type="password" placeholder="Password"
|
||||
value={this.password()}
|
||||
onchange={m.withAttr('value', this.password)}
|
||||
disabled={this.loading} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<button className="btn btn-primary btn-block"
|
||||
type="submit"
|
||||
disabled={this.loading}>
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
footer() {
|
||||
return [
|
||||
<p className="forgot-password-link">
|
||||
<a href="javascript:;" onclick={this.forgotPassword.bind(this)}>Forgot password?</a>
|
||||
</p>,
|
||||
<p className="sign-up-link">
|
||||
Don't have an account?
|
||||
<a href="javascript:;" onclick={this.signUp.bind(this)}>Sign Up</a>
|
||||
</p>
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the forgot password modal, prefilling it with an email if the user has
|
||||
* entered one.
|
||||
*/
|
||||
forgotPassword() {
|
||||
const email = this.email();
|
||||
const props = email.indexOf('@') !== -1 ? {email} : null;
|
||||
|
||||
app.modal.show(new ForgotPasswordModal(props));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the sign up modal, prefilling it with an email/username/password if
|
||||
* the user has entered one.
|
||||
*/
|
||||
signUp() {
|
||||
const props = {password: this.password()};
|
||||
const email = this.email();
|
||||
props[email.indexOf('@') !== -1 ? 'email' : 'username'] = email;
|
||||
|
||||
app.modal.show(new SignUpModal(props));
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.$('[name=' + (this.email() ? 'password' : 'email') + ']').select();
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
const email = this.email();
|
||||
const password = this.password();
|
||||
|
||||
app.session.login(email, password).then(
|
||||
() => {
|
||||
this.hide();
|
||||
if (this.props.onlogin) this.props.onlogin();
|
||||
},
|
||||
response => {
|
||||
this.loading = false;
|
||||
|
||||
if (response && response.code === 'confirm_email') {
|
||||
this.alert = Alert.component({
|
||||
message: ['You need to confirm your email before you can log in. We\'ve sent a confirmation email to ', <strong>{response.email}</strong>, '. If it doesn\'t arrive soon, check your spam folder.']
|
||||
});
|
||||
} else {
|
||||
this.alert = Alert.component({
|
||||
type: 'warning',
|
||||
message: 'Your login details were incorrect.'
|
||||
});
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
this.focus();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
134
js/forum/src/components/Modal.js
Normal file
134
js/forum/src/components/Modal.js
Normal file
@ -0,0 +1,134 @@
|
||||
import Component from 'flarum/Component';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import Alert from 'flarum/components/Alert';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
/**
|
||||
* The `Modal` component displays a modal dialog, wrapped in a form. Subclasses
|
||||
* should implement the `className`, `title`, and `content` methods.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Modal extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* An alert component to show below the header.
|
||||
*
|
||||
* @type {Alert}
|
||||
*/
|
||||
this.alert = null;
|
||||
|
||||
/**
|
||||
* Whether or not the form is processing.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.alert) {
|
||||
this.alert.props.dismissible = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'modal-dialog ' + this.className()}>
|
||||
<div className="modal-content">
|
||||
<div className="close back-control">
|
||||
<a href="javascript:;" className="btn btn-icon btn-link" onclick={this.hide.bind(this)}>
|
||||
{icon('times', {className: 'icon'})}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form onsubmit={this.onsubmit.bind(this)}>
|
||||
<div className="modal-header">
|
||||
<h3 className="title-control">{this.title()}</h3>
|
||||
</div>
|
||||
|
||||
{alert ? <div className="modal-alert">{this.alert}</div> : ''}
|
||||
|
||||
{this.content()}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{LoadingIndicator.component({
|
||||
className: 'modal-loading' + (this.loading ? ' active' : '')
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the class name to apply to the modal.
|
||||
*
|
||||
* @return {String}
|
||||
* @abstract
|
||||
*/
|
||||
className() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the title of the modal dialog.
|
||||
*
|
||||
* @return {String}
|
||||
* @abstract
|
||||
*/
|
||||
title() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of the modal.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
* @abstract
|
||||
*/
|
||||
content() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the modal form's submit event.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
onsubmit() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on the first input when the modal is ready to be used.
|
||||
*/
|
||||
onready() {
|
||||
this.$(':input:first').select();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the modal.
|
||||
*/
|
||||
hide() {
|
||||
app.modal.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an alert describing errors returned from the API, and give focus to
|
||||
* the first relevant field.
|
||||
*
|
||||
* @param {Array} errors
|
||||
*/
|
||||
handleErrors(errors) {
|
||||
if (errors) {
|
||||
this.alert(new Alert({
|
||||
type: 'warning',
|
||||
message: errors.map((error, k) => [error.detail, k < errors.length - 1 ? m('br') : ''])
|
||||
}));
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
|
||||
if (errors) {
|
||||
this.$('[name=' + errors[0].path + ']').select();
|
||||
} else {
|
||||
this.$(':input:first').select();
|
||||
}
|
||||
}
|
||||
}
|
190
js/forum/src/components/NotificationGrid.js
Normal file
190
js/forum/src/components/NotificationGrid.js
Normal file
@ -0,0 +1,190 @@
|
||||
import Component from 'flarum/Component';
|
||||
import Checkbox from 'flarum/components/Checkbox';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
|
||||
/**
|
||||
* The `NotificationGrid` component displays a table of notification types and
|
||||
* methods, allowing the user to toggle each combination.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `user`
|
||||
*/
|
||||
export default class NotificationGrid extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Information about the available notification methods.
|
||||
*
|
||||
* @type {Array}
|
||||
*/
|
||||
this.methods = [
|
||||
{name: 'alert', icon: 'bell', label: 'Alert'},
|
||||
{name: 'email', icon: 'envelope-o', label: 'Email'}
|
||||
];
|
||||
|
||||
/**
|
||||
* A map of notification type-method combinations to the checkbox instances
|
||||
* that represent them.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.inputs = {};
|
||||
|
||||
/**
|
||||
* Information about the available notification types.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.types = this.notificationTypes().toArray();
|
||||
|
||||
// For each of the notification type-method combinations, create and store a
|
||||
// new checkbox component instance, which we will render in the view.
|
||||
this.types.forEach(type => {
|
||||
this.methods.forEach(method => {
|
||||
const key = this.preferenceKey(type.name, method.name);
|
||||
const preference = this.props.user.preferences()[key];
|
||||
|
||||
this.inputs[key] = new Checkbox({
|
||||
state: !!preference,
|
||||
disabled: typeof preference === 'undefined',
|
||||
onchange: () => this.toggle([key])
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<table className="notification-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<td/>
|
||||
{this.methods.map(method => (
|
||||
<th className="toggle-group" onclick={this.toggleMethod.bind(this, method.name)}>
|
||||
{icon(method.icon)} {method.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{this.types.map(type => (
|
||||
<tr>
|
||||
<td className="toggle-group" onclick={this.toggleType.bind(this, type.name)}>
|
||||
{type.label}
|
||||
</td>
|
||||
{this.methods.map(method => (
|
||||
<td className="checkbox-cell">
|
||||
{this.inputs[this.preferenceKey(type.name, method.name)].render()}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
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 the state of the given preferences, based on the value of the first
|
||||
* one.
|
||||
*
|
||||
* @param {Array} keys
|
||||
*/
|
||||
toggle(keys) {
|
||||
const user = this.props.user;
|
||||
const preferences = user.preferences();
|
||||
const enabled = !preferences[keys[0]];
|
||||
|
||||
keys.forEach(key => {
|
||||
const control = this.inputs[key];
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle all notification types for the given method.
|
||||
*
|
||||
* @param {String} method
|
||||
*/
|
||||
toggleMethod(method) {
|
||||
const keys = this.types
|
||||
.map(type => this.preferenceKey(type.name, method))
|
||||
.filter(key => !this.inputs[key].props.disabled);
|
||||
|
||||
this.toggle(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle all notification methods for the given type.
|
||||
*
|
||||
* @param {String} type
|
||||
*/
|
||||
toggleType(type) {
|
||||
const keys = this.methods
|
||||
.map(method => this.preferenceKey(type, method.name))
|
||||
.filter(key => !this.inputs[key].props.disabled);
|
||||
|
||||
this.toggle(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the preference key for the given notification type-method
|
||||
* combination.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {String} method
|
||||
* @return {String}
|
||||
*/
|
||||
preferenceKey(type, method) {
|
||||
return 'notify_' + type + '_' + method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the notification types to display in the grid.
|
||||
*
|
||||
* Each notification type is an object which has the following properties:
|
||||
*
|
||||
* - `name` The name of the notification type.
|
||||
* - `label` The label to display in the notification grid row.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
notificationTypes() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('discussionRenamed', {
|
||||
name: 'discussionRenamed',
|
||||
label: [icon('pencil'), ' Someone renames a discussion I started']
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
141
js/forum/src/components/NotificationList.js
Normal file
141
js/forum/src/components/NotificationList.js
Normal file
@ -0,0 +1,141 @@
|
||||
import Component from 'flarum/Component';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import Button from 'flarum/components/Button';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import Discussion from 'flarum/models/Discussion';
|
||||
|
||||
/**
|
||||
* The `NotificationList` component displays a list of the logged-in user's
|
||||
* notifications, grouped by discussion.
|
||||
*/
|
||||
export default class NotificationList extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Whether or not the notifications are loading.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = false;
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
view() {
|
||||
const groups = [];
|
||||
|
||||
if (app.cache.notifications) {
|
||||
const discussions = {};
|
||||
|
||||
// Build an array of discussions which the notifications are related to,
|
||||
// and add the notifications as children.
|
||||
app.cache.notifications.forEach(notification => {
|
||||
const subject = notification.subject();
|
||||
|
||||
// Get the discussion that this notification is related to. If it's not
|
||||
// directly related to a discussion, it may be related to a post or
|
||||
// other entity which is related to a discussion.
|
||||
let discussion;
|
||||
if (subject instanceof Discussion) discussion = subject;
|
||||
else if (subject.discussion) discussion = subject.discussion();
|
||||
|
||||
// If the notification is not related to a discussion directly or
|
||||
// indirectly, then we will assign it to a neutral group.
|
||||
const key = discussion ? discussion.id() : 0;
|
||||
discussions[key] = discussions[key] || {discussion: discussion, notifications: []};
|
||||
discussions[key].notifications.push(notification);
|
||||
|
||||
if (groups.indexOf(discussions[key]) === -1) {
|
||||
groups.push(discussions[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="notification-list">
|
||||
<div className="notifications-header">
|
||||
<div className="primary-control">
|
||||
{Button.component({
|
||||
className: 'btn btn-icon btn-link btn-sm',
|
||||
icon: 'check',
|
||||
title: 'Mark All as Read',
|
||||
onclick: this.markAllAsRead.bind(this)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h4 className="title-control">Notifications</h4>
|
||||
</div>
|
||||
|
||||
<div className="notifications-content">
|
||||
{groups.length
|
||||
? groups.map(group => {
|
||||
const badges = group.discussion && group.discussion.badges().toArray();
|
||||
|
||||
return (
|
||||
<div className="notification-group">
|
||||
{group.discussion
|
||||
? (
|
||||
<a className="notification-group-header"
|
||||
href={app.route.discussion(group.discussion)}
|
||||
config={m.route}>
|
||||
{badges && badges.length ? <ul className="badges">{listItems(badges)}</ul> : ''}
|
||||
{group.discussion.title()}
|
||||
</a>
|
||||
) : (
|
||||
<div className="notification-group-header">
|
||||
{app.forum.attribute('title')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="notification-group-list">
|
||||
{group.notifications.map(notification => {
|
||||
const NotificationComponent = app.notificationComponents[notification.contentType()];
|
||||
return NotificationComponent ? <li>{NotificationComponent.component({notification})}</li> : '';
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: !this.loading
|
||||
? <div className="no-notifications">No Notifications</div>
|
||||
: LoadingIndicator.component({className: 'loading-indicator-block'})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load notifications into the application's cache if they haven't already
|
||||
* been loaded.
|
||||
*/
|
||||
load() {
|
||||
if (app.cache.notifications && !app.session.user.unreadNotificationsCount()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
app.store.find('notifications').then(notifications => {
|
||||
app.session.user.pushAttributes({unreadNotificationsCount: 0});
|
||||
app.cache.notifications = notifications.sort((a, b) => b.time() - a.time());
|
||||
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all of the notifications as read.
|
||||
*/
|
||||
markAllAsRead() {
|
||||
if (!app.cache.notifications) return;
|
||||
|
||||
app.cache.notifications.forEach(notification => {
|
||||
if (!notification.isRead()) {
|
||||
notification.save({isRead: true});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
44
js/forum/src/components/NotificationsDropdown.js
Normal file
44
js/forum/src/components/NotificationsDropdown.js
Normal file
@ -0,0 +1,44 @@
|
||||
import Component from 'flarum/Component';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import NotificationList from 'flarum/components/NotificationList';
|
||||
|
||||
export default class NotificationsDropdown extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Whether or not the notifications dropdown is visible.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.showing = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const user = app.session.user;
|
||||
const unread = user.unreadNotificationsCount();
|
||||
|
||||
return (
|
||||
<div className="dropdown btn-group notifications-dropdown">
|
||||
<a href="javascript:;"
|
||||
className={'dropdown-toggle btn btn-default btn-rounded btn-naked btn-icon' + (unread ? ' unread' : '')}
|
||||
data-toggle="dropdown"
|
||||
onclick={this.onclick.bind(this)}>
|
||||
<span className="notifications-icon">{unread || icon('bell')}</span>
|
||||
<span className="label">Notifications</span>
|
||||
</a>
|
||||
<div className="dropdown-menu dropdown-menu-right">
|
||||
{this.showing ? NotificationList.component() : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onclick() {
|
||||
if (app.drawer.isOpen()) {
|
||||
m.route(app.route('notifications'));
|
||||
} else {
|
||||
this.showing = true;
|
||||
}
|
||||
}
|
||||
}
|
20
js/forum/src/components/NotificationsPage.js
Normal file
20
js/forum/src/components/NotificationsPage.js
Normal file
@ -0,0 +1,20 @@
|
||||
import Component from 'flarum/Component';
|
||||
import NotificationList from 'flarum/components/NotificationList';
|
||||
|
||||
/**
|
||||
* The `NotificationsPage` component shows the notifications list. It is only
|
||||
* used on mobile devices where the notifications dropdown is within the drawer.
|
||||
*/
|
||||
export default class NotificationsPage extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
app.current = this;
|
||||
app.history.push('notifications');
|
||||
app.drawer.hide();
|
||||
}
|
||||
|
||||
view() {
|
||||
return <div>{NotificationList.component()}</div>;
|
||||
}
|
||||
}
|
29
js/forum/src/components/PostEdited.js
Normal file
29
js/forum/src/components/PostEdited.js
Normal file
@ -0,0 +1,29 @@
|
||||
import Component from 'flarum/Component';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import humanTime from 'flarum/utils/humanTime';
|
||||
|
||||
/**
|
||||
* The `PostEdited` component displays information about when and by whom a post
|
||||
* was edited.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostEdited extends Component {
|
||||
view() {
|
||||
const post = this.props.post;
|
||||
const editUser = post.editUser();
|
||||
const title = 'Edited ' + (editUser ? 'by ' + editUser.username() + ' ' : '') + humanTime(post.editTime());
|
||||
|
||||
return (
|
||||
<span className="post-edited" title={title}>{icon('pencil')}</span>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
this.$().tooltip();
|
||||
}
|
||||
}
|
45
js/forum/src/components/PostMeta.js
Normal file
45
js/forum/src/components/PostMeta.js
Normal file
@ -0,0 +1,45 @@
|
||||
import Component from 'flarum/Component';
|
||||
import humanTime from 'flarum/helpers/humanTime';
|
||||
import fullTime from 'flarum/helpers/fullTime';
|
||||
|
||||
/**
|
||||
* The `PostMeta` component displays the time of a post, and when clicked, shows
|
||||
* a dropdown containing more information about the post (number, full time,
|
||||
* permalink).
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostMeta extends Component {
|
||||
view() {
|
||||
const post = this.props.post;
|
||||
const time = post.time();
|
||||
const permalink = window.location.origin + app.route.post(post);
|
||||
const 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.
|
||||
const selectPermalink = function() {
|
||||
setTimeout(() => $(this).parent().find('.permalink').select());
|
||||
|
||||
m.redraw.strategy('none');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dropdown post-meta">
|
||||
<a href="javascript:;" data-toggle="dropdown" className="dropdown-toggle" onclick={selectPermalink}>
|
||||
{humanTime(time)}
|
||||
</a>
|
||||
|
||||
<div className="dropdown-menu">
|
||||
<span className="number">Post #{post.number()}</span>
|
||||
{fullTime(time)}
|
||||
{touch
|
||||
? <a href="btn btn-default permalink" href={permalink}>{permalink}</a>
|
||||
: <input className="form-control permalink" value="permalink" onclick={e => e.stopPropagation()} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
32
js/forum/src/components/PostPreview.js
Normal file
32
js/forum/src/components/PostPreview.js
Normal file
@ -0,0 +1,32 @@
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import username from 'flarum/helpers/username';
|
||||
import humanTime from 'flarum/helpers/humanTime';
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
|
||||
/**
|
||||
* The `PostPreview` component shows a link to a post containing the avatar and
|
||||
* username of the author, and a short excerpt of the post's content.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostPreview extends Component {
|
||||
view() {
|
||||
const post = this.props.post;
|
||||
const user = post.user();
|
||||
const excerpt = highlight(post.contentPlain(), this.props.highlight, 200);
|
||||
|
||||
return (
|
||||
<a className="post-preview" href={app.route.post(post)} config={m.route} onclick={this.props.onclick}>
|
||||
<span className="post-preview-content">
|
||||
{avatar(user)}
|
||||
{username(user)}
|
||||
{humanTime(post.time())}
|
||||
<span className="excerpt">{excerpt}</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
556
js/forum/src/components/PostStream.js
Normal file
556
js/forum/src/components/PostStream.js
Normal file
@ -0,0 +1,556 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ScrollListener from 'flarum/utils/ScrollListener';
|
||||
import PostLoading from 'flarum/components/LoadingPost';
|
||||
import anchorScroll from 'flarum/utils/anchorScroll';
|
||||
import mixin from 'flarum/utils/mixin';
|
||||
import evented from 'flarum/utils/evented';
|
||||
import ReplyPlaceholder from 'flarum/components/ReplyPlaceholder';
|
||||
|
||||
/**
|
||||
* The `PostStream` component displays an infinitely-scrollable wall of posts in
|
||||
* a discussion. Posts that have not loaded will be displayed as placeholders.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `discussion`
|
||||
* - `includedPosts`
|
||||
*/
|
||||
class PostStream extends mixin(Component, evented) {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The discussion to display the post stream for.
|
||||
*
|
||||
* @type {Discussion}
|
||||
*/
|
||||
this.discussion = this.props.discussion;
|
||||
|
||||
/**
|
||||
* Whether or not the infinite-scrolling auto-load functionality is
|
||||
* disabled.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.paused = false;
|
||||
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
this.loadPageTimeouts = {};
|
||||
this.pagesLoading = 0;
|
||||
|
||||
this.init(this.props.includedPosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll to a post with a certain number.
|
||||
*
|
||||
* @param {Integer} number
|
||||
* @param {Boolean} noAnimation
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToNumber(number, noAnimation) {
|
||||
this.paused = true;
|
||||
|
||||
const promise = this.loadNearNumber(number);
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
return promise.then(() => {
|
||||
m.redraw(true);
|
||||
|
||||
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll to a certain index within the discussion.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @param {Boolean} backwards Whether or not to load backwards from the given
|
||||
* index.
|
||||
* @param {Boolean} noAnimation
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToIndex(index, backwards, noAnimation) {
|
||||
this.paused = true;
|
||||
|
||||
const promise = this.loadNearIndex(index);
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
return promise.then(() => {
|
||||
anchorScroll(this.$('.post-stream-item:' + (backwards ? 'last' : 'first')), () => m.redraw(true));
|
||||
|
||||
this.scrollToIndex(index, noAnimation, backwards).done(this.unpause.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll up to the first post in the discussion.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToFirst() {
|
||||
return this.goToIndex(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and scroll down to the last post in the discussion.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
goToLast() {
|
||||
return this.goToIndex(this.count() - 1, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stream so that it loads and includes the latest posts in the
|
||||
* discussion, if the end is being viewed.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
update() {
|
||||
if (!this.viewingEnd) return;
|
||||
|
||||
this.visibleEnd = this.count();
|
||||
|
||||
this.loadRange(this.visibleStart, this.visibleEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of posts in the discussion.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
count() {
|
||||
return this.discussion.postIds().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that the given index is not outside of the possible range of
|
||||
* indexes in the discussion.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @protected
|
||||
*/
|
||||
sanitizeIndex(index) {
|
||||
return Math.max(0, Math.min(this.count(), index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the stream with the given array of posts.
|
||||
*
|
||||
* @param {Post[]} posts
|
||||
*/
|
||||
init(posts) {
|
||||
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
|
||||
this.visibleEnd = this.visibleStart + posts.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the stream so that a specific range of posts is displayed. If a range
|
||||
* is not specified, the first page of posts will be displayed.
|
||||
*
|
||||
* @param {Integer} [start]
|
||||
* @param {Integer} [end]
|
||||
*/
|
||||
reset(start, end) {
|
||||
this.visibleStart = start || 0;
|
||||
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible page of posts.
|
||||
*
|
||||
* @return {Post[]}
|
||||
*/
|
||||
posts() {
|
||||
return this.discussion.postIds()
|
||||
.slice(this.visibleStart, this.visibleEnd)
|
||||
.map(id => app.store.getById('posts', id));
|
||||
}
|
||||
|
||||
view() {
|
||||
function fadeIn(element, isInitialized, context) {
|
||||
if (!context.fadedIn) $(element).hide().fadeIn();
|
||||
context.fadedIn = true;
|
||||
}
|
||||
|
||||
let lastTime;
|
||||
|
||||
this.visibleEnd = this.sanitizeIndex(this.visibleEnd);
|
||||
this.viewingEnd = this.visibleEnd === this.count();
|
||||
|
||||
return (
|
||||
<div className="post-stream">
|
||||
{this.posts().map((post, i) => {
|
||||
let content;
|
||||
const attrs = {'data-index': this.visibleStart + i};
|
||||
|
||||
if (post) {
|
||||
const time = post.time();
|
||||
const PostComponent = app.postComponents[post.contentType()];
|
||||
content = PostComponent ? PostComponent.component({post}) : '';
|
||||
|
||||
attrs.key = 'post' + post.id();
|
||||
attrs.config = fadeIn;
|
||||
attrs['data-time'] = time.toISOString();
|
||||
attrs['data-number'] = post.number();
|
||||
|
||||
// If the post before this one was more than 4 hours ago, we will
|
||||
// display a 'time gap' indicating how long it has been in between
|
||||
// the posts.
|
||||
const dt = time - lastTime;
|
||||
|
||||
if (dt > 1000 * 60 * 60 * 24 * 4) {
|
||||
content = [
|
||||
<div className="time-gap">
|
||||
<span>{moment.duration(dt).humanize()} later</span>
|
||||
</div>,
|
||||
content
|
||||
];
|
||||
}
|
||||
|
||||
lastTime = time;
|
||||
} else {
|
||||
attrs.key = this.visibleStart + i;
|
||||
|
||||
content = PostLoading.component();
|
||||
}
|
||||
|
||||
return <div className="post-stream-item" {...attrs}>{content}</div>;
|
||||
})}
|
||||
|
||||
{
|
||||
// If we're viewing the end of the discussion, the user can reply, and
|
||||
// is not already doing so, then show a 'write a reply' placeholder.
|
||||
this.viewingEnd &&
|
||||
(!app.session.user || this.discussion.canReply()) &&
|
||||
!app.composingReplyTo(this.discussion)
|
||||
? (
|
||||
<div className="post-stream-item" key="reply">
|
||||
{ReplyPlaceholder.component({discussion: this.discussion})}
|
||||
</div>
|
||||
) : ''
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
// This is wrapped in setTimeout due to the following Mithril issue:
|
||||
// https://github.com/lhorie/mithril.js/issues/637
|
||||
setTimeout(() => this.scrollListener.start());
|
||||
|
||||
context.onunload = () => {
|
||||
this.scrollListener.stop();
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* When the window is scrolled, check if either extreme of the post stream is
|
||||
* in the viewport, and if so, trigger loading the next/previous page.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
onscroll(top) {
|
||||
if (this.paused) return;
|
||||
|
||||
const marginTop = this.getMarginTop();
|
||||
const viewportHeight = $(window).height() - marginTop;
|
||||
const viewportTop = top + marginTop;
|
||||
const loadAheadDistance = 500;
|
||||
|
||||
if (this.visibleStart > 0) {
|
||||
const $item = this.$('.post-stream-item[data-index=' + this.visibleStart + ']');
|
||||
|
||||
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
|
||||
this.loadPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.visibleEnd < this.count()) {
|
||||
const $item = this.$('.post-stream-item[data-index=' + (this.visibleEnd - 1) + ']');
|
||||
|
||||
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
|
||||
this.loadNext();
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle calculation of our position (start/end numbers of posts in the
|
||||
// viewport) to 100ms.
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the next page of posts.
|
||||
*/
|
||||
loadNext() {
|
||||
const start = this.visibleEnd;
|
||||
const end = this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount);
|
||||
|
||||
// Unload the posts which are two pages back from the page we're currently
|
||||
// loading.
|
||||
const twoPagesAway = start - this.constructor.loadCount * 2;
|
||||
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
|
||||
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
}
|
||||
|
||||
this.loadPage(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the previous page of posts.
|
||||
*/
|
||||
loadPrevious() {
|
||||
const end = this.visibleStart;
|
||||
const start = this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount);
|
||||
|
||||
// Unload the posts which are two pages back from the page we're currently
|
||||
// loading.
|
||||
const twoPagesAway = start + this.constructor.loadCount * 2;
|
||||
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
|
||||
this.visibleEnd = twoPagesAway;
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
}
|
||||
|
||||
this.loadPage(start, end, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page of posts into the stream and redraw.
|
||||
*
|
||||
* @param {Integer} start
|
||||
* @param {Integer} end
|
||||
* @param {Boolean} backwards
|
||||
*/
|
||||
loadPage(start, end, backwards) {
|
||||
const redraw = () => {
|
||||
if (start < this.visibleStart || end > this.visibleEnd) return;
|
||||
|
||||
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||
anchorScroll(`.post-stream-item[data-index=${anchorIndex}]`, () => m.redraw(true));
|
||||
|
||||
this.unpause();
|
||||
};
|
||||
redraw();
|
||||
|
||||
this.pagesLoading++;
|
||||
|
||||
this.loadPageTimeouts[start] = setTimeout(() => {
|
||||
this.loadRange(start, end).then(() => {
|
||||
redraw();
|
||||
this.pagesLoading--;
|
||||
});
|
||||
}, this.pagesLoading ? 1000 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and inject the specified range of posts into the stream, without
|
||||
* clearing it.
|
||||
*
|
||||
* @param {Integer} start
|
||||
* @param {Integer} end
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadRange(start, end) {
|
||||
const loadIds = [];
|
||||
const loaded = [];
|
||||
|
||||
this.discussion.postIds().slice(start, end).forEach(id => {
|
||||
const post = app.store.getById('posts', id);
|
||||
|
||||
if (!post) {
|
||||
loadIds.push(id);
|
||||
} else {
|
||||
loaded.push(post);
|
||||
}
|
||||
});
|
||||
|
||||
return loadIds.length
|
||||
? app.store.find('posts', loadIds)
|
||||
: m.deferred().resolve(loaded).promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stream and load posts near a certain number. Returns a promise.
|
||||
* If the post with the given number is already loaded, the promise will be
|
||||
* resolved immediately.
|
||||
*
|
||||
* @param {Integer} number
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadNearNumber(number) {
|
||||
if (this.posts().some(post => post && post.number() === number)) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
this.reset();
|
||||
|
||||
return app.store.find('posts', {
|
||||
filter: {discussion: this.discussion.id()},
|
||||
page: {near: number}
|
||||
}).then(this.init.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the stream and load posts near a certain index. A page of posts
|
||||
* surrounding the given index will be loaded. Returns a promise. If the given
|
||||
* index is already loaded, the promise will be resolved immediately.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @return {Promise}
|
||||
*/
|
||||
loadNearIndex(index) {
|
||||
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
|
||||
const end = start + this.constructor.loadCount;
|
||||
|
||||
this.reset(start, end);
|
||||
|
||||
return this.loadRange(start, end).then(this.init.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which posts (by number) are currently visible in the viewport, and
|
||||
* fire an event with the information.
|
||||
*/
|
||||
calculatePosition() {
|
||||
const marginTop = this.getMarginTop();
|
||||
const $window = $(window);
|
||||
const viewportHeight = $window.height() - marginTop;
|
||||
const scrollTop = $window.scrollTop() + marginTop;
|
||||
let startNumber;
|
||||
let endNumber;
|
||||
|
||||
this.$('.post-stream-item').each(function() {
|
||||
const $item = $(this);
|
||||
const top = $item.offset().top;
|
||||
const height = $item.outerHeight(true);
|
||||
|
||||
if (top + height > scrollTop) {
|
||||
if (!startNumber) {
|
||||
startNumber = $item.data('number');
|
||||
}
|
||||
|
||||
if (top + height < scrollTop + viewportHeight) {
|
||||
if ($item.data('number')) {
|
||||
endNumber = $item.data('number');
|
||||
}
|
||||
} else return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (startNumber) {
|
||||
this.trigger('positionChanged', startNumber || 1, endNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
getMarginTop() {
|
||||
return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll down to a certain post by number and 'flash' it.
|
||||
*
|
||||
* @param {Integer} number
|
||||
* @param {Boolean} noAnimation
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToNumber(number, noAnimation) {
|
||||
const $item = this.$(`.post-stream-item[data-number=${number}]`);
|
||||
|
||||
return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll down to a certain post by index.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @param {Boolean} noAnimation
|
||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||
* at the given index, instead of the top of it.
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToIndex(index, noAnimation, bottom) {
|
||||
const $item = this.$(`.post-stream-item[data-index=${index}]`);
|
||||
|
||||
return this.scrollToItem($item, noAnimation, true, bottom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll down to the given post.
|
||||
*
|
||||
* @param {jQuery} $item
|
||||
* @param {Boolean} noAnimation
|
||||
* @param {Boolean} force Whether or not to force scrolling to the item, even
|
||||
* if it is already in the viewport.
|
||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||
* at the given index, instead of the top of it.
|
||||
* @return {jQuery.Deferred}
|
||||
*/
|
||||
scrollToItem($item, noAnimation, force, bottom) {
|
||||
const $container = $('html, body').stop(true);
|
||||
|
||||
if ($item.length) {
|
||||
const itemTop = $item.offset().top - this.getMarginTop();
|
||||
const itemBottom = itemTop + $item.height();
|
||||
const scrollTop = $(document).scrollTop();
|
||||
const scrollBottom = scrollTop + $(window).height();
|
||||
|
||||
// If the item is already in the viewport, we may not need to scroll.
|
||||
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||
const top = bottom ? itemBottom : ($item.is(':first-child') ? 0 : itemTop);
|
||||
|
||||
if (noAnimation) {
|
||||
$container.scrollTop(top);
|
||||
} else if (top !== scrollTop) {
|
||||
$container.animate({scrollTop: top}, 'fast');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $container.promise();
|
||||
}
|
||||
|
||||
/**
|
||||
* 'Flash' the given post, drawing the user's attention to it.
|
||||
*
|
||||
* @param {jQuery} $item
|
||||
*/
|
||||
flashItem($item) {
|
||||
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume the stream's ability to auto-load posts on scroll.
|
||||
*/
|
||||
unpause() {
|
||||
this.paused = false;
|
||||
this.scrollListener.update(true);
|
||||
this.trigger('unpaused');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of posts to load per page.
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
PostStream.loadCount = 20;
|
||||
|
||||
export default PostStream;
|
463
js/forum/src/components/PostStreamScrubber.js
Normal file
463
js/forum/src/components/PostStreamScrubber.js
Normal file
@ -0,0 +1,463 @@
|
||||
import Component from 'flarum/Component';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import ScrollListener from 'flarum/utils/ScrollListener';
|
||||
import SubtreeRetainer from 'flarum/utils/SubtreeRetainer';
|
||||
import computed from 'flarum/utils/computed';
|
||||
import formatNumber from 'flarum/utils/formatNumber';
|
||||
|
||||
/**
|
||||
* The `PostStreamScrubber` component displays a scrubber which can be used to
|
||||
* navigate/scrub through a post stream.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `stream`
|
||||
* - `className`
|
||||
*/
|
||||
export default class PostStreamScrubber extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.handlers = {};
|
||||
|
||||
/**
|
||||
* The index of the post that is currently at the top of the viewport.
|
||||
*
|
||||
* @type {Number}
|
||||
*/
|
||||
this.index = 0;
|
||||
|
||||
/**
|
||||
* The number of posts that are currently visible in the viewport.
|
||||
*
|
||||
* @type {Number}
|
||||
*/
|
||||
this.visible = 1;
|
||||
|
||||
/**
|
||||
* The description to render on the scrubber.
|
||||
*
|
||||
* @type {String}
|
||||
*/
|
||||
this.description = '';
|
||||
|
||||
/**
|
||||
* The integer index of the last item that is visible in the viewport. This
|
||||
* is displayed on the scrubber (i.e. X of 100 posts).
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
this.visibleIndex = computed('index', 'visible', 'count', function(index, visible, count) {
|
||||
return Math.min(count, Math.ceil(Math.max(0, index) + visible));
|
||||
});
|
||||
|
||||
// When the post stream begins loading posts at a certain index, we want our
|
||||
// scrubber scrollbar to jump to that position.
|
||||
this.props.stream.on('unpaused', this.handlers.streamWasUnpaused = this.streamWasUnpaused.bind(this));
|
||||
|
||||
// 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));
|
||||
|
||||
// Create a subtree retainer that will always cache the subtree after the
|
||||
// initial draw. We render parts of the scrubber using this because we
|
||||
// modify their DOM directly, and do not want Mithril messing around with
|
||||
// our changes.
|
||||
this.subtree = new SubtreeRetainer(() => true);
|
||||
}
|
||||
|
||||
view() {
|
||||
const retain = this.subtree.retain();
|
||||
const unreadCount = this.props.stream.discussion.unreadCount();
|
||||
const unreadPercent = Math.min(this.count() - this.index, unreadCount) / this.count();
|
||||
|
||||
const viewing = [
|
||||
<span className="index">{retain || formatNumber(this.visibleIndex())}</span>,
|
||||
' of ',
|
||||
<span className="count">{formatNumber(this.count())}</span>,
|
||||
' posts '
|
||||
];
|
||||
|
||||
function styleUnread(element, isInitialized, context) {
|
||||
const $element = $(element);
|
||||
const newStyle = {
|
||||
top: (100 - unreadPercent * 100) + '%',
|
||||
height: (unreadPercent * 100) + '%'
|
||||
};
|
||||
|
||||
if (context.oldStyle) {
|
||||
$element.stop(true).css(context.oldStyle).animate(newStyle);
|
||||
} else {
|
||||
$element.css(newStyle);
|
||||
}
|
||||
|
||||
context.oldStyle = newStyle;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'post-stream-scrubber dropdown ' + (this.disabled() ? 'disabled ' : '') + (this.props.className || '')}>
|
||||
<a href="javascript:;" className="btn btn-default dropdown-toggle" data-toggle="dropdown">
|
||||
{viewing} {icon('sort')}
|
||||
</a>
|
||||
|
||||
<div className="dropdown-menu">
|
||||
<div className="scrubber">
|
||||
<a href="javascript:;" className="scrubber-first" onclick={this.goToFirst.bind(this)}>
|
||||
{icon('angle-double-up')} Original Post
|
||||
</a>
|
||||
|
||||
<div className="scrubber-scrollbar">
|
||||
<div className="scrubber-before"/>
|
||||
<div className="scrubber-handle">
|
||||
<div className="scrubber-bar"/>
|
||||
<div className="scrubber-info">
|
||||
<strong>{viewing}</strong>
|
||||
<span class="description">{retain || this.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scrubber-after"/>
|
||||
|
||||
<div className="scrubber-unread" config={styleUnread}>
|
||||
{formatNumber(unreadCount)} unread
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="javascript:;" className="scrubber-last" onclick={this.goToLast.bind(this)}>
|
||||
{icon('angle-double-down')} Now
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the first post in the discussion.
|
||||
*/
|
||||
goToFirst() {
|
||||
this.props.stream.goToFirst();
|
||||
this.index = 0;
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to the last post in the discussion.
|
||||
*/
|
||||
goToLast() {
|
||||
this.props.stream.goToLast();
|
||||
this.index = this.props.stream.count();
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of posts in the discussion.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
count() {
|
||||
return this.props.stream.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the stream is unpaused, update the scrubber to reflect its position.
|
||||
*/
|
||||
streamWasUnpaused() {
|
||||
this.update(window.pageYOffset);
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the scrubber should be disabled, i.e. if all of the
|
||||
* posts are visible in the viewport.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
disabled() {
|
||||
return this.visible >= this.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the page is scrolled, update the scrollbar to reflect the visible
|
||||
* posts.
|
||||
*
|
||||
* @param {Integer} top
|
||||
*/
|
||||
onscroll(top) {
|
||||
const stream = this.props.stream;
|
||||
|
||||
if (stream.paused || !stream.$()) return;
|
||||
|
||||
this.update(top);
|
||||
this.renderScrollbar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the index/visible/description properties according to the window's
|
||||
* current scroll position.
|
||||
*
|
||||
* @param {Integer} scrollTop
|
||||
*/
|
||||
update(scrollTop) {
|
||||
const stream = this.props.stream;
|
||||
|
||||
const marginTop = stream.getMarginTop();
|
||||
const viewportTop = scrollTop + marginTop;
|
||||
const viewportHeight = $(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.
|
||||
const $items = stream.$('> .post-stream-item[data-index]');
|
||||
let index = $items.first().data('index') || 0;
|
||||
let visible = 0;
|
||||
let 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() {
|
||||
const $this = $(this);
|
||||
const top = $this.offset().top;
|
||||
const 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 < viewportTop) {
|
||||
visible = (top + height - viewportTop) / height;
|
||||
index = parseFloat($this.data('index')) + 1 - visible;
|
||||
return true;
|
||||
}
|
||||
if (top > viewportTop + viewportHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the bottom half of this item is visible at the top of the
|
||||
// viewport, then set the start of the visible proportion as our index.
|
||||
if (top <= viewportTop && top + height > viewportTop) {
|
||||
visible = (top + height - viewportTop) / height;
|
||||
index = parseFloat($this.data('index')) + 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 >= viewportTop + viewportHeight) {
|
||||
visible += (viewportTop + viewportHeight - 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.
|
||||
const time = $this.data('time');
|
||||
if (time) period = time;
|
||||
});
|
||||
|
||||
this.index = index;
|
||||
this.visible = visible;
|
||||
this.description = period ? moment(period).format('MMMM YYYY') : '';
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
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();
|
||||
|
||||
// When any part of the whole scrollbar is clicked, we want to jump to
|
||||
// that position.
|
||||
this.$('.scrubber-scrollbar')
|
||||
.bind('click', this.onclick.bind(this))
|
||||
|
||||
// Now we want to make the scrollbar handle draggable. Let's start by
|
||||
// preventing default browser events from messing things up.
|
||||
.css({ cursor: 'pointer', 'user-select': 'none' })
|
||||
.bind('dragstart mousedown touchstart', 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.dragging = false;
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
|
||||
this.$('.scrubber-handle')
|
||||
.css('cursor', 'move')
|
||||
.bind('mousedown touchstart', this.onmousedown.bind(this))
|
||||
|
||||
// Exempt the scrollbar handle from the 'jump to' click event.
|
||||
.click(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.stream.off('unpaused', this.handlers.streamWasUnpaused);
|
||||
|
||||
$(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.
|
||||
*
|
||||
* @param {Boolean} animate
|
||||
*/
|
||||
renderScrollbar(animate) {
|
||||
const percentPerPost = this.percentPerPost();
|
||||
const index = this.index;
|
||||
const count = this.count();
|
||||
const visible = this.visible || 1;
|
||||
|
||||
const $scrubber = this.$();
|
||||
$scrubber.find('.index').text(formatNumber(this.visibleIndex()));
|
||||
$scrubber.find('.description').text(this.description);
|
||||
$scrubber.toggleClass('disabled', this.disabled());
|
||||
|
||||
const heights = {};
|
||||
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
|
||||
heights.handle = Math.min(100 - heights.before, percentPerPost.visible * visible);
|
||||
heights.after = 100 - heights.before - heights.handle;
|
||||
|
||||
const func = animate ? 'animate' : 'css';
|
||||
for (const part in heights) {
|
||||
const $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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the percentage of the height of the scrubber that should be allocated
|
||||
* to each post.
|
||||
*
|
||||
* @return {Object}
|
||||
* @property {Number} index The percent per post for posts on either side of
|
||||
* the visible part of the scrubber.
|
||||
* @property {Number} visible The percent per post for the visible part of the
|
||||
* scrubber.
|
||||
*/
|
||||
percentPerPost() {
|
||||
const count = this.count() || 1;
|
||||
const visible = this.visible || 1;
|
||||
|
||||
// To stop the handle of the scrollbar from getting too small when there
|
||||
// are many posts, we define a minimum percentage height for the handle
|
||||
// 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.
|
||||
const minPercentVisible = 50 / this.$('.scrubber-scrollbar').outerHeight() * 100;
|
||||
const percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
|
||||
const percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
|
||||
|
||||
return {
|
||||
index: percentPerPost,
|
||||
visible: percentPerVisiblePost
|
||||
};
|
||||
}
|
||||
|
||||
onresize() {
|
||||
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.
|
||||
const scrubber = this.$();
|
||||
const scrollbar = this.$('.scrubber-scrollbar');
|
||||
|
||||
scrollbar.css('max-height', $(window).height() -
|
||||
scrubber.offset().top + $(window).scrollTop() -
|
||||
parseInt($('.global-page').css('padding-bottom'), 10) -
|
||||
(scrubber.outerHeight() - scrollbar.outerHeight()));
|
||||
}
|
||||
|
||||
onmousedown(e) {
|
||||
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
||||
this.indexStart = this.index;
|
||||
this.dragging = true;
|
||||
this.props.stream.paused = true;
|
||||
$('body').css('cursor', 'move');
|
||||
}
|
||||
|
||||
onmousemove(e) {
|
||||
if (!this.dragging) 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.
|
||||
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
|
||||
const deltaPercent = deltaPixels / this.$('.scrubber-scrollbar').outerHeight() * 100;
|
||||
const deltaIndex = deltaPercent / this.percentPerPost().index;
|
||||
const newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
|
||||
|
||||
this.index = Math.max(0, newIndex);
|
||||
this.renderScrollbar();
|
||||
}
|
||||
|
||||
onmouseup() {
|
||||
if (!this.dragging) return;
|
||||
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
this.dragging = false;
|
||||
$('body').css('cursor', '');
|
||||
|
||||
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.
|
||||
const intIndex = Math.floor(this.index);
|
||||
this.props.stream.goToIndex(intIndex);
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
onclick(e) {
|
||||
// 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.
|
||||
const $scrollbar = this.$('.scrubber-scrollbar');
|
||||
const offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $scrollbar.offset().top + $('body').scrollTop();
|
||||
let offsetPercent = offsetPixels / $scrollbar.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($scrollbar.find('.scrubber-handle')[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.
|
||||
let offsetIndex = offsetPercent / this.percentPerPost().index;
|
||||
offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex));
|
||||
this.props.stream.goToIndex(Math.floor(offsetIndex));
|
||||
this.index = offsetIndex;
|
||||
this.renderScrollbar(true);
|
||||
|
||||
this.$().removeClass('open');
|
||||
}
|
||||
}
|
100
js/forum/src/components/PostUser.js
Normal file
100
js/forum/src/components/PostUser.js
Normal file
@ -0,0 +1,100 @@
|
||||
import Component from 'flarum/Component';
|
||||
import UserCard from 'flarum/components/UserCard';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import username from 'flarum/helpers/username';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `PostUser` component shows the avatar and username of a post's author.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*/
|
||||
export default class PostHeaderUser extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Whether or not the user hover card is visible.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.cardVisible = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const post = this.props.post;
|
||||
const user = post.user();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="post-user">
|
||||
<h3>{avatar(user)} {username(user)}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let card = '';
|
||||
|
||||
if (!post.isHidden() && this.cardVisible) {
|
||||
card = UserCard.component({
|
||||
user,
|
||||
className: 'user-card-popover fade',
|
||||
controlsButtonClassName: 'btn btn-default btn-icon btn-controls btn-naked'
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="post-user">
|
||||
<h3>
|
||||
<a href={app.route.user(user)} config={m.route}>
|
||||
{avatar(user)} {username(user)}
|
||||
</a>
|
||||
</h3>
|
||||
<ul className="badges">
|
||||
{listItems(user.badges().toArray())}
|
||||
</ul>
|
||||
{card}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
let timeout;
|
||||
|
||||
this.$()
|
||||
.on('mouseover', 'h3 a, .user-card', () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(this.showCard.bind(this), 500);
|
||||
})
|
||||
.on('mouseout', 'h3 a, .user-card', () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(this.hideCard.bind(this), 250);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the user card.
|
||||
*/
|
||||
showCard() {
|
||||
this.cardVisible = true;
|
||||
|
||||
m.redraw();
|
||||
|
||||
setTimeout(() => this.$('.user-card').addClass('in'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the user card.
|
||||
*/
|
||||
hideCard() {
|
||||
this.$('.user-card').removeClass('in')
|
||||
.one('transitionend', () => {
|
||||
this.cardVisible = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
50
js/forum/src/components/PostedActivity.js
Normal file
50
js/forum/src/components/PostedActivity.js
Normal file
@ -0,0 +1,50 @@
|
||||
import Activity from 'flarum/components/Activity';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import { truncate } from 'flarum/utils/string';
|
||||
|
||||
/**
|
||||
* The `PostedActivity` component displays an activity feed item for when a user
|
||||
* started or posted in a discussion.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - All of the props for Activity
|
||||
*/
|
||||
export default class PostedActivity extends Activity {
|
||||
description() {
|
||||
const post = this.props.activity.subject();
|
||||
|
||||
return post.number() === 1 ? 'Started a discussion' : 'Posted a reply';
|
||||
}
|
||||
|
||||
content() {
|
||||
const post = this.props.activity.subject();
|
||||
|
||||
return (
|
||||
<a className="activity-content posted-activity-preview"
|
||||
href={app.route.post(post)}
|
||||
config={m.route}>
|
||||
<ul className="posted-activity-header">
|
||||
{listItems(this.headerItems().toArray())}
|
||||
</ul>
|
||||
<div className="posted-activity-body">
|
||||
{m.trust(truncate(post.contentPlain(), 200))}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the header of the post preview.
|
||||
*
|
||||
* @return {[type]}
|
||||
*/
|
||||
headerItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('title', <h3>{this.props.activity.subject().discussion().title()}</h3>);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
93
js/forum/src/components/ReplyComposer.js
Normal file
93
js/forum/src/components/ReplyComposer.js
Normal file
@ -0,0 +1,93 @@
|
||||
import ComposerBody from 'flarum/components/ComposerBody';
|
||||
import Alert from 'flarum/components/Alert';
|
||||
import Button from 'flarum/components/Button';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
/**
|
||||
* The `ReplyComposer` component displays the composer content for replying to a
|
||||
* discussion.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - All of the props of ComposerBody
|
||||
* - `discussion`
|
||||
*/
|
||||
export default class ReplyComposer extends ComposerBody {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.placeholder = props.placeholder || 'Write a Reply...';
|
||||
props.submitLabel = props.submitLabel || 'Post Reply';
|
||||
props.confirmExit = props.confirmExit || 'You have not posted your reply. Do you wish to discard it?';
|
||||
}
|
||||
|
||||
headerItems() {
|
||||
const items = super.headerItems();
|
||||
const discussion = this.props.discussion;
|
||||
|
||||
items.add('title', (
|
||||
<h3>
|
||||
{icon('reply')} <a href={app.route.discussion(discussion)} config={m.route}>{discussion.title()}</a>
|
||||
</h3>
|
||||
));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to submit to the server when the reply is saved.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
data() {
|
||||
return {
|
||||
content: this.content(),
|
||||
relationships: {discussion: this.props.discussion}
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit() {
|
||||
const discussion = this.props.discussion;
|
||||
|
||||
this.loading = true;
|
||||
m.redraw();
|
||||
|
||||
const data = this.data();
|
||||
|
||||
app.store.createRecord('posts').save(data).then(
|
||||
post => {
|
||||
// If we're currently viewing the discussion which this reply was made
|
||||
// in, then we can update the post stream.
|
||||
if (app.viewingDiscussion(discussion)) {
|
||||
app.current.stream.update();
|
||||
} 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.
|
||||
let alert;
|
||||
const viewButton = Button.component({
|
||||
children: 'View',
|
||||
onclick: () => {
|
||||
m.route(app.route.post(post));
|
||||
app.alerts.dismiss(alert);
|
||||
}
|
||||
});
|
||||
app.alerts.show(
|
||||
alert = new Alert({
|
||||
type: 'success',
|
||||
message: 'Your reply was posted.',
|
||||
controls: [viewButton]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
app.composer.hide();
|
||||
},
|
||||
errors => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
app.alertErrors(errors);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
33
js/forum/src/components/ReplyPlaceholder.js
Normal file
33
js/forum/src/components/ReplyPlaceholder.js
Normal file
@ -0,0 +1,33 @@
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import DiscussionControls from 'flarum/utils/DiscussionControls';
|
||||
|
||||
/**
|
||||
* The `ReplyPlaceholder` component displays a placeholder for a reply, which,
|
||||
* when clicked, opens the reply composer.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `discussion`
|
||||
*/
|
||||
export default class ReplyPlaceholder extends Component {
|
||||
view() {
|
||||
function triggerClick(e) {
|
||||
$(this).trigger('click');
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const reply = () => {
|
||||
DiscussionControls.replyAction.call(this.props.discussion, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="post reply-post" onclick={reply} onmousedown={triggerClick}>
|
||||
<header className="post-header">
|
||||
{avatar(app.session.user)}
|
||||
Write a Reply...
|
||||
</header>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
291
js/forum/src/components/Search.js
Normal file
291
js/forum/src/components/Search.js
Normal file
@ -0,0 +1,291 @@
|
||||
import Component from 'flarum/Component';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import classList from 'flarum/utils/classList';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import DiscussionsSearchSource from 'flarum/components/DiscussionsSearchSource';
|
||||
import UsersSearchSource from 'flarum/components/UsersSearchSource';
|
||||
|
||||
/**
|
||||
* The `Search` component displays a menu of as-you-type results from a variety
|
||||
* of sources.
|
||||
*
|
||||
* The search box will be 'activated' if the app's current controller implements
|
||||
* a `searching` method that returns a truthy value. If this is the case, an 'x'
|
||||
* button will be shown next to the search field, and clicking it will call the
|
||||
* `clearSearch` method on the controller.
|
||||
*/
|
||||
export default class Search extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The value of the search input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.value = m.prop();
|
||||
|
||||
/**
|
||||
* Whether or not the search input has focus.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.hasFocus = false;
|
||||
|
||||
/**
|
||||
* An array of SearchSources.
|
||||
*
|
||||
* @type {SearchSource[]}
|
||||
*/
|
||||
this.sources = this.sourceItems().toArray();
|
||||
|
||||
/**
|
||||
* The number of sources that are still loading results.
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
this.loadingSources = 0;
|
||||
|
||||
/**
|
||||
* A list of queries that have been searched for.
|
||||
*
|
||||
* @type {Array}
|
||||
*/
|
||||
this.searched = [];
|
||||
|
||||
/**
|
||||
* The index of the currently-selected <li> in the results list. This can be
|
||||
* a unique string (to account for the fact that an item's position may jump
|
||||
* around as new results load), but otherwise it will be numeric (the
|
||||
* sequential position within the list).
|
||||
*
|
||||
* @type {String|Integer}
|
||||
*/
|
||||
this.index = 0;
|
||||
}
|
||||
|
||||
view() {
|
||||
const currentSearch = this.getCurrentSearch();
|
||||
|
||||
// Initialize search input value in the view rather than the constructor so
|
||||
// that we have access to app.current.
|
||||
if (typeof this.value() === 'undefined') {
|
||||
this.value(currentSearch || '');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'search dropdown ' + classList({
|
||||
open: this.value() && this.hasFocus,
|
||||
active: !!currentSearch,
|
||||
loading: !!this.loadingSources
|
||||
})}>
|
||||
<div className="search-input">
|
||||
<input className="form-control"
|
||||
placeholder="Search Forum"
|
||||
value={this.value()}
|
||||
oninput={m.withAttr('value', this.value)}
|
||||
onfocus={() => this.hasFocus = true}
|
||||
onblur={() => this.hasFocus = false}/>
|
||||
{this.loadingSources
|
||||
? LoadingIndicator.component({size: 'tiny', className: 'btn btn-icon btn-link'})
|
||||
: currentSearch
|
||||
? <button className="clear btn btn-icon btn-link" onclick={this.clear.bind(this)}>{icon('times-circle')}</button>
|
||||
: ''}
|
||||
</div>
|
||||
<ul className="dropdown-menu dropdown-menu-right search-results">
|
||||
{this.sources.map(source => source.view(this.value()))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized) {
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
|
||||
if (isInitialized) return;
|
||||
|
||||
const search = this;
|
||||
|
||||
this.$('.search-results')
|
||||
.on('mousedown', e => e.preventDefault())
|
||||
.on('click', () => this.$('input').blur())
|
||||
|
||||
// Whenever the mouse is hovered over a search result, highlight it.
|
||||
.on('mouseenter', '> li:not(.dropdown-header)', function() {
|
||||
search.setIndex(
|
||||
search.selectableItems().index(this)
|
||||
);
|
||||
});
|
||||
|
||||
// Handle navigation key events on the search input.
|
||||
this.$('input')
|
||||
.on('keydown', e => {
|
||||
switch (e.which) {
|
||||
case 40: case 38: // Down/Up
|
||||
this.setIndex(this.getCurrentNumericIndex() + (e.which === 40 ? 1 : -1), true);
|
||||
e.preventDefault();
|
||||
break;
|
||||
|
||||
case 13: // Return
|
||||
this.$('input').blur();
|
||||
m.route(this.getItem(this.index).find('a').attr('href'));
|
||||
app.drawer.hide();
|
||||
break;
|
||||
|
||||
case 27: // Escape
|
||||
this.clear();
|
||||
break;
|
||||
|
||||
default:
|
||||
// no default
|
||||
}
|
||||
})
|
||||
|
||||
// Handle input key events on the search input, triggering results to
|
||||
// load.
|
||||
.on('input focus', function() {
|
||||
const query = this.value.toLowerCase();
|
||||
|
||||
if (!query) return;
|
||||
|
||||
clearTimeout(search.searchTimeout);
|
||||
search.searchTimeout = setTimeout(() => {
|
||||
if (search.searched.indexOf(query) !== -1) return;
|
||||
|
||||
if (query.length >= 3) {
|
||||
search.sources.map(source => {
|
||||
if (!source.search) return;
|
||||
|
||||
search.loadingSources++;
|
||||
|
||||
source.search(query).then(() => {
|
||||
search.loadingSources--;
|
||||
m.redraw();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
search.searched.push(query);
|
||||
m.redraw();
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active search in the app's current controller.
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
getCurrentSearch() {
|
||||
return app.current && typeof app.current.searching === 'function' && app.current.searching();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search input and the current controller's active search.
|
||||
*/
|
||||
clear() {
|
||||
this.value('');
|
||||
|
||||
if (this.getCurrentSearch()) {
|
||||
app.current.clearSearch();
|
||||
} else {
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of SearchSources.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
sourceItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('discussions', new DiscussionsSearchSource());
|
||||
items.add('users', new UsersSearchSource());
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the search result items that are selectable.
|
||||
*
|
||||
* @return {jQuery}
|
||||
*/
|
||||
selectableItems() {
|
||||
return this.$('.search-results > li:not(.dropdown-header)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the position of the currently selected search result item.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
getCurrentNumericIndex() {
|
||||
return this.selectableItems().index(
|
||||
this.getItem(this.index)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the <li> in the search results with the given index (numeric or named).
|
||||
*
|
||||
* @param {String} index
|
||||
* @return {DOMElement}
|
||||
*/
|
||||
getItem(index) {
|
||||
const $items = this.selectableItems();
|
||||
let $item = $items.filter(`[data-index=${index}]`);
|
||||
|
||||
if (!$item.length) {
|
||||
$item = $items.eq(index);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the currently-selected search result item to the one with the given
|
||||
* index.
|
||||
*
|
||||
* @param {Integer} index
|
||||
* @param {Boolean} scrollToItem Whether or not to scroll the dropdown so that
|
||||
* the item is in view.
|
||||
*/
|
||||
setIndex(index, scrollToItem) {
|
||||
const $items = this.selectableItems();
|
||||
const $dropdown = $items.parent();
|
||||
|
||||
let fixedIndex = index;
|
||||
if (index < 0) {
|
||||
fixedIndex = $items.length - 1;
|
||||
} else if (index >= $items.length) {
|
||||
fixedIndex = 0;
|
||||
}
|
||||
|
||||
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
|
||||
|
||||
this.index = $item.attr('data-index') || fixedIndex;
|
||||
|
||||
if (scrollToItem) {
|
||||
const dropdownScroll = $dropdown.scrollTop();
|
||||
const dropdownTop = $dropdown.offset().top;
|
||||
const dropdownBottom = dropdownTop + $dropdown.outerHeight();
|
||||
const itemTop = $item.offset().top;
|
||||
const itemBottom = itemTop + $item.outerHeight();
|
||||
|
||||
let scrollTop;
|
||||
if (itemTop < dropdownTop) {
|
||||
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
|
||||
} else if (itemBottom > dropdownBottom) {
|
||||
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
|
||||
}
|
||||
|
||||
if (typeof scrollTop !== 'undefined') {
|
||||
$dropdown.stop(true).animate({scrollTop}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
js/forum/src/components/SearchSource.js
Normal file
32
js/forum/src/components/SearchSource.js
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The `SearchSource` interface defines a section of search results in the
|
||||
* search dropdown.
|
||||
*
|
||||
* Search sources should be registered with the `Search` component instance
|
||||
* (app.search) by extending the `sourceItems` method. When the user types a
|
||||
* query, each search source will be prompted to load search results via the
|
||||
* `search` method. When the dropdown is redrawn, it will be constructed by
|
||||
* putting together the output from the `view` method of each source.
|
||||
*
|
||||
* @interface
|
||||
*/
|
||||
export default class SearchSource {
|
||||
/**
|
||||
* Make a request to get results for the given query.
|
||||
*
|
||||
* @param {String} query
|
||||
* @return {Promise}
|
||||
*/
|
||||
search() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of virtual <li>s that list the search results for the given
|
||||
* query.
|
||||
*
|
||||
* @param {String} query
|
||||
* @return {Object}
|
||||
*/
|
||||
view() {
|
||||
}
|
||||
}
|
90
js/forum/src/components/SessionDropdown.js
Normal file
90
js/forum/src/components/SessionDropdown.js
Normal file
@ -0,0 +1,90 @@
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import username from 'flarum/helpers/username';
|
||||
import Dropdown from 'flarum/components/Dropdown';
|
||||
import Button from 'flarum/components/Button';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import Separator from 'flarum/components/Separator';
|
||||
import Group from 'flarum/models/Group';
|
||||
|
||||
/**
|
||||
* The `SessionDropdown` component shows a button with the current user's
|
||||
* avatar/name, with a dropdown of session controls.
|
||||
*/
|
||||
export default class SessionDropdown extends Dropdown {
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.buttonClassName = 'btn btn-default btn-naked btn-rounded btn-user';
|
||||
props.menuClassName = 'dropdown-menu-right';
|
||||
}
|
||||
|
||||
view() {
|
||||
this.props.children = this.items().toArray();
|
||||
|
||||
return super.view();
|
||||
}
|
||||
|
||||
getButtonContent() {
|
||||
const user = app.session.user;
|
||||
|
||||
return [
|
||||
avatar(user), ' ',
|
||||
<span className="label">{username(user)}</span>
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the contents of the dropdown menu.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
items() {
|
||||
const items = new ItemList();
|
||||
const user = app.session.user;
|
||||
|
||||
items.add('profile',
|
||||
Button.component({
|
||||
icon: 'user',
|
||||
children: 'Profile',
|
||||
href: app.route.user(user),
|
||||
config: m.route
|
||||
}),
|
||||
100
|
||||
);
|
||||
|
||||
items.add('settings',
|
||||
Button.component({
|
||||
icon: 'cog',
|
||||
children: 'Settings',
|
||||
href: app.route('settings'),
|
||||
config: m.route
|
||||
}),
|
||||
50
|
||||
);
|
||||
|
||||
if (user.groups().some(group => Number(group.id()) === Group.ADMINISTRATOR_ID)) {
|
||||
items.add('administration',
|
||||
Button.component({
|
||||
icon: 'wrench',
|
||||
children: 'Administration',
|
||||
href: app.forum.attribute('baseUrl') + '/admin',
|
||||
target: '_blank'
|
||||
}),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
items.add('separator', Separator.component(), -90);
|
||||
|
||||
items.add('logOut',
|
||||
Button.component({
|
||||
icon: 'sign-out',
|
||||
children: 'Log Out',
|
||||
onclick: app.session.logout.bind(app.session)
|
||||
}),
|
||||
-100
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
145
js/forum/src/components/SettingsPage.js
Normal file
145
js/forum/src/components/SettingsPage.js
Normal file
@ -0,0 +1,145 @@
|
||||
import UserPage from 'flarum/components/UserPage';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import Switch from 'flarum/components/Switch';
|
||||
import Button from 'flarum/components/Button';
|
||||
import FieldSet from 'flarum/components/FieldSet';
|
||||
import NotificationGrid from 'flarum/components/NotificationGrid';
|
||||
import ChangePasswordModal from 'flarum/components/ChangePasswordModal';
|
||||
import ChangeEmailModal from 'flarum/components/ChangeEmailModal';
|
||||
import DeleteAccountModal from 'flarum/components/DeleteAccountModal';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `SettingsPage` component displays the user's settings control panel, in
|
||||
* the context of their user profile.
|
||||
*/
|
||||
export default class SettingsPage extends UserPage {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.init(app.session.user);
|
||||
app.setTitle('Settings');
|
||||
app.drawer.hide();
|
||||
}
|
||||
|
||||
content() {
|
||||
return (
|
||||
<div className="settings">
|
||||
<ul>{listItems(this.settingsItems().toArray())}</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the user's settings controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
settingsItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('account',
|
||||
FieldSet.component({
|
||||
label: 'Account',
|
||||
className: 'settings-account',
|
||||
children: this.accountItems().toArray()
|
||||
})
|
||||
);
|
||||
|
||||
items.add('notifications',
|
||||
FieldSet.component({
|
||||
label: 'Notifications',
|
||||
className: 'settings-account',
|
||||
children: [NotificationGrid.component({user: this.user})]
|
||||
})
|
||||
);
|
||||
|
||||
items.add('privacy',
|
||||
FieldSet.component({
|
||||
label: 'Privacy',
|
||||
className: 'settings-privacy',
|
||||
children: this.privacyItems().toArray()
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the user's account settings.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
accountItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('changePassword',
|
||||
Button.component({
|
||||
children: 'Change Password',
|
||||
className: 'btn btn-default',
|
||||
onclick: () => app.modal.show(new ChangePasswordModal())
|
||||
})
|
||||
);
|
||||
|
||||
items.add('changeEmail',
|
||||
Button.component({
|
||||
children: 'Change Email',
|
||||
className: 'btn btn-default',
|
||||
onclick: () => app.modal.show(new ChangeEmailModal())
|
||||
})
|
||||
);
|
||||
|
||||
items.add('deleteAccount',
|
||||
Button.component({
|
||||
children: 'Delete Account',
|
||||
className: 'btn btn-default btn-danger',
|
||||
onclick: () => app.modal.show(new DeleteAccountModal())
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a callback that will save a value to the given preference.
|
||||
*
|
||||
* @param {String} key
|
||||
* @return {Function}
|
||||
*/
|
||||
preferenceSaver(key) {
|
||||
return (value, component) => {
|
||||
const preferences = this.user.preferences();
|
||||
preferences[key] = value;
|
||||
|
||||
if (component) component.loading = true;
|
||||
m.redraw();
|
||||
|
||||
this.user.save({preferences}).then(() => {
|
||||
if (component) component.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the user's privacy settings.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
privacyItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('discloseOnline',
|
||||
Switch.component({
|
||||
children: 'Allow others to see when I am online',
|
||||
state: this.user.preferences().discloseOnline,
|
||||
onchange: (value, component) => {
|
||||
this.user.pushAttributes({lastSeenTime: null});
|
||||
this.preferenceSaver('discloseOnline')(value, component);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
172
js/forum/src/components/SignUpModal.js
Normal file
172
js/forum/src/components/SignUpModal.js
Normal file
@ -0,0 +1,172 @@
|
||||
import Modal from 'flarum/components/Modal';
|
||||
import LogInModal from 'flarum/components/LogInModal';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
|
||||
/**
|
||||
* The `SignUpModal` component displays a modal dialog with a singup form.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `username`
|
||||
* - `email`
|
||||
* - `password`
|
||||
*/
|
||||
export default class SignUpModal extends Modal {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The value of the username input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.username = m.prop(this.props.username || '');
|
||||
|
||||
/**
|
||||
* The value of the email input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.email = m.prop(this.props.email || '');
|
||||
|
||||
/**
|
||||
* The value of the password input.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
this.password = m.prop(this.props.password || '');
|
||||
|
||||
/**
|
||||
* The user that has been signed up and that should be welcomed.
|
||||
*
|
||||
* @type {null|User}
|
||||
*/
|
||||
this.welcomeUser = null;
|
||||
}
|
||||
|
||||
className() {
|
||||
return 'modal-sm signup-modal' + (this.welcomeUser ? ' signup-modal-success' : '');
|
||||
}
|
||||
|
||||
title() {
|
||||
return 'Sign Up';
|
||||
}
|
||||
|
||||
body() {
|
||||
const body = [(
|
||||
<div className="form-centered">
|
||||
<div className="form-group">
|
||||
<input className="form-control" name="username" placeholder="Username"
|
||||
value={this.username()}
|
||||
onchange={m.withAttr('value', this.email)}
|
||||
disabled={this.loading} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<input className="form-control" name="email" type="email" placeholder="Email"
|
||||
value={this.email()}
|
||||
onchange={m.withAttr('value', this.email)}
|
||||
disabled={this.loading} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<input className="form-control" name="password" type="password" placeholder="Password"
|
||||
value={this.password()}
|
||||
onchange={m.withAttr('value', this.password)}
|
||||
disabled={this.loading} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<button className="btn btn-primary btn-block"
|
||||
type="submit"
|
||||
disabled={this.loading}>
|
||||
Sign Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)];
|
||||
|
||||
if (this.welcomeUser) {
|
||||
const user = this.welcomeUser;
|
||||
const emailProviderName = user.email().split('@')[1];
|
||||
|
||||
const fadeIn = (element, isInitialized) => {
|
||||
if (isInitialized) return;
|
||||
$(element).hide().fadeIn();
|
||||
};
|
||||
|
||||
body.push(
|
||||
<div className="signup-welcome" style={{background: user.color()}} config={fadeIn}>
|
||||
<div className="darken-overlay"/>
|
||||
<div className="container">
|
||||
{avatar(user)}
|
||||
<h3>Welcome, {user.username()}!</h3>
|
||||
|
||||
{user.isConfirmed() ? [
|
||||
<p>We've sent a confirmation email to <strong>{user.email()}</strong>. If it doesn't arrive soon, check your spam folder.</p>,
|
||||
<p><a href={`http://${emailProviderName}`} className="btn btn-primary">Go to {emailProviderName}</a></p>
|
||||
] : (
|
||||
<p><button className="btn btn-primary" onclick={this.hide.bind(this)}>Dismiss</button></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
footer() {
|
||||
return [
|
||||
<p className="log-in-link">
|
||||
Already have an account?
|
||||
<a href="javascript:;" onclick={this.logIn.bind(this)}>Log In</a>
|
||||
</p>
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the log in modal, prefilling it with an email/username/password if
|
||||
* the user has entered one.
|
||||
*/
|
||||
logIn() {
|
||||
const props = {
|
||||
email: this.email() || this.username(),
|
||||
password: this.password()
|
||||
};
|
||||
|
||||
app.modal.show(new LogInModal(props));
|
||||
}
|
||||
|
||||
onready() {
|
||||
if (this.props.username) {
|
||||
this.$('[name=email]').select();
|
||||
} else {
|
||||
super.onready();
|
||||
}
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
this.loading = true;
|
||||
|
||||
const data = {
|
||||
username: this.username(),
|
||||
email: this.email(),
|
||||
password: this.password()
|
||||
};
|
||||
|
||||
app.store.createRecord('users').save(data).then(
|
||||
user => {
|
||||
this.welcomeUser = user;
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
},
|
||||
response => {
|
||||
this.loading = false;
|
||||
this.handleErrors(response.errors);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
29
js/forum/src/components/TerminalPost.js
Normal file
29
js/forum/src/components/TerminalPost.js
Normal file
@ -0,0 +1,29 @@
|
||||
import Component from 'flarum/Component';
|
||||
import humanTime from 'flarum/helpers/humanTime';
|
||||
import username from 'flarum/helpers/username';
|
||||
|
||||
/**
|
||||
* Displays information about a the first or last post in a discussion.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `discussion`
|
||||
* - `lastPost`
|
||||
*/
|
||||
export default class TerminalPost extends Component {
|
||||
view() {
|
||||
const discussion = this.props.discussion;
|
||||
const lastPost = this.props.lastPost && discussion.repliesCount();
|
||||
|
||||
const user = discussion[lastPost ? 'lastUser' : 'startUser']();
|
||||
const time = discussion[lastPost ? 'lastTime' : 'startTime']();
|
||||
|
||||
return (
|
||||
<span>
|
||||
{username(user)}
|
||||
{lastPost ? 'replied' : 'started'}
|
||||
{humanTime(time)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
158
js/forum/src/components/TextEditor.js
Normal file
158
js/forum/src/components/TextEditor.js
Normal file
@ -0,0 +1,158 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import Button from 'flarum/components/Button';
|
||||
|
||||
/**
|
||||
* The `TextEditor` component displays a textarea with controls, including a
|
||||
* submit button.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `submitLabel`
|
||||
* - `value`
|
||||
* - `placeholder`
|
||||
* - `disabled`
|
||||
*/
|
||||
export default class TextEditor extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The value of the textarea.
|
||||
*
|
||||
* @type {[type]}
|
||||
*/
|
||||
this.value = m.prop(this.props.value || '');
|
||||
}
|
||||
|
||||
static initProps(props) {
|
||||
super.initProps(props);
|
||||
|
||||
props.submitLabel = props.submitLabel || 'Submit';
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div className="text-editor">
|
||||
<textarea className="form-control flexible-height"
|
||||
config={this.configTextarea.bind(this)}
|
||||
oninput={m.withAttr('value', this.oninput.bind(this))}
|
||||
placeholder={this.props.placeholder || ''}
|
||||
disabled={!!this.props.disabled}
|
||||
value={this.value()}/>
|
||||
|
||||
<ul className="text-editor-controls">
|
||||
{listItems(this.controlItems().toArray())}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the textarea element.
|
||||
*
|
||||
* @param {DOMElement} element
|
||||
* @param {Boolean} isInitialized
|
||||
*/
|
||||
configTextarea(element, isInitialized) {
|
||||
if (isInitialized) return;
|
||||
|
||||
$(element).bind('keydown', 'meta+return', () => {
|
||||
this.onsubmit();
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the text editor controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
controlItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('submit',
|
||||
Button.component({
|
||||
children: this.props.submitLabel,
|
||||
icon: 'check',
|
||||
className: 'btn btn-primary',
|
||||
onclick: this.onsubmit.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of the text editor.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
setValue(value) {
|
||||
this.$('textarea').val(value).trigger('input');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected range of the textarea.
|
||||
*
|
||||
* @param {Integer} start
|
||||
* @param {Integer} end
|
||||
*/
|
||||
setSelectionRange(start, end) {
|
||||
const $textarea = this.$('textarea');
|
||||
|
||||
$textarea[0].setSelectionRange(start, end);
|
||||
$textarea.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected range of the textarea.
|
||||
*
|
||||
* @return {Array}
|
||||
*/
|
||||
getSelectionRange() {
|
||||
const $textarea = this.$('textarea');
|
||||
|
||||
return [$textarea[0].selectionStart, $textarea[0].selectionEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content into the textarea at the position of the cursor.
|
||||
*
|
||||
* @param {String} insert
|
||||
*/
|
||||
insertAtCursor(insert) {
|
||||
const textarea = this.$('textarea')[0];
|
||||
const value = this.value();
|
||||
const index = textarea ? textarea.selectionStart : value.length;
|
||||
|
||||
this.setValue(value.slice(0, index) + insert + value.slice(index));
|
||||
|
||||
// Move the textarea cursor to the end of the content we just inserted.
|
||||
if (textarea) {
|
||||
const pos = index + insert.length;
|
||||
this.setSelectionRange(pos, pos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle input into the textarea.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
oninput(value) {
|
||||
this.value(value);
|
||||
|
||||
this.props.onchange(this.value());
|
||||
|
||||
m.redraw.strategy('none');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the submit button being clicked.
|
||||
*/
|
||||
onsubmit() {
|
||||
this.props.onsubmit(this.value());
|
||||
}
|
||||
}
|
112
js/forum/src/components/UserBio.js
Normal file
112
js/forum/src/components/UserBio.js
Normal file
@ -0,0 +1,112 @@
|
||||
import Component from 'flarum/Component';
|
||||
import classList from 'flarum/utils/classList';
|
||||
|
||||
/**
|
||||
* The `UserBio` component displays a user's bio, optionally letting the user
|
||||
* edit it (if they have permission).
|
||||
*/
|
||||
export default class UserBio extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Whether or not the bio is currently being edited.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.editing = false;
|
||||
|
||||
/**
|
||||
* Whether or not the bio is currently being saved.
|
||||
*
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
view() {
|
||||
const user = this.props.user;
|
||||
let content;
|
||||
|
||||
if (this.editing) {
|
||||
content = <textarea className="form-control" placeholder="Write something about yourself" rows="3"/>;
|
||||
} else {
|
||||
let subContent;
|
||||
|
||||
if (this.loading) {
|
||||
subContent = <p className="placeholder">Saving</p>;
|
||||
} else {
|
||||
const bioHtml = user.bioHtml();
|
||||
|
||||
if (bioHtml) {
|
||||
subContent = m.trust(bioHtml);
|
||||
} else if (this.props.editable) {
|
||||
subContent = <p className="placeholder">Write something about yourself</p>;
|
||||
}
|
||||
}
|
||||
|
||||
content = <div className="bio-content">{subContent}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'bio ' + classList({
|
||||
editable: this.isEditable(),
|
||||
editing: this.editing
|
||||
})}
|
||||
onclick={this.edit.bind(this)}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not the bio can be edited.
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isEditable() {
|
||||
return this.props.user.canEdit() && this.props.editable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the bio.
|
||||
*/
|
||||
edit() {
|
||||
if (!this.isEditable()) return;
|
||||
|
||||
this.editing = true;
|
||||
m.redraw();
|
||||
|
||||
const bio = this;
|
||||
const save = function(e) {
|
||||
if (e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
bio.save($(this).val());
|
||||
};
|
||||
|
||||
this.$('textarea').focus()
|
||||
.bind('blur', save)
|
||||
.bind('keydown', 'return', save);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the bio.
|
||||
*
|
||||
* @param {String} value
|
||||
*/
|
||||
save(value) {
|
||||
const user = this.props.user;
|
||||
|
||||
if (user.bio() !== value) {
|
||||
this.loading = true;
|
||||
|
||||
user.save({bio: value}).then(() => {
|
||||
this.loading = false;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
this.editing = false;
|
||||
m.redraw();
|
||||
}
|
||||
}
|
96
js/forum/src/components/UserCard.js
Normal file
96
js/forum/src/components/UserCard.js
Normal file
@ -0,0 +1,96 @@
|
||||
import Component from 'flarum/Component';
|
||||
import humanTime from 'flarum/utils/humanTime';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import UserControls from 'flarum/utils/UserControls';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import username from 'flarum/helpers/username';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import Dropdown from 'flarum/components/Dropdown';
|
||||
import UserBio from 'flarum/components/UserBio';
|
||||
import AvatarEditor from 'flarum/components/AvatarEditor';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `UserCard` component displays a user's profile card. This is used both on
|
||||
* the `UserPage` (in the hero) and in discussions, shown when hovering over a
|
||||
* post author.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `user`
|
||||
* - `className`
|
||||
* - `editable`
|
||||
* - `controlsButtonClassName`
|
||||
*/
|
||||
export default class UserCard extends Component {
|
||||
view() {
|
||||
const user = this.props.user;
|
||||
const controls = UserControls.controls(user, this).toArray();
|
||||
|
||||
return (
|
||||
<div className={'user-card ' + (this.props.className || '')}
|
||||
style={{backgroundColor: user.color()}}>
|
||||
<div className="darken-overlay"/>
|
||||
|
||||
<div className="container">
|
||||
{controls.length ? Dropdown.component({
|
||||
children: controls,
|
||||
className: 'contextual-controls',
|
||||
menuClass: 'dropdown-menu-right',
|
||||
buttonClass: this.props.controlsButtonClassName
|
||||
}) : ''}
|
||||
|
||||
<div className="user-profile">
|
||||
<h2 className="user-identity">
|
||||
{this.props.editable
|
||||
? [AvatarEditor.component({user, className: 'user-avatar'}), username(user)]
|
||||
: (
|
||||
<a href={app.route.user(user)} config={m.route}>
|
||||
{avatar(user, {className: 'user-avatar'})}
|
||||
{username(user)}
|
||||
</a>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<ul className="badges user-badges">{listItems(user.badges().toArray())}</ul>
|
||||
<ul className="user-info">{listItems(this.infoItems().toArray())}</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of tidbits of info to show on this user's profile.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
infoItems() {
|
||||
const items = new ItemList();
|
||||
const user = this.props.user;
|
||||
const lastSeenTime = user.lastSeenTime();
|
||||
|
||||
items.add('bio',
|
||||
UserBio.component({
|
||||
user,
|
||||
editable: this.props.editable
|
||||
})
|
||||
);
|
||||
|
||||
if (lastSeenTime) {
|
||||
const online = user.isOnline();
|
||||
|
||||
items.add('lastSeen', (
|
||||
<span className={'user-last-seen' + (online ? ' online' : '')}>
|
||||
{online
|
||||
? [icon('circle'), ' Online']
|
||||
: [icon('clock-o'), ' ', humanTime(lastSeenTime)]}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
items.add('joined', ['Joined ', humanTime(user.joinTime())]);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
173
js/forum/src/components/UserPage.js
Normal file
173
js/forum/src/components/UserPage.js
Normal file
@ -0,0 +1,173 @@
|
||||
import Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import affixSidebar from 'flarum/utils/affixSidebar';
|
||||
import UserCard from 'flarum/components/UserCard';
|
||||
import LoadingIndicator from 'flarum/components/LoadingIndicator';
|
||||
import SelectDropdown from 'flarum/components/SelectDropdown';
|
||||
import LinkButton from 'flarum/components/LinkButton';
|
||||
import Separator from 'flarum/components/Separator';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `UserPage` component shows a user's profile. It can be extended to show
|
||||
* content inside of the content area. See `ActivityPage` and `SettingsPage` for
|
||||
* examples.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class UserPage extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The user this page is for.
|
||||
*
|
||||
* @type {User}
|
||||
*/
|
||||
this.user = null;
|
||||
|
||||
app.history.push('user');
|
||||
app.current = this;
|
||||
app.drawer.hide();
|
||||
}
|
||||
|
||||
view() {
|
||||
return (
|
||||
<div>
|
||||
{this.user ? [
|
||||
UserCard.component({
|
||||
user: this.user,
|
||||
className: 'hero user-hero',
|
||||
editable: this.user.canEdit(),
|
||||
controlsButtonClassName: 'btn btn-default'
|
||||
}),
|
||||
<div className="container">
|
||||
<nav className="side-nav user-nav" config={affixSidebar}>
|
||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||
</nav>
|
||||
<div className="offset-content user-content">
|
||||
{this.content()}
|
||||
</div>
|
||||
</div>
|
||||
] : [
|
||||
LoadingIndicator.component({className: 'loading-indicator-block'})
|
||||
]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
$('body').addClass('user-page');
|
||||
context.onunload = () => $('body').removeClass('user-page');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content to display in the user page.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
*/
|
||||
content() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component with a user, and trigger the loading of their
|
||||
* activity feed.
|
||||
*
|
||||
* @param {User} user
|
||||
* @protected
|
||||
*/
|
||||
init(user) {
|
||||
this.user = user;
|
||||
|
||||
app.setTitle(user.username());
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a username, load the user's profile from the store, or make a request
|
||||
* if we don't have it yet. Then initialize the profile page with that user.
|
||||
*
|
||||
* @param {[type]} username [description]
|
||||
* @return {[type]}
|
||||
*/
|
||||
loadUser(username) {
|
||||
const lowercaseUsername = username.toLowerCase();
|
||||
|
||||
app.store.all('users').some(user => {
|
||||
if (user.username().toLowerCase() === lowercaseUsername && user.joinTime()) {
|
||||
this.init(user);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!this.user) {
|
||||
app.store.find('users', username).then(this.init.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the content of the sidebar.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
sidebarItems() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('nav',
|
||||
SelectDropdown.component({
|
||||
children: this.navItems().toArray(),
|
||||
itemClass: 'title-control'
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the navigation in the sidebar.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
navItems() {
|
||||
const items = new ItemList();
|
||||
const user = this.user;
|
||||
|
||||
items.add('activity',
|
||||
LinkButton.component({
|
||||
href: app.route('user.activity', {username: user.username()}),
|
||||
children: 'Activity',
|
||||
icon: 'user'
|
||||
})
|
||||
);
|
||||
|
||||
items.add('discussions',
|
||||
LinkButton.component({
|
||||
href: app.route('user.discussions', {username: user.username()}),
|
||||
children: ['Discussions', <span className="count">{user.discussionsCount()}</span>],
|
||||
icon: 'reorder'
|
||||
})
|
||||
);
|
||||
|
||||
items.add('posts',
|
||||
LinkButton.component({
|
||||
href: app.route('user.posts', {username: user.username()}),
|
||||
children: ['Posts', <span className="count">{user.commentsCount()}</span>],
|
||||
icon: 'comment-o'
|
||||
})
|
||||
);
|
||||
|
||||
if (app.session.user === user) {
|
||||
items.add('separator', Separator.component());
|
||||
items.add('settings',
|
||||
LinkButton.component({
|
||||
href: app.route('settings'),
|
||||
children: 'Settings',
|
||||
icon: 'cog'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
36
js/forum/src/components/UsersSearchSource.js
Normal file
36
js/forum/src/components/UsersSearchSource.js
Normal file
@ -0,0 +1,36 @@
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
|
||||
/**
|
||||
* The `UsersSearchSource` finds and displays user search results in the search
|
||||
* dropdown.
|
||||
*
|
||||
* @implements SearchSource
|
||||
*/
|
||||
export default class UsersSearchResults {
|
||||
search(query) {
|
||||
return app.store.find('users', {
|
||||
filter: {q: query},
|
||||
page: {limit: 5}
|
||||
});
|
||||
}
|
||||
|
||||
view(query) {
|
||||
const results = app.store.all('users')
|
||||
.filter(user => user.username().toLowerCase().substr(0, query.length) === query);
|
||||
|
||||
if (!results.length) return '';
|
||||
|
||||
return [
|
||||
<li className="dropdown-header">Users</li>,
|
||||
results.map(user => (
|
||||
<li className="user-search-result" data-index={'users' + user.id()}>
|
||||
<a href={app.route.user(user)} config={m.route}>
|
||||
{avatar(user)}
|
||||
{highlight(user.username(), query)}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
];
|
||||
}
|
||||
}
|
46
js/forum/src/components/WelcomeHero.js
Normal file
46
js/forum/src/components/WelcomeHero.js
Normal file
@ -0,0 +1,46 @@
|
||||
import Component from 'flarum/Component';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
/**
|
||||
* The `WelcomeHero` component displays a hero that welcomes the user to the
|
||||
* forum.
|
||||
*/
|
||||
export default class WelcomeHero extends Component {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.hidden = localStorage.getItem('welcomeHidden');
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.hidden) return <div/>;
|
||||
|
||||
const slideUp = () => {
|
||||
this.$().slideUp(this.hide.bind(this));
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="hero welcome-hero">
|
||||
<div class="container">
|
||||
<button className="close btn btn-icon btn-link" onclick={slideUp}>
|
||||
{icon('times')}
|
||||
</button>
|
||||
|
||||
<div className="container-narrow">
|
||||
<h2>{app.forum.attribute('welcomeTitle')}</h2>
|
||||
<div className="subtitle">{m.trust(app.forum.attribute('welcomeMessage'))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the welcome hero.
|
||||
*/
|
||||
hide() {
|
||||
localStorage.setItem('welcomeHidden', 'true');
|
||||
|
||||
this.hidden = true;
|
||||
}
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
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.loading = m.prop(true);
|
||||
this.moreResults = m.prop(false);
|
||||
this.activity = m.prop([]);
|
||||
this.loadLimit = 20;
|
||||
|
||||
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();
|
||||
super.setupUser(user);
|
||||
m.endComputation();
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh() {
|
||||
m.startComputation();
|
||||
this.loading(true);
|
||||
this.activity([]);
|
||||
m.endComputation();
|
||||
this.loadResults().then(this.parseResults.bind(this));
|
||||
}
|
||||
|
||||
loadResults(offset) {
|
||||
return app.store.find('activity', {
|
||||
filter: {
|
||||
user: this.user().id(),
|
||||
type: this.props.filter
|
||||
},
|
||||
page: {offset, limit: this.loadLimit}
|
||||
})
|
||||
}
|
||||
|
||||
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 >= this.loadLimit);
|
||||
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)
|
||||
})) : '')
|
||||
]);
|
||||
}
|
||||
}
|
@ -1,103 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import listItems from 'flarum/helpers/list-items';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
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 icon')
|
||||
]),
|
||||
m('ul.dropdown-menu', listItems(this.controlItems().toArray()))
|
||||
]);
|
||||
}
|
||||
|
||||
controlItems() {
|
||||
var items = new ItemList();
|
||||
|
||||
items.add('upload',
|
||||
ActionButton.component({
|
||||
icon: 'upload',
|
||||
label: 'Upload',
|
||||
onclick: this.upload.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
items.add('remove',
|
||||
ActionButton.component({
|
||||
icon: 'times',
|
||||
label: 'Remove',
|
||||
onclick: this.remove.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
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.forum.attribute('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() {
|
||||
var self = this;
|
||||
var user = this.props.user;
|
||||
self.loading(true);
|
||||
m.redraw();
|
||||
m.request({
|
||||
method: 'DELETE',
|
||||
url: app.forum.attribute('apiUrl')+'/users/'+user.id()+'/avatar',
|
||||
config: app.session.authorize.bind(app.session)
|
||||
}).then(function(data) {
|
||||
self.loading(false);
|
||||
app.store.pushPayload(data);
|
||||
delete user.avatarColor;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
import FormModal from 'flarum/components/form-modal';
|
||||
import Alert from 'flarum/components/alert';
|
||||
|
||||
export default class ChangeEmailModal extends FormModal {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.success = m.prop(false);
|
||||
this.email = m.prop(app.session.user().email());
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.success()) {
|
||||
var emailProviderName = this.email().split('@')[1];
|
||||
}
|
||||
var disabled = this.loading();
|
||||
|
||||
return super.view({
|
||||
className: 'modal-sm change-email-modal',
|
||||
title: 'Change Email',
|
||||
body: m('div.form-centered', this.success()
|
||||
? [
|
||||
m('p.help-text', 'We\'ve sent a confirmation email to ', m('strong', this.email()), '. If it doesn\'t arrive soon, check your spam folder.'),
|
||||
m('div.form-group', [
|
||||
m('a.btn.btn-primary.btn-block', {href: 'http://'+emailProviderName}, 'Go to '+emailProviderName)
|
||||
])
|
||||
]
|
||||
: [
|
||||
m('div.form-group', [
|
||||
m('input.form-control[type=email][name=email]', {
|
||||
placeholder: app.session.user().email(),
|
||||
value: this.email(),
|
||||
onchange: m.withAttr('value', this.email),
|
||||
disabled
|
||||
})
|
||||
]),
|
||||
m('div.form-group', [
|
||||
m('button.btn.btn-primary.btn-block[type=submit]', {disabled}, 'Save Changes')
|
||||
])
|
||||
])
|
||||
});
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.email() === app.session.user().email()) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading(true);
|
||||
app.session.user().save({ email: this.email() }).then(() => {
|
||||
this.loading(false);
|
||||
this.success(true);
|
||||
this.alert(null);
|
||||
m.redraw();
|
||||
}, response => {
|
||||
this.loading(false);
|
||||
this.handleErrors(response.errors);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
import FormModal from 'flarum/components/form-modal';
|
||||
|
||||
export default class ChangePasswordModal extends FormModal {
|
||||
view() {
|
||||
return super.view({
|
||||
className: 'modal-sm change-password-modal',
|
||||
title: 'Change Password',
|
||||
body: m('div.form-centered', [
|
||||
m('p.help-text', 'Click the button below and check your email for a link to change your password.'),
|
||||
m('div.form-group', [
|
||||
m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Send Password Reset Email')
|
||||
])
|
||||
])
|
||||
});
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
this.loading(true);
|
||||
|
||||
m.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl')+'/forgot',
|
||||
data: {email: app.session.user().email()},
|
||||
background: true
|
||||
}).then(response => {
|
||||
this.hide();
|
||||
});
|
||||
}
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
import Post from 'flarum/components/post';
|
||||
import classList from 'flarum/utils/class-list';
|
||||
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 EditComposer from 'flarum/components/edit-composer';
|
||||
import Composer from 'flarum/components/composer';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
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 CommentPost extends Post {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.postHeaderUser = new PostHeaderUser({post: this.props.post});
|
||||
this.subtree.check(this.postHeaderUser.showCard);
|
||||
}
|
||||
|
||||
view() {
|
||||
var post = this.props.post;
|
||||
|
||||
return super.view([
|
||||
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())))
|
||||
], {
|
||||
className: classList({
|
||||
'comment-post': true,
|
||||
'is-hidden': post.isHidden(),
|
||||
'is-edited': post.isEdited(),
|
||||
'reveal-content': this.revealContent,
|
||||
'editing': app.composer.component instanceof EditComposer &&
|
||||
app.composer.component.props.post === this.props.post &&
|
||||
app.composer.position() !== Composer.PositionEnum.MINIMIZED
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
toggleContent() {
|
||||
this.revealContent = !this.revealContent;
|
||||
}
|
||||
|
||||
headerItems() {
|
||||
var items = new ItemList();
|
||||
var post = this.props.post;
|
||||
var props = {post};
|
||||
|
||||
items.add('user', this.postHeaderUser.render(), {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;
|
||||
}
|
||||
|
||||
footerItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
|
||||
actionItems() {
|
||||
return new ItemList();
|
||||
}
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
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.ready = m.prop(false);
|
||||
this.content = m.prop(this.props.originalContent);
|
||||
this.editor = new TextEditor({
|
||||
submitLabel: this.props.submitLabel,
|
||||
placeholder: this.props.placeholder,
|
||||
disabled: this.loading(),
|
||||
onchange: this.content,
|
||||
onsubmit: this.onsubmit.bind(this),
|
||||
value: this.content()
|
||||
});
|
||||
}
|
||||
|
||||
view(className) {
|
||||
this.editor.props.disabled = this.loading() || !this.ready();
|
||||
|
||||
return m('div', {className}, [
|
||||
avatar(this.props.user, {className: 'composer-avatar'}),
|
||||
m('div.composer-body', [
|
||||
m('ul.composer-header', listItems(this.headerItems().toArray())),
|
||||
m('div.composer-editor', this.editor.render())
|
||||
]),
|
||||
LoadingIndicator.component({className: 'composer-loading'+(this.loading() ? ' active' : '')})
|
||||
]);
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.ready(true);
|
||||
m.redraw(true);
|
||||
|
||||
this.$(':input:enabled:visible:first').focus();
|
||||
}
|
||||
|
||||
preventExit() {
|
||||
return this.content() && this.content() != this.props.originalContent && this.props.confirmExit;
|
||||
}
|
||||
|
||||
onsubmit(value) {
|
||||
//
|
||||
}
|
||||
}
|
@ -1,292 +1,451 @@
|
||||
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 Component from 'flarum/Component';
|
||||
import ItemList from 'flarum/utils/ItemList';
|
||||
import ComposerButton from 'flarum/components/ComposerButton';
|
||||
import listItems from 'flarum/helpers/listItems';
|
||||
import classList from 'flarum/utils/classList';
|
||||
import computed from 'flarum/utils/computed';
|
||||
|
||||
/**
|
||||
* The `Composer` component displays the composer. It can be loaded with a
|
||||
* content component with `load` and then its position/state can be altered with
|
||||
* `show`, `hide`, `close`, `minimize`, `fullScreen`, and `exitFullScreen`.
|
||||
*/
|
||||
class Composer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.position = m.prop(Composer.PositionEnum.HIDDEN);
|
||||
this.height = m.prop();
|
||||
/**
|
||||
* The composer's current position.
|
||||
*
|
||||
* @type {Composer.PositionEnum}
|
||||
*/
|
||||
this.position = Composer.PositionEnum.HIDDEN;
|
||||
|
||||
// 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) {
|
||||
/**
|
||||
* The composer's previous position.
|
||||
*
|
||||
* @type {Composer.PositionEnum}
|
||||
*/
|
||||
this.oldPosition = null;
|
||||
|
||||
/**
|
||||
* The composer's intended height, which can be modified by the user
|
||||
* (by dragging the composer handle).
|
||||
*
|
||||
* @type {Integer}
|
||||
*/
|
||||
this.height = null;
|
||||
|
||||
/**
|
||||
* Computed the composer's current height, based on the intended height, and
|
||||
* the composer's current state. This will be applied to the composer's
|
||||
* content's DOM element.
|
||||
*
|
||||
* @return {Integer}
|
||||
*/
|
||||
this.computedHeight = computed('height', 'position', (height, position) => {
|
||||
// If the composer is minimized, then we don't want to set a height; we'll
|
||||
// let the CSS decide how high it is. If it's fullscreen, then we need to
|
||||
// make it as high as the window.
|
||||
if (position === Composer.PositionEnum.MINIMIZED) {
|
||||
return '';
|
||||
} else if (position === Composer.PositionEnum.FULLSCREEN) {
|
||||
return $(window).height();
|
||||
} else {
|
||||
return Math.max(200, Math.min(height, $(window).height() - $('#header').outerHeight()));
|
||||
}
|
||||
|
||||
// Otherwise, if it's normal or hidden, then we use the intended height.
|
||||
// We don't let the composer get too small or too big, though.
|
||||
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
|
||||
const 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;
|
||||
classes.visible = this.position === Composer.PositionEnum.NORMAL || classes.minimized || classes.fullScreen;
|
||||
|
||||
// If the composer is minimized, tell the composer's content component that
|
||||
// it shouldn't let the user interact with it. Set up a handler so that if
|
||||
// the content IS clicked, the composer will be shown.
|
||||
if (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: () => {
|
||||
if (this.position() === Composer.PositionEnum.MINIMIZED) this.show();
|
||||
}}, this.component ? this.component.render() : '')
|
||||
]);
|
||||
const showIfMinimized = () => {
|
||||
if (this.position === Composer.PositionEnum.MINIMIZED) this.show();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'composer ' + classList(classes)}>
|
||||
<div className="composer-handle"/>
|
||||
<ul className="composer-controls">{listItems(this.controlItems().toArray())}</ul>
|
||||
<div className="composer-content" onclick={showIfMinimized}>
|
||||
{this.component ? this.component.render() : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onload(element, isInitialized, context) {
|
||||
this.element(element);
|
||||
config(isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
if (isInitialized) { return; }
|
||||
// Since this component is a part of the global UI that persists between
|
||||
// routes, we will flag the DOM to be retained across route changes.
|
||||
context.retain = true;
|
||||
|
||||
// Hide the composer to begin with.
|
||||
this.height(localStorage.getItem('composerHeight') || this.$().height());
|
||||
// Initialize the composer's intended height based on what the user has set
|
||||
// it at previously, or otherwise the composer's default height. After that,
|
||||
// we'll hide the composer.
|
||||
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'));
|
||||
// Whenever any of the inputs inside the composer are have focus, we want to
|
||||
// add a class to the composer to draw attention to it.
|
||||
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 = {};
|
||||
// Don't let the user leave the page without first giving the composer's
|
||||
// component a chance to scream at the user to make sure they don't
|
||||
// unintentionally lose any contnet.
|
||||
window.onbeforeunload = () => {
|
||||
return (this.component && this.component.preventExit()) || null;
|
||||
};
|
||||
|
||||
$(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
|
||||
// Add the necessary event handlers to the composer's handle so that it can
|
||||
// be used to resize the composer.
|
||||
const composer = this;
|
||||
const handlers = {};
|
||||
|
||||
this.$('.composer-handle').css('cursor', 'row-resize')
|
||||
.bind('dragstart mousedown', e => e.preventDefault())
|
||||
.mousedown(function(e) {
|
||||
composer.mouseStart = e.clientY;
|
||||
composer.heightStart = composer.$().height();
|
||||
composer.handle = $(this);
|
||||
$('body').css('cursor', 'row-resize');
|
||||
});
|
||||
|
||||
$(window).on('resize', handlers.onresize = this.updateHeight.bind(this)).resize();
|
||||
|
||||
$(document)
|
||||
.on('mousemove', this.handlers.onmousemove = this.onmousemove.bind(this))
|
||||
.on('mouseup', this.handlers.onmouseup = this.onmouseup.bind(this));
|
||||
.on('mousemove', handlers.onmousemove = this.onmousemove.bind(this))
|
||||
.on('mouseup', handlers.onmouseup = this.onmouseup.bind(this));
|
||||
|
||||
window.onbeforeunload = e => {
|
||||
return (this.component && this.component.preventExit()) || null;
|
||||
context.onunload = () => {
|
||||
$(window).off('resize', handlers.onresize);
|
||||
|
||||
$(document)
|
||||
.off('mousemove', handlers.onmousemove)
|
||||
.off('mouseup', handlers.onmouseup);
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the composer according to mouse movement.
|
||||
*
|
||||
* @param {Event} e
|
||||
*/
|
||||
onmousemove(e) {
|
||||
if (!this.handle) { return; }
|
||||
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);
|
||||
const deltaPixels = this.mouseStart - e.clientY;
|
||||
this.height = this.heightStart + deltaPixels;
|
||||
this.updateHeight();
|
||||
|
||||
var scrollTop = $(window).scrollTop();
|
||||
this.updateBodyPadding(scrollTop > 0 && scrollTop + $(window).height() >= $(document).height());
|
||||
// Update the body's padding-bottom so that no content on the page will ever
|
||||
// get permanently hidden behind the composer. If the user is already
|
||||
// scrolled to the bottom of the page, then we will keep them scrolled to
|
||||
// the bottom after the padding has been updated.
|
||||
const scrollTop = $(window).scrollTop();
|
||||
const anchorToBottom = scrollTop > 0 && scrollTop + $(window).height() >= $(document).height();
|
||||
this.updateBodyPadding(anchorToBottom);
|
||||
|
||||
localStorage.setItem('composerHeight', height);
|
||||
localStorage.setItem('composerHeight', this.height);
|
||||
}
|
||||
|
||||
onmouseup(e) {
|
||||
if (!this.handle) { return; }
|
||||
/**
|
||||
* Finish resizing the composer when the mouse is released.
|
||||
*/
|
||||
onmouseup() {
|
||||
if (!this.handle) return;
|
||||
|
||||
this.handle = null;
|
||||
$('body').css('cursor', '');
|
||||
}
|
||||
|
||||
preventExit() {
|
||||
if (this.component) {
|
||||
var preventExit = this.component.preventExit();
|
||||
if (preventExit) {
|
||||
return !confirm(preventExit);
|
||||
}
|
||||
/**
|
||||
* Update the DOM to reflect the composer's current height. This involves
|
||||
* setting the height of the composer's root element, and adjusting the height
|
||||
* of any flexible elements inside the composer's body.
|
||||
*/
|
||||
updateHeight() {
|
||||
const height = this.computedHeight();
|
||||
const $flexible = this.$('.flexible-height');
|
||||
|
||||
this.$().height(height);
|
||||
|
||||
if ($flexible.length) {
|
||||
const headerHeight = $flexible.offset().top - this.$().offset().top;
|
||||
const paddingBottom = parseInt($flexible.css('padding-bottom'), 10);
|
||||
const footerHeight = this.$('.text-editor-controls').outerHeight(true);
|
||||
|
||||
$flexible.height(height - headerHeight - paddingBottom - footerHeight);
|
||||
}
|
||||
}
|
||||
|
||||
update(anchorToBottom) {
|
||||
var $composer = this.$().stop(true);
|
||||
var oldHeight = $composer.is(':visible') ? $composer.outerHeight() : 0;
|
||||
/**
|
||||
* 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() {
|
||||
const visible = this.position !== Composer.PositionEnum.HIDDEN &&
|
||||
this.position !== Composer.PositionEnum.MINIMIZED;
|
||||
|
||||
var scrollTop = $(window).scrollTop();
|
||||
const paddingBottom = visible
|
||||
? this.computedHeight() - parseInt($('#page').css('padding-bottom'), 10)
|
||||
: 0;
|
||||
$('#content').css({paddingBottom});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update (and animate) the DOM to reflect the composer's current state.
|
||||
*/
|
||||
update() {
|
||||
// Before we redraw the composer to its new state, we need to save the
|
||||
// current height of the composer, as well as the page's scroll position, so
|
||||
// that we can smoothly transition from the old to the new state.
|
||||
const $composer = this.$().stop(true);
|
||||
const oldHeight = $composer.is(':visible') ? $composer.outerHeight() : 0;
|
||||
const scrollTop = $(window).scrollTop();
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
this.$().height(this.computedHeight());
|
||||
var newHeight = $composer.outerHeight();
|
||||
// Now that we've redrawn and the composer's DOM has been updated, we want
|
||||
// to update the composer's height. Once we've done that, we'll capture the
|
||||
// real value to use as the end point for our animation later on.
|
||||
$composer.show();
|
||||
this.updateHeight();
|
||||
|
||||
switch (this.position()) {
|
||||
case Composer.PositionEnum.HIDDEN:
|
||||
$composer.css({height: oldHeight}).animate({bottom: -newHeight}, 'fast', () => {
|
||||
$composer.hide();
|
||||
this.clear();
|
||||
m.redraw();
|
||||
});
|
||||
break;
|
||||
const newHeight = $composer.outerHeight();
|
||||
|
||||
switch (this.position) {
|
||||
case Composer.PositionEnum.NORMAL:
|
||||
// If the composer is being opened, we will make it visible and animate
|
||||
// it growing/sliding up from the bottom of the viewport. Or if the user
|
||||
// has just exited fullscreen mode, we will simply tell the content to
|
||||
// take focus.
|
||||
if (this.oldPosition !== Composer.PositionEnum.FULLSCREEN) {
|
||||
$composer.show();
|
||||
$composer.css({height: oldHeight}).animate({bottom: 0, height: newHeight}, 'fast', this.component.focus.bind(this.component));
|
||||
$composer.show()
|
||||
.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));
|
||||
// If the composer has been minimized, we will animate it shrinking down
|
||||
// to its new smaller size.
|
||||
$composer.css({height: oldHeight})
|
||||
.animate({height: newHeight}, 'fast');
|
||||
break;
|
||||
|
||||
case Composer.PositionEnum.HIDDEN:
|
||||
// If the composer has been hidden, then we will animate it sliding down
|
||||
// beyond the edge of the viewport. Once the animation is complete, we
|
||||
// un-draw the composer's component.
|
||||
$composer.css({height: oldHeight})
|
||||
.animate({bottom: -newHeight}, 'fast', () => {
|
||||
$composer.hide();
|
||||
this.clear();
|
||||
m.redraw();
|
||||
});
|
||||
break;
|
||||
|
||||
case Composer.PositionEnum.FULLSCREEN:
|
||||
this.component.focus();
|
||||
break;
|
||||
|
||||
default:
|
||||
// no default
|
||||
}
|
||||
|
||||
if (this.position() !== Composer.PositionEnum.FULLSCREEN) {
|
||||
// Provided the composer isn't in fullscreen mode, we'll want to update the
|
||||
// body's padding to make sure all of the page's content can still be seen.
|
||||
// Plus, we'll scroll back to where we were before the composer was opened,
|
||||
// as its opening may have changed the content of the page.
|
||||
if (this.position !== Composer.PositionEnum.FULLSCREEN) {
|
||||
this.updateBodyPadding();
|
||||
$('html, body').scrollTop(scrollTop);
|
||||
} else {
|
||||
this.component.focus();
|
||||
}
|
||||
$('body').toggleClass('composer-open', this.position() !== Composer.PositionEnum.HIDDEN);
|
||||
this.oldPosition = this.position();
|
||||
|
||||
if (this.position() !== Composer.PositionEnum.HIDDEN) {
|
||||
this.setContentHeight(this.computedHeight());
|
||||
}
|
||||
this.oldPosition = this.position;
|
||||
}
|
||||
|
||||
// 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() {
|
||||
var paddingBottom = this.position() !== Composer.PositionEnum.HIDDEN && this.position() !== Composer.PositionEnum.MINIMIZED
|
||||
? this.computedHeight() - parseInt($('#page').css('padding-bottom'))
|
||||
: 0;
|
||||
$('#content').css({paddingBottom});
|
||||
}
|
||||
/**
|
||||
* Confirm with the user that they want to close the composer and lose their
|
||||
* content.
|
||||
*
|
||||
* @return {Boolean} Whether or not the exit was cancelled.
|
||||
*/
|
||||
preventExit() {
|
||||
if (this.component) {
|
||||
const preventExit = this.component.preventExit();
|
||||
|
||||
// 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 flexible = this.$('.flexible-height');
|
||||
if (flexible.length) {
|
||||
flexible.height(height -
|
||||
(flexible.offset().top - this.$().offset().top) -
|
||||
parseInt(flexible.css('padding-bottom')) -
|
||||
this.$('.text-editor-controls').outerHeight(true));
|
||||
}
|
||||
}
|
||||
|
||||
load(component) {
|
||||
if (!this.preventExit()) {
|
||||
// If we load a similar component into the composer, then Mithril will be
|
||||
// able to diff the old/new contents and some DOM-related state from the
|
||||
// old composer will remain. To prevent this from happening, we clear the
|
||||
// component and force a redraw, so that the new component will be working
|
||||
// on a blank slate.
|
||||
if (this.component) {
|
||||
this.clear();
|
||||
m.redraw(true);
|
||||
if (preventExit) {
|
||||
return !confirm(preventExit);
|
||||
}
|
||||
this.component = component;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a content component into the composer.
|
||||
*
|
||||
* @param {Component} component
|
||||
* @public
|
||||
*/
|
||||
load(component) {
|
||||
if (this.preventExit()) return;
|
||||
|
||||
// If we load a similar component into the composer, then Mithril will be
|
||||
// able to diff the old/new contents and some DOM-related state from the
|
||||
// old composer will remain. To prevent this from happening, we clear the
|
||||
// component and force a redraw, so that the new component will be working
|
||||
// on a blank slate.
|
||||
if (this.component) {
|
||||
this.clear();
|
||||
m.redraw(true);
|
||||
}
|
||||
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the composer's content component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
clear() {
|
||||
this.component = null;
|
||||
}
|
||||
|
||||
show(anchorToBottom) {
|
||||
if ([Composer.PositionEnum.MINIMIZED, Composer.PositionEnum.HIDDEN].indexOf(this.position()) !== -1) {
|
||||
this.position(Composer.PositionEnum.NORMAL);
|
||||
/**
|
||||
* Show the composer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
show() {
|
||||
// If the composer is hidden or minimized, we'll need to update its
|
||||
// position. Otherwise, if the composer is already showing (whether it's
|
||||
// fullscreen or not), we can leave it as is.
|
||||
if ([Composer.PositionEnum.MINIMIZED, Composer.PositionEnum.HIDDEN].indexOf(this.position) !== -1) {
|
||||
this.position = Composer.PositionEnum.NORMAL;
|
||||
}
|
||||
this.update(anchorToBottom);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.position(Composer.PositionEnum.HIDDEN);
|
||||
this.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the composer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
hide() {
|
||||
this.position = Composer.PositionEnum.HIDDEN;
|
||||
this.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm with the user so they don't lose their content, then close the
|
||||
* composer.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
close() {
|
||||
if (!this.preventExit()) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimize the composer. Has no effect if the composer is hidden.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
minimize() {
|
||||
if (this.position() !== Composer.PositionEnum.HIDDEN) {
|
||||
this.position(Composer.PositionEnum.MINIMIZED);
|
||||
if (this.position !== Composer.PositionEnum.HIDDEN) {
|
||||
this.position = Composer.PositionEnum.MINIMIZED;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the composer into fullscreen mode. Has no effect if the composer is
|
||||
* hidden.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
fullScreen() {
|
||||
if (this.position() !== Composer.PositionEnum.HIDDEN) {
|
||||
this.position(Composer.PositionEnum.FULLSCREEN);
|
||||
if (this.position !== Composer.PositionEnum.HIDDEN) {
|
||||
this.position = Composer.PositionEnum.FULLSCREEN;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit fullscreen mode.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
exitFullScreen() {
|
||||
if (this.position() === Composer.PositionEnum.FULLSCREEN) {
|
||||
this.position(Composer.PositionEnum.NORMAL);
|
||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
this.position = Composer.PositionEnum.NORMAL;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
control(props) {
|
||||
props.className = 'btn btn-icon btn-link';
|
||||
return ActionButton.component(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the composer's controls.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
controlItems() {
|
||||
var items = new ItemList();
|
||||
const 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) }));
|
||||
if (this.position === Composer.PositionEnum.FULLSCREEN) {
|
||||
items.add('exitFullScreen', ComposerButton.component({
|
||||
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), wrapperClass: 'back-control' }));
|
||||
items.add('fullScreen', this.control({ icon: 'expand', title: 'Full Screen', onclick: this.fullScreen.bind(this) }));
|
||||
if (this.position !== Composer.PositionEnum.MINIMIZED) {
|
||||
items.add('minimize', ComposerButton.component({
|
||||
icon: 'minus minimize',
|
||||
title: 'Minimize',
|
||||
onclick: this.minimize.bind(this),
|
||||
wrapperClass: 'back-control'
|
||||
}));
|
||||
|
||||
items.add('fullScreen', ComposerButton.component({
|
||||
icon: 'expand',
|
||||
title: 'Full Screen',
|
||||
onclick: this.fullScreen.bind(this)
|
||||
}));
|
||||
}
|
||||
items.add('close', this.control({ icon: 'times', title: 'Close', onclick: this.close.bind(this) }));
|
||||
|
||||
items.add('close', ComposerButton.component({
|
||||
icon: 'times',
|
||||
title: 'Close',
|
||||
onclick: this.close.bind(this)
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
|
@ -1,34 +0,0 @@
|
||||
import FormModal from 'flarum/components/form-modal';
|
||||
|
||||
export default class DeleteAccountModal extends FormModal {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.confirmation = m.prop();
|
||||
}
|
||||
|
||||
view() {
|
||||
return super.view({
|
||||
className: 'modal-sm change-password-modal',
|
||||
title: 'Delete Account',
|
||||
body: m('div.form-centered', [
|
||||
m('p.help-text', 'Hold up there skippy! If you delete your account, there\'s no going back. All of your posts will be kept, but no longer associated with your account.'),
|
||||
m('div.form-group', [
|
||||
m('input.form-control[name=confirm][placeholder=Type "DELETE" to proceed]', {oninput: m.withAttr('value', this.confirmation)})
|
||||
]),
|
||||
m('div.form-group', [
|
||||
m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading() || this.confirmation() != 'DELETE'}, 'Delete Account')
|
||||
])
|
||||
])
|
||||
});
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.confirmation() !== 'DELETE') return;
|
||||
|
||||
this.loading(true);
|
||||
app.session.user().delete().then(() => app.session.logout());
|
||||
}
|
||||
}
|
@ -1,96 +0,0 @@
|
||||
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 DiscussionComposer extends ComposerBody {
|
||||
constructor(props) {
|
||||
props.placeholder = props.placeholder || 'Write a Post...';
|
||||
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(),
|
||||
oninput: 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'));
|
||||
},
|
||||
onkeydown: (e) => {
|
||||
if (e.which === 13) { // return
|
||||
e.preventDefault();
|
||||
this.editor.setSelectionRange(0, 0);
|
||||
}
|
||||
m.redraw.strategy('none');
|
||||
}
|
||||
})));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
onload(element, isInitialized, context) {
|
||||
super.onload(element, isInitialized, context);
|
||||
|
||||
this.editor.$('textarea').keydown((e) => {
|
||||
if (e.which === 8 && e.target.selectionStart == 0 && e.target.selectionEnd == 0) { // Backspace
|
||||
e.preventDefault();
|
||||
var title = this.$(':input:enabled:visible:first')[0];
|
||||
title.focus();
|
||||
title.selectionStart = title.selectionEnd = title.value.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
preventExit() {
|
||||
return (this.title() || this.content()) && this.props.confirmExit;
|
||||
}
|
||||
|
||||
data() {
|
||||
return {
|
||||
title: this.title(),
|
||||
content: this.content()
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit() {
|
||||
this.loading(true);
|
||||
m.redraw();
|
||||
|
||||
var data = this.data();
|
||||
|
||||
app.store.createRecord('discussions').save(data).then(discussion => {
|
||||
app.composer.hide();
|
||||
app.cache.discussionList.addDiscussion(discussion);
|
||||
m.route(app.route('discussion', { id: discussion.id(), slug: discussion.slug() }));
|
||||
}, response => {
|
||||
this.loading(false);
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import listItems from 'flarum/helpers/list-items';
|
||||
|
||||
export default class DiscussionHero extends Component {
|
||||
view() {
|
||||
var discussion = this.props.discussion;
|
||||
|
||||
return m('header.hero.discussion-hero', [
|
||||
m('div.container', m('ul.discussion-hero-items', listItems(this.items().toArray())))
|
||||
]);
|
||||
}
|
||||
|
||||
items() {
|
||||
var items = new ItemList();
|
||||
var discussion = this.props.discussion;
|
||||
|
||||
var badges = discussion.badges().toArray();
|
||||
if (badges.length) {
|
||||
items.add('badges', m('ul.badges', listItems(badges)));
|
||||
}
|
||||
|
||||
items.add('title', m('h2.discussion-title', discussion.title()));
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
@ -1,126 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import listItems from 'flarum/helpers/list-items';
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import humanTime from 'flarum/utils/human-time';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import abbreviateNumber from 'flarum/utils/abbreviate-number';
|
||||
import DropdownButton from 'flarum/components/dropdown-button';
|
||||
import TerminalPost from 'flarum/components/terminal-post';
|
||||
import PostPreview from 'flarum/components/post-preview';
|
||||
import SubtreeRetainer from 'flarum/utils/subtree-retainer';
|
||||
import slidable from 'flarum/utils/slidable';
|
||||
|
||||
export default class DiscussionListItem extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.subtree = new SubtreeRetainer(
|
||||
() => this.props.discussion.freshness,
|
||||
() => app.session.user() && app.session.user().readTime(),
|
||||
() => this.active()
|
||||
);
|
||||
}
|
||||
|
||||
active() {
|
||||
return m.route.param('id') === this.props.discussion.id();
|
||||
}
|
||||
|
||||
view() {
|
||||
var discussion = this.props.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 relevantPosts = this.props.q ? discussion.relevantPosts() : '';
|
||||
var controls = discussion.controls(this).toArray();
|
||||
|
||||
return this.subtree.retain() || m('div.discussion-list-item', {className: this.active() ? 'active' : ''}, [
|
||||
controls.length ? DropdownButton.component({
|
||||
icon: 'ellipsis-v',
|
||||
items: controls,
|
||||
className: 'contextual-controls',
|
||||
buttonClass: 'btn btn-default btn-naked btn-icon btn-sm slidable-underneath slidable-underneath-right',
|
||||
menuClass: 'pull-right'
|
||||
}) : '',
|
||||
|
||||
m('a.slidable-underneath.slidable-underneath-left.elastic', {
|
||||
className: discussion.isUnread() ? '' : 'disabled',
|
||||
onclick: this.markAsRead.bind(this)
|
||||
}, icon('check icon')),
|
||||
|
||||
m('div.slidable-slider.discussion-summary', {className: isUnread ? 'unread' : ''}, [
|
||||
|
||||
m((startUser ? 'a' : 'span')+'.author', {
|
||||
href: startUser ? app.route.user(startUser) : undefined,
|
||||
config: function(element, isInitialized, context) {
|
||||
$(element).tooltip({ placement: 'right' });
|
||||
m.route.apply(this, arguments);
|
||||
},
|
||||
title: 'Started by '+(startUser ? startUser.username() : '[deleted]')+' '+humanTime(discussion.startTime())
|
||||
}, [
|
||||
avatar(startUser, {title: ''})
|
||||
]),
|
||||
|
||||
m('ul.badges', listItems(discussion.badges().toArray())),
|
||||
|
||||
m('a.main', {href: app.route.discussion(discussion, jumpTo), config: m.route}, [
|
||||
m('h3.title', highlight(discussion.title(), this.props.q)),
|
||||
m('ul.info', listItems(this.infoItems().toArray()))
|
||||
]),
|
||||
|
||||
m('span.count', {onclick: this.markAsRead.bind(this), title: displayUnread ? 'Mark as Read' : ''}, [
|
||||
abbreviateNumber(discussion[displayUnread ? 'unreadCount' : 'repliesCount']())
|
||||
]),
|
||||
|
||||
(relevantPosts && relevantPosts.length)
|
||||
? m('div.relevant-posts', relevantPosts.map(post => PostPreview.component({post, highlight: this.props.q})))
|
||||
: ''
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
markAsRead() {
|
||||
var discussion = this.props.discussion;
|
||||
|
||||
if (discussion.isUnread()) {
|
||||
discussion.save({ readNumber: discussion.lastPostNumber() });
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Build an item list of info for a discussion listing. By default this is
|
||||
just the first/last post indicator.
|
||||
|
||||
@return {ItemList}
|
||||
*/
|
||||
infoItems() {
|
||||
var items = new ItemList();
|
||||
|
||||
items.add('terminalPost',
|
||||
TerminalPost.component({
|
||||
discussion: this.props.discussion,
|
||||
lastPost: this.props.terminalPostType !== 'start'
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
config(element, isInitialized, context) {
|
||||
if (isInitialized) return;
|
||||
|
||||
if ('ontouchstart' in window) {
|
||||
this.$().addClass('slidable');
|
||||
|
||||
var slidableInstance = slidable(element);
|
||||
|
||||
this.$('.contextual-controls').on('hidden.bs.dropdown', function() {
|
||||
slidableInstance.reset();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
@ -1,133 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import DiscussionListItem from 'flarum/components/discussion-list-item';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
|
||||
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.refresh();
|
||||
|
||||
app.session.on('loggedIn', this.loggedInHandler = this.refresh.bind(this));
|
||||
}
|
||||
|
||||
params() {
|
||||
var params = {include: ['startUser', 'lastUser']};
|
||||
for (var i in this.props.params) {
|
||||
params[i] = this.props.params[i];
|
||||
}
|
||||
params.sort = this.sortMap()[params.sort];
|
||||
if (params.q) {
|
||||
params.filter = params.filter || {};
|
||||
params.filter.q = params.q;
|
||||
params.include.push('relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user');
|
||||
delete params.q;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
sortMap() {
|
||||
var map = {};
|
||||
if (this.props.params.q) {
|
||||
map.relevance = '';
|
||||
}
|
||||
map.recent = '-lastTime';
|
||||
map.replies = '-commentsCount';
|
||||
map.newest = '-startTime';
|
||||
map.oldest = '+startTime';
|
||||
return map;
|
||||
}
|
||||
|
||||
refresh() {
|
||||
m.startComputation();
|
||||
this.loading(true);
|
||||
this.discussions([]);
|
||||
m.endComputation();
|
||||
this.loadResults().then(this.parseResults.bind(this), response => {
|
||||
this.loading(false);
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
onunload() {
|
||||
app.session.off('loggedIn', this.loggedInHandler);
|
||||
}
|
||||
|
||||
terminalPostType() {
|
||||
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1 ? 'start' : 'last'
|
||||
}
|
||||
|
||||
countType() {
|
||||
return this.props.params.sort === 'replies' ? 'replies' : 'unread';
|
||||
}
|
||||
|
||||
loadResults(offset) {
|
||||
const discussions = app.preloadedDocument();
|
||||
|
||||
if (discussions) {
|
||||
return m.deferred().resolve(discussions).promise;
|
||||
} else {
|
||||
var params = this.params();
|
||||
params.page = {offset};
|
||||
params.include = params.include.join(',');
|
||||
return app.store.find('discussions', params);
|
||||
}
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
var self = this;
|
||||
this.loading(true);
|
||||
this.loadResults(this.discussions().length).then((results) => this.parseResults(results));
|
||||
}
|
||||
|
||||
parseResults(results) {
|
||||
m.startComputation();
|
||||
this.loading(false);
|
||||
|
||||
[].push.apply(this.discussions(), results);
|
||||
this.moreResults(!!results.payload.links.next);
|
||||
m.endComputation();
|
||||
return results;
|
||||
}
|
||||
|
||||
removeDiscussion(discussion) {
|
||||
var index = this.discussions().indexOf(discussion);
|
||||
if (index !== -1) {
|
||||
this.discussions().splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
addDiscussion(discussion) {
|
||||
this.discussions().unshift(discussion);
|
||||
}
|
||||
|
||||
view() {
|
||||
return m('div.discussion-list', [
|
||||
m('ul', [
|
||||
this.discussions().map(discussion => {
|
||||
return m('li', {
|
||||
key: discussion.id(),
|
||||
'data-id': discussion.id()
|
||||
}, DiscussionListItem.component({
|
||||
discussion,
|
||||
q: this.props.params.q,
|
||||
countType: this.countType(),
|
||||
terminalPostType: this.terminalPostType()
|
||||
}));
|
||||
})
|
||||
]),
|
||||
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)
|
||||
})) : '')
|
||||
]);
|
||||
}
|
||||
}
|
@ -1,249 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import DiscussionList from 'flarum/components/discussion-list';
|
||||
import DiscussionHero from 'flarum/components/discussion-hero';
|
||||
import PostStream from 'flarum/components/post-stream';
|
||||
import PostScrubber from 'flarum/components/post-scrubber';
|
||||
import ReplyComposer from 'flarum/components/reply-composer';
|
||||
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';
|
||||
import mixin from 'flarum/utils/mixin';
|
||||
import evented from 'flarum/utils/evented';
|
||||
|
||||
export default class DiscussionPage extends mixin(Component, evented) {
|
||||
/**
|
||||
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.discussion = m.prop();
|
||||
this.refresh();
|
||||
|
||||
if (app.cache.discussionList) {
|
||||
if (app.current instanceof DiscussionPage) {
|
||||
m.redraw.strategy('diff'); // otherwise pane redraws (killing retained subtrees) and mouseenter event is triggered so it doesn't hide
|
||||
}
|
||||
app.pane.enable();
|
||||
app.pane.hide();
|
||||
}
|
||||
|
||||
app.history.push('discussion');
|
||||
app.current = this;
|
||||
app.session.on('loggedIn', this.loggedInHandler = this.refresh.bind(this));
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.currentNear = m.route.param('near') || 0;
|
||||
this.discussion(null);
|
||||
|
||||
var params = this.params();
|
||||
params.include = params.include.join(',');
|
||||
|
||||
const discussion = app.preloadedDocument();
|
||||
if (discussion) {
|
||||
// We must wrap this in a setTimeout because if we are mounting this
|
||||
// component for the first time on page load, then any calls to m.redraw
|
||||
// will be ineffective and thus any configs (scroll code) will be run
|
||||
// before stuff is drawn to the page.
|
||||
setTimeout(this.setupDiscussion.bind(this, discussion));
|
||||
} else {
|
||||
app.store.find('discussions', m.route.param('id'), params).then(this.setupDiscussion.bind(this));
|
||||
}
|
||||
|
||||
// Trigger a redraw only if we're not already in a computation (e.g. route change)
|
||||
m.startComputation();
|
||||
m.endComputation();
|
||||
}
|
||||
|
||||
params() {
|
||||
return {
|
||||
page: {near: this.currentNear},
|
||||
include: ['posts', 'posts.user', 'posts.user.groups']
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
*/
|
||||
setupDiscussion(discussion) {
|
||||
// Hold up there skippy! If the slug in the URL doesn't match up, we'll
|
||||
// redirect so we have the correct one.
|
||||
// Waiting on https://github.com/lhorie/mithril.js/issues/539
|
||||
// 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.discussion(discussion);
|
||||
app.setTitle(discussion.title());
|
||||
|
||||
var includedPosts = [];
|
||||
if (discussion.payload && discussion.payload.included) {
|
||||
discussion.payload.included.forEach(record => {
|
||||
if (record.type === 'posts' && record.relationships && record.relationships.discussion) {
|
||||
includedPosts.push(app.store.getById('posts', record.id));
|
||||
}
|
||||
});
|
||||
includedPosts.sort((a, b) => a.id() - b.id()).splice(20);
|
||||
}
|
||||
|
||||
this.stream = new PostStream({ discussion, includedPosts });
|
||||
this.stream.on('positionChanged', this.positionChanged.bind(this));
|
||||
this.stream.goToNumber(m.route.param('near') || 1, true);
|
||||
|
||||
this.trigger('loaded', discussion);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (m.route.param('id') == discussion.id()) {
|
||||
e.preventDefault();
|
||||
if (m.route.param('near') != this.currentNear) {
|
||||
this.stream.goToNumber(m.route.param('near') || 1);
|
||||
}
|
||||
this.currentNear = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
app.pane.disable();
|
||||
app.session.off('loggedIn', this.loggedInHandler);
|
||||
|
||||
if (app.composingReplyTo(discussion) && !app.composer.component.content()) {
|
||||
app.composer.hide();
|
||||
} else {
|
||||
app.composer.minimize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
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.render()) : '',
|
||||
m('div.discussion-area', discussion ? [
|
||||
DiscussionHero.component({discussion}),
|
||||
m('div.container', [
|
||||
m('nav.discussion-nav', [
|
||||
m('ul', listItems(this.sidebarItems().toArray()))
|
||||
]),
|
||||
this.stream.render()
|
||||
])
|
||||
] : LoadingIndicator.component({className: 'loading-indicator-block'}))
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
configIndex(element, isInitialized, context) {
|
||||
if (isInitialized) { return; }
|
||||
|
||||
context.retain = true;
|
||||
|
||||
var $index = $(element);
|
||||
|
||||
// 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;
|
||||
$index.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);
|
||||
};
|
||||
|
||||
var $discussion = $index.find('.discussion-list-item.active');
|
||||
if ($discussion.length) {
|
||||
var indexTop = $index.offset().top;
|
||||
var discussionTop = $discussion.offset().top;
|
||||
if (discussionTop < indexTop || discussionTop + $discussion.outerHeight() > indexTop + $index.outerHeight()) {
|
||||
$index.scrollTop($index.scrollTop() - indexTop + discussionTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
sidebarItems() {
|
||||
var items = new ItemList();
|
||||
|
||||
items.add('controls',
|
||||
DropdownSplit.component({
|
||||
items: this.discussion().controls(this).toArray(),
|
||||
icon: 'ellipsis-v',
|
||||
className: 'primary-control',
|
||||
buttonClass: 'btn btn-primary'
|
||||
})
|
||||
);
|
||||
|
||||
items.add('scrubber',
|
||||
PostScrubber.component({
|
||||
stream: this.stream,
|
||||
className: 'title-control'
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
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() || 0)) {
|
||||
discussion.save({readNumber: endNumber});
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
import Notification from 'flarum/components/notification';
|
||||
import username from 'flarum/helpers/username';
|
||||
|
||||
export default class DiscussionRenamedNotification extends Notification {
|
||||
view() {
|
||||
var notification = this.props.notification;
|
||||
|
||||
return super.view({
|
||||
href: app.route.discussion(notification.subject(), notification.content().postNumber),
|
||||
icon: 'pencil',
|
||||
content: [username(notification.sender()), ' changed the title']
|
||||
});
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import EventPost from 'flarum/components/event-post';
|
||||
|
||||
export default class DiscussionRenamedPost extends EventPost {
|
||||
view() {
|
||||
var post = this.props.post;
|
||||
var oldTitle = post.content()[0];
|
||||
var newTitle = post.content()[1];
|
||||
|
||||
return super.view('pencil', ['changed the title from ', m('strong.old-title', oldTitle), ' to ', m('strong.new-title', newTitle), '.']);
|
||||
}
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import truncate from 'flarum/utils/truncate';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
|
||||
export default class DiscussionsSearchResults {
|
||||
constructor() {
|
||||
this.results = {};
|
||||
}
|
||||
|
||||
search(string) {
|
||||
this.results[string] = [];
|
||||
return app.store.find('discussions', {filter: {q: string}, page: {limit: 3}, include: 'relevantPosts,relevantPosts.discussion,relevantPosts.user'}).then(results => {
|
||||
this.results[string] = results;
|
||||
});
|
||||
}
|
||||
|
||||
view(string) {
|
||||
return [
|
||||
m('li.dropdown-header', 'Discussions'),
|
||||
m('li', ActionButton.component({
|
||||
icon: 'search',
|
||||
label: 'Search all discussions for "'+string+'"',
|
||||
href: app.route('index', {q: string}),
|
||||
config: m.route
|
||||
})),
|
||||
(this.results[string] && this.results[string].length) ? this.results[string].map(discussion => {
|
||||
var relevantPosts = discussion.relevantPosts();
|
||||
var post = relevantPosts && relevantPosts[0];
|
||||
return m('li.discussion-search-result', {'data-index': 'discussions'+discussion.id()},
|
||||
m('a', { href: app.route.discussion(discussion, post && post.number()), config: m.route },
|
||||
m('div.title', highlight(discussion.title(), string)),
|
||||
post ? m('div.excerpt', highlight(post.contentPlain(), string, 100)) : ''
|
||||
)
|
||||
);
|
||||
}) : ''
|
||||
];
|
||||
}
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
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';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
/**
|
||||
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 EditComposer 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', [
|
||||
icon('pencil'), ' ',
|
||||
m('a', {href: app.route.discussion(post.discussion(), post.number()), config: m.route}, 'Post #'+post.number()),
|
||||
' in ', post.discussion().title()
|
||||
]));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
data() {
|
||||
return {
|
||||
content: this.content()
|
||||
};
|
||||
}
|
||||
|
||||
onsubmit() {
|
||||
var post = this.props.post;
|
||||
|
||||
this.loading(true);
|
||||
m.redraw();
|
||||
|
||||
post.save(this.data()).then(post => {
|
||||
app.composer.hide();
|
||||
m.redraw();
|
||||
}, response => {
|
||||
this.loading(false);
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
import Post from 'flarum/components/post';
|
||||
import username from 'flarum/helpers/username';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import humanTime from 'flarum/utils/human-time';
|
||||
import { dasherize } from 'flarum/utils/string';
|
||||
|
||||
export default class EventPost extends Post {
|
||||
view(iconName, content, attrs) {
|
||||
var post = this.props.post;
|
||||
var user = post.user();
|
||||
|
||||
attrs = attrs || {};
|
||||
attrs.className = 'event-post '+dasherize(post.contentType())+'-post '+(attrs.className || '');
|
||||
|
||||
return super.view([
|
||||
icon(iconName+' post-icon'),
|
||||
m('div.event-post-info', [
|
||||
user ? m('a.post-user', {href: app.route.user(user), config: m.route}, username(user)) : username(user), ' ',
|
||||
content
|
||||
])
|
||||
], attrs);
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
import FormModal from 'flarum/components/form-modal';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
import Alert from 'flarum/components/alert';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
export default class ForgotPasswordModal extends FormModal {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.email = m.prop(this.props.email || '');
|
||||
this.success = m.prop(false);
|
||||
}
|
||||
|
||||
view() {
|
||||
if (this.success()) {
|
||||
var emailProviderName = this.email().split('@')[1];
|
||||
}
|
||||
|
||||
return super.view({
|
||||
className: 'modal-sm forgot-password',
|
||||
title: 'Forgot Password',
|
||||
body: m('div.form-centered', this.success()
|
||||
? [
|
||||
m('p.help-text', 'We\'ve sent you an email containing a link to reset your password. Check your spam folder if you don\'t receive it within the next minute or two.'),
|
||||
m('div.form-group', [
|
||||
m('a.btn.btn-primary.btn-block', {href: 'http://'+emailProviderName}, 'Go to '+emailProviderName)
|
||||
])
|
||||
]
|
||||
: [
|
||||
m('p.help-text', 'Enter your email address and we\'ll send you a link to reset your password.'),
|
||||
m('div.form-group', [
|
||||
m('input.form-control[name=email][placeholder=Email]', {value: this.email(), onchange: m.withAttr('value', this.email), disabled: this.loading()})
|
||||
]),
|
||||
m('div.form-group', [
|
||||
m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Recover Password')
|
||||
])
|
||||
])
|
||||
});
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
this.loading(true);
|
||||
|
||||
m.request({
|
||||
method: 'POST',
|
||||
url: app.forum.attribute('apiUrl')+'/forgot',
|
||||
data: {email: this.email()},
|
||||
background: true,
|
||||
extract: xhr => {
|
||||
if (xhr.status === 404) {
|
||||
this.alert(new Alert({ type: 'warning', message: 'That email wasn\'t found in our database.' }));
|
||||
throw new Error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}).then(response => {
|
||||
this.loading(false);
|
||||
this.success(true);
|
||||
this.alert(null);
|
||||
m.redraw();
|
||||
}, response => {
|
||||
this.loading(false);
|
||||
this.handleErrors(response.errors);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,62 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
import Alert from 'flarum/components/alert';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
export default class FormModal extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.alert = m.prop();
|
||||
this.loading = m.prop(false);
|
||||
}
|
||||
|
||||
view(options) {
|
||||
var alert = this.alert();
|
||||
if (alert) {
|
||||
alert.props.dismissible = false;
|
||||
}
|
||||
|
||||
return m('div.modal-dialog', {className: options.className, config: this.element}, [
|
||||
m('div.modal-content', [
|
||||
m('div.back-control.close', m('a[href=javascript:;].btn.btn-icon.btn-link', {onclick: this.hide.bind(this)}, icon('times icon'))),
|
||||
m('form', {onsubmit: this.onsubmit.bind(this)}, [
|
||||
m('div.modal-header', m('h3.title-control', options.title)),
|
||||
alert ? m('div.modal-alert', alert) : '',
|
||||
m('div.modal-body', options.body),
|
||||
options.footer ? m('div.modal-footer', options.footer) : ''
|
||||
])
|
||||
]),
|
||||
LoadingIndicator.component({className: 'modal-loading'+(this.loading() ? ' active' : '')})
|
||||
])
|
||||
}
|
||||
|
||||
ready() {
|
||||
this.$(':input:first').select();
|
||||
}
|
||||
|
||||
hide() {
|
||||
app.modal.close();
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
//
|
||||
}
|
||||
|
||||
handleErrors(errors) {
|
||||
if (errors) {
|
||||
this.alert(new Alert({
|
||||
type: 'warning',
|
||||
message: errors.map((error, k) => [error.detail, k < errors.length - 1 ? m('br') : ''])
|
||||
}));
|
||||
}
|
||||
|
||||
m.redraw();
|
||||
|
||||
if (errors) {
|
||||
this.$('[name='+errors[0].path+']').select();
|
||||
} else {
|
||||
this.$(':input:first').select();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
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();
|
||||
|
||||
items.add('search', app.search.render());
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import NavItem from 'flarum/components/nav-item'
|
||||
|
||||
export default class IndexNavItem extends NavItem {
|
||||
static props(props) {
|
||||
props.onclick = props.onclick || function() {
|
||||
if (app.cache.discussionList) {
|
||||
app.cache.discussionList.forceReload = true;
|
||||
}
|
||||
m.redraw.strategy('none');
|
||||
};
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import humanTime from 'flarum/helpers/human-time';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
|
||||
export default class JoinedActivity 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())
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
import FormModal from 'flarum/components/form-modal';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
import ForgotPasswordModal from 'flarum/components/forgot-password-modal';
|
||||
import SignupModal from 'flarum/components/signup-modal';
|
||||
import Alert from 'flarum/components/alert';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
export default class LoginModal extends FormModal {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.email = m.prop(this.props.email || '');
|
||||
this.password = m.prop(this.props.password || '');
|
||||
}
|
||||
|
||||
view() {
|
||||
return super.view({
|
||||
className: 'modal-sm login-modal',
|
||||
title: 'Log In',
|
||||
body: m('div.form-centered', [
|
||||
m('div.form-group', [
|
||||
m('input.form-control[name=email][placeholder=Username or Email]', {value: this.email(), onchange: m.withAttr('value', this.email), disabled: this.loading()})
|
||||
]),
|
||||
m('div.form-group', [
|
||||
m('input.form-control[type=password][name=password][placeholder=Password]', {value: this.password(), onchange: m.withAttr('value', this.password), disabled: this.loading()})
|
||||
]),
|
||||
m('div.form-group', [
|
||||
m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Log In')
|
||||
])
|
||||
]),
|
||||
footer: [
|
||||
m('p.forgot-password-link', m('a[href=javascript:;]', {onclick: () => {
|
||||
var email = this.email();
|
||||
var props = email.indexOf('@') !== -1 ? {email} : null;
|
||||
app.modal.show(new ForgotPasswordModal(props));
|
||||
}}, 'Forgot password?')),
|
||||
m('p.sign-up-link', [
|
||||
'Don\'t have an account? ',
|
||||
m('a[href=javascript:;]', {onclick: () => {
|
||||
var props = {password: this.password()};
|
||||
var email = this.email();
|
||||
props[email.indexOf('@') !== -1 ? 'email' : 'username'] = email;
|
||||
app.modal.show(new SignupModal(props));
|
||||
}}, 'Sign Up')
|
||||
])
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
ready() {
|
||||
this.email() ? this.$('[name=password]').select() : this.$('[name=email]').select();
|
||||
}
|
||||
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
this.loading(true);
|
||||
var email = this.email();
|
||||
var password = this.password();
|
||||
|
||||
app.session.login(email, password).then(() => {
|
||||
this.hide();
|
||||
this.props.onlogin && this.props.onlogin();
|
||||
}, response => {
|
||||
this.loading(false);
|
||||
if (response && response.code === 'confirm_email') {
|
||||
var state;
|
||||
|
||||
this.alert(Alert.component({
|
||||
message: ['You need to confirm your email before you can log in. We\'ve sent a confirmation email to ', m('strong', response.email), '. If it doesn\'t arrive soon, check your spam folder.']
|
||||
}));
|
||||
} else {
|
||||
this.alert(Alert.component({
|
||||
type: 'warning',
|
||||
message: 'Your login details were incorrect.'
|
||||
}));
|
||||
}
|
||||
m.redraw();
|
||||
this.ready();
|
||||
});
|
||||
}
|
||||
}
|
@ -1,95 +0,0 @@
|
||||
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].render());
|
||||
})
|
||||
]))
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
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];
|
||||
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)).filter(key => !this.inputs[key].props.disabled);
|
||||
this.toggle(keys);
|
||||
}
|
||||
|
||||
toggleType(type) {
|
||||
var keys = this.methods.map(method => this.key(type, method.name)).filter(key => !this.inputs[key].props.disabled);
|
||||
this.toggle(keys);
|
||||
}
|
||||
}
|
@ -1,97 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import username from 'flarum/helpers/username';
|
||||
import listItems from 'flarum/helpers/list-items';
|
||||
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';
|
||||
import Discussion from 'flarum/models/discussion';
|
||||
|
||||
export default class NotificationList extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.loading = m.prop(false);
|
||||
this.load();
|
||||
}
|
||||
|
||||
view() {
|
||||
var user = this.props.user;
|
||||
|
||||
var groups = [];
|
||||
if (app.cache.notifications) {
|
||||
var groupsObject = {};
|
||||
app.cache.notifications.forEach(notification => {
|
||||
var subject = notification.subject();
|
||||
var discussion = subject instanceof Discussion ? subject : (subject.discussion && subject.discussion());
|
||||
var key = discussion ? discussion.id() : 0;
|
||||
groupsObject[key] = groupsObject[key] || {discussion: discussion, notifications: []};
|
||||
groupsObject[key].notifications.push(notification);
|
||||
if (groups.indexOf(groupsObject[key]) === -1) {
|
||||
groups.push(groupsObject[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return m('div.notification-list', [
|
||||
m('div.notifications-header', [
|
||||
m('div.primary-control',
|
||||
ActionButton.component({
|
||||
className: 'btn btn-icon btn-link btn-sm',
|
||||
icon: 'check',
|
||||
title: 'Mark All as Read',
|
||||
onclick: this.markAllAsRead.bind(this)
|
||||
})
|
||||
),
|
||||
m('h4.title-control', 'Notifications')
|
||||
]),
|
||||
m('div.notifications-content', groups.length
|
||||
? groups.map(group => {
|
||||
var badges = group.discussion && group.discussion.badges().toArray();
|
||||
|
||||
return m('div.notification-group', [
|
||||
group.discussion
|
||||
? m('a.notification-group-header', {
|
||||
href: app.route.discussion(group.discussion),
|
||||
config: m.route
|
||||
},
|
||||
badges && badges.length ? m('ul.badges', listItems(badges)) : '',
|
||||
group.discussion.title()
|
||||
)
|
||||
: m('div.notification-group-header', app.forum.attribute('title')),
|
||||
m('ul.notification-group-list', group.notifications.map(notification => {
|
||||
var NotificationComponent = app.notificationComponentRegistry[notification.contentType()];
|
||||
return NotificationComponent ? m('li', NotificationComponent.component({notification})) : '';
|
||||
}))
|
||||
])
|
||||
})
|
||||
: (!this.loading() ? m('div.no-notifications', 'No Notifications') : '')),
|
||||
this.loading() ? LoadingIndicator.component() : ''
|
||||
]);
|
||||
}
|
||||
|
||||
load() {
|
||||
if (!app.cache.notifications || app.session.user().unreadNotificationsCount()) {
|
||||
var component = this;
|
||||
this.loading(true);
|
||||
m.redraw();
|
||||
app.store.find('notifications').then(notifications => {
|
||||
app.session.user().pushAttributes({unreadNotificationsCount: 0});
|
||||
this.loading(false);
|
||||
app.cache.notifications = notifications.sort((a, b) => b.time() - a.time());
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
app.cache.notifications.forEach(function(notification) {
|
||||
if (!notification.isRead()) {
|
||||
notification.save({isRead: true});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -1,25 +1,67 @@
|
||||
import Component from 'flarum/component';
|
||||
import Component from 'flarum/Component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import humanTime from 'flarum/helpers/human-time';
|
||||
import { dasherize } from 'flarum/utils/string';
|
||||
import humanTime from 'flarum/helpers/humanTime';
|
||||
|
||||
/**
|
||||
* The `Notification` component abstract displays a single notification.
|
||||
* Subclasses should implement the `icon`, `href`, and `content` methods.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `notification`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Notification extends Component {
|
||||
view(args) {
|
||||
var notification = this.props.notification;
|
||||
view() {
|
||||
const notification = this.props.notification;
|
||||
const href = this.href();
|
||||
|
||||
return m('div.notification.notification-'+dasherize(notification.contentType()), {
|
||||
className: !notification.isRead() ? 'unread' : '',
|
||||
onclick: this.read.bind(this)
|
||||
}, m('a', {href: args.href, config: args.config || m.route}, [
|
||||
avatar(notification.sender()), ' ',
|
||||
icon(args.icon+' icon'), ' ',
|
||||
m('span.content', args.content), ' ',
|
||||
humanTime(notification.time())
|
||||
]));
|
||||
return (
|
||||
<div className={'notification notification-' + notification.contentType() + ' ' + (!notification.isRead() ? 'unread' : '')}
|
||||
onclick={this.markAsRead.bind(this)}>
|
||||
<a href={href} config={href.indexOf('://') === -1 ? m.route : undefined}>
|
||||
{avatar(notification.sender())}
|
||||
{icon(this.icon(), {className: 'icon'})}
|
||||
<span className="content">{this.content()}</span>
|
||||
{humanTime(notification.time())}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
read() {
|
||||
/**
|
||||
* Get the name of the icon that should be displayed in the notification.
|
||||
*
|
||||
* @return {String}
|
||||
* @abstract
|
||||
*/
|
||||
icon() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL that the notification should link to.
|
||||
*
|
||||
* @return {String}
|
||||
* @abstract
|
||||
*/
|
||||
href() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of the notification.
|
||||
*
|
||||
* @return {VirtualElement}
|
||||
* @abstract
|
||||
*/
|
||||
content() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the notification as read.
|
||||
*/
|
||||
markAsRead() {
|
||||
this.props.notification.save({isRead: true});
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import NotificationList from 'flarum/components/notification-list';
|
||||
|
||||
export default class NotificationsPage extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
app.current = this;
|
||||
app.history.push('notifications');
|
||||
app.drawer.hide();
|
||||
}
|
||||
|
||||
view() {
|
||||
return m('div', NotificationList.component());
|
||||
}
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
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'));
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
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()})
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
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'));
|
||||
}
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
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', {username: user.username()}), config: m.route}, [
|
||||
avatar(user), ' ',
|
||||
username(user)
|
||||
]),
|
||||
m('ul.badges', listItems(user.badges().toArray().reverse()))
|
||||
] : [
|
||||
avatar(), ' ',
|
||||
username()
|
||||
]
|
||||
),
|
||||
user && !post.isHidden() && 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.$().on('mouseover', 'h3 a, .user-card', function() {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(function() {
|
||||
component.showCard(true);
|
||||
m.redraw();
|
||||
setTimeout(() => component.$('.user-card').addClass('in'));
|
||||
}, 500);
|
||||
}).on('mouseout', 'h3 a, .user-card', function() {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(function() {
|
||||
component.$('.user-card').removeClass('in').one('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function() {
|
||||
component.showCard(false);
|
||||
m.redraw();
|
||||
});
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
|
||||
export default class PostLoadingComponent extends Component {
|
||||
view() {
|
||||
return m('div.post.comment-post.loading-post.fake-post',
|
||||
m('header.post-header', avatar(), ' ', m('div.fake-text')),
|
||||
m('div.post-body', m('div.fake-text'), m('div.fake-text'), m('div.fake-text'))
|
||||
);
|
||||
}
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import username from 'flarum/helpers/username';
|
||||
import humanTime from 'flarum/helpers/human-time';
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import truncate from 'flarum/utils/truncate';
|
||||
|
||||
export default class PostPreview extends Component {
|
||||
view() {
|
||||
var post = this.props.post;
|
||||
var user = post.user();
|
||||
|
||||
var excerpt = post.contentPlain();
|
||||
var start = 0;
|
||||
|
||||
if (this.props.highlight) {
|
||||
var regexp = new RegExp(this.props.highlight, 'gi');
|
||||
start = Math.max(0, excerpt.search(regexp) - 100);
|
||||
}
|
||||
|
||||
excerpt = truncate(excerpt, 200, start);
|
||||
|
||||
if (this.props.highlight) {
|
||||
excerpt = highlight(excerpt, regexp);
|
||||
}
|
||||
|
||||
return m('a.post-preview', {
|
||||
href: app.route.post(post),
|
||||
config: m.route,
|
||||
onclick: this.props.onclick
|
||||
}, m('div.post-preview-content', [
|
||||
avatar(user), ' ',
|
||||
username(user), ' ',
|
||||
humanTime(post.time()), ' ',
|
||||
excerpt
|
||||
]));
|
||||
}
|
||||
}
|
@ -1,387 +0,0 @@
|
||||
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';
|
||||
import formatNumber from 'flarum/utils/format-number';
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
export default class PostScrubber extends Component {
|
||||
/**
|
||||
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
var stream = this.props.stream;
|
||||
this.handlers = {};
|
||||
|
||||
// When the stream-content component begins loading posts at a certain
|
||||
// index, we want our scrubber scrollbar to jump to that position.
|
||||
stream.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this));
|
||||
|
||||
/**
|
||||
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.stream.count();
|
||||
this.index = m.prop(0);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
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.
|
||||
*/
|
||||
disabled() {
|
||||
return this.visible() >= this.count();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
view() {
|
||||
var retain = this.subtree.retain();
|
||||
var stream = this.props.stream;
|
||||
var unreadCount = this.props.stream.discussion.unreadCount();
|
||||
var unreadPercent = Math.min(this.count() - this.index(), unreadCount) / this.count();
|
||||
|
||||
// @todo clean up duplication
|
||||
return m('div.stream-scrubber.dropdown'+(this.disabled() ? '.disabled' : ''), {config: this.onload.bind(this), className: this.props.className}, [
|
||||
m('a.btn.btn-default.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', [
|
||||
m('span.index', retain || formatNumber(this.visibleIndex())), ' of ', m('span.count', formatNumber(this.count())), ' posts ',
|
||||
icon('sort icon-glyph')
|
||||
]),
|
||||
m('div.dropdown-menu', [
|
||||
m('div.scrubber', [
|
||||
m('a.scrubber-first[href=javascript:;]', {onclick: () => {
|
||||
stream.goToFirst();
|
||||
this.index(0);
|
||||
this.renderScrollbar(true);
|
||||
}}, [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 || formatNumber(this.visibleIndex())), ' of ', m('span.count', formatNumber(this.count())), ' posts']),
|
||||
m('span.description', retain || this.description())
|
||||
])
|
||||
]),
|
||||
m('div.scrubber-after'),
|
||||
(app.session.user() && unreadPercent) ? m('div.scrubber-unread', {
|
||||
style: {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'},
|
||||
config: function(element, isInitialized, context) {
|
||||
var $element = $(element);
|
||||
var newStyle = {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'};
|
||||
if (context.oldStyle) {
|
||||
$element.stop(true).css(context.oldStyle).animate(newStyle);
|
||||
}
|
||||
context.oldStyle = newStyle;
|
||||
}
|
||||
}, formatNumber(unreadCount)+' unread') : ''
|
||||
]),
|
||||
m('a.scrubber-last[href=javascript:;]', {onclick: () => {
|
||||
stream.goToLast();
|
||||
this.index(stream.count());
|
||||
this.renderScrollbar(true);
|
||||
}}, [icon('angle-double-down'), ' Now'])
|
||||
])
|
||||
])
|
||||
])
|
||||
}
|
||||
|
||||
onscroll(top) {
|
||||
var stream = this.props.stream;
|
||||
|
||||
if (stream.paused() || !stream.$()) { return; }
|
||||
|
||||
this.update(top);
|
||||
this.renderScrollbar();
|
||||
}
|
||||
|
||||
/**
|
||||
Update the index/visible/description properties according to the window's
|
||||
current scroll position.
|
||||
*/
|
||||
update(top) {
|
||||
var stream = this.props.stream;
|
||||
|
||||
var $window = $(window);
|
||||
var marginTop = stream.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 = stream.$('> .item[data-index]');
|
||||
var index = $items.first().data('index') || 0;
|
||||
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('index')) + 1 - visible;
|
||||
return;
|
||||
}
|
||||
if (top > scrollTop + windowHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the bottom half of this item is visible at the top of the
|
||||
// viewport
|
||||
if (top <= scrollTop && top + height > scrollTop) {
|
||||
visible = (top + height - scrollTop) / height;
|
||||
index = parseFloat($this.data('index')) + 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; }
|
||||
|
||||
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();
|
||||
|
||||
// When any part of the whole scrollbar is clicked, we want to jump to
|
||||
// that position.
|
||||
this.$('.scrubber-scrollbar')
|
||||
.bind('click', this.onclick.bind(this))
|
||||
|
||||
// Now we want to make the scrollbar handle draggable. Let's start by
|
||||
// preventing default browser events from messing things up.
|
||||
.css({ cursor: 'pointer', 'user-select': 'none' })
|
||||
.bind('dragstart mousedown touchstart', 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.dragging = false;
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
|
||||
this.$('.scrubber-slider')
|
||||
.css('cursor', 'move')
|
||||
.bind('mousedown touchstart', this.onmousedown.bind(this))
|
||||
|
||||
// Exempt the scrollbar handle from the 'jump to' click event.
|
||||
.click(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.stream.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() || 1;
|
||||
|
||||
var $scrubber = this.$();
|
||||
$scrubber.find('.index').text(formatNumber(this.visibleIndex()));
|
||||
$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() || 1;
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
onresize() {
|
||||
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 scrubber = this.$();
|
||||
var scrollbar = this.$('.scrubber-scrollbar');
|
||||
scrollbar.css('max-height', $(window).height() - scrubber.offset().top + $(window).scrollTop() - parseInt($('.global-page').css('padding-bottom')) - (scrubber.outerHeight() - scrollbar.outerHeight()));
|
||||
}
|
||||
|
||||
onmousedown(e) {
|
||||
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
||||
this.indexStart = this.index();
|
||||
this.dragging = true;
|
||||
this.props.stream.paused(true);
|
||||
$('body').css('cursor', 'move');
|
||||
}
|
||||
|
||||
onmousemove(e) {
|
||||
if (! this.dragging) { 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 = (e.clientY || e.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();
|
||||
}
|
||||
|
||||
onmouseup(e) {
|
||||
if (!this.dragging) { return; }
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
this.dragging = false;
|
||||
$('body').css('cursor', '');
|
||||
|
||||
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());
|
||||
this.props.stream.goToIndex(intIndex);
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
onclick(e) {
|
||||
// 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 $scrollbar = this.$('.scrubber-scrollbar');
|
||||
var offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $scrollbar.offset().top + $('body').scrollTop();
|
||||
var offsetPercent = offsetPixels / $scrollbar.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($scrollbar.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 / this.percentPerPost().index;
|
||||
offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex));
|
||||
this.props.stream.goToIndex(Math.floor(offsetIndex));
|
||||
this.index(offsetIndex);
|
||||
this.renderScrollbar(true);
|
||||
|
||||
this.$().removeClass('open');
|
||||
}
|
||||
}
|
@ -1,463 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import ScrollListener from 'flarum/utils/scroll-listener';
|
||||
import PostLoading from 'flarum/components/post-loading';
|
||||
import anchorScroll from 'flarum/utils/anchor-scroll';
|
||||
import mixin from 'flarum/utils/mixin';
|
||||
import evented from 'flarum/utils/evented';
|
||||
import ReplyPlaceholder from 'flarum/components/reply-placeholder';
|
||||
|
||||
class PostStream extends mixin(Component, evented) {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.discussion = this.props.discussion;
|
||||
this.setup(this.props.includedPosts);
|
||||
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
|
||||
this.paused = m.prop(false);
|
||||
|
||||
this.loadPageTimeouts = {};
|
||||
this.pagesLoading = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
Load and scroll to a post with a certain number.
|
||||
*/
|
||||
goToNumber(number, noAnimation) {
|
||||
this.paused(true);
|
||||
|
||||
var promise = this.loadNearNumber(number);
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
return promise.then(() => {
|
||||
m.redraw(true);
|
||||
|
||||
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
Load and scroll to a certain index within the discussion.
|
||||
*/
|
||||
goToIndex(index, backwards, noAnimation) {
|
||||
this.paused(true);
|
||||
|
||||
var promise = this.loadNearIndex(index);
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
return promise.then(() => {
|
||||
anchorScroll(this.$('.item:' + (backwards ? 'last' : 'first')), () => m.redraw(true));
|
||||
|
||||
this.scrollToIndex(index, noAnimation, backwards).done(this.unpause.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
Load and scroll up to the first post in the discussion.
|
||||
*/
|
||||
goToFirst() {
|
||||
return this.goToIndex(0);
|
||||
}
|
||||
|
||||
/**
|
||||
Load and scroll down to the last post in the discussion.
|
||||
*/
|
||||
goToLast() {
|
||||
return this.goToIndex(this.count() - 1, true);
|
||||
}
|
||||
|
||||
/**
|
||||
Add a post to the end of the stream. Nothing will be done if the end of the
|
||||
stream is not visible.
|
||||
*/
|
||||
update() {
|
||||
if (this.viewingEnd) {
|
||||
this.visibleEnd = this.count();
|
||||
|
||||
this.loadRange(this.visibleStart, this.visibleEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Get the total number of posts in the discussion.
|
||||
*/
|
||||
count() {
|
||||
return this.discussion.postIds().length;
|
||||
}
|
||||
|
||||
/**
|
||||
Make sure that the given index is not outside of the possible range of
|
||||
indexes in the discussion.
|
||||
*/
|
||||
sanitizeIndex(index) {
|
||||
return Math.max(0, Math.min(this.count(), index));
|
||||
}
|
||||
|
||||
/**
|
||||
Set up the stream with the given array of posts.
|
||||
*/
|
||||
setup(posts) {
|
||||
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
|
||||
this.visibleEnd = this.visibleStart + posts.length;
|
||||
}
|
||||
|
||||
/**
|
||||
Clear the stream and fill it with placeholder posts.
|
||||
*/
|
||||
clear(start, end) {
|
||||
this.visibleStart = start || 0;
|
||||
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
|
||||
}
|
||||
|
||||
posts() {
|
||||
return this.discussion.postIds()
|
||||
.slice(this.visibleStart, this.visibleEnd)
|
||||
.map(id => app.store.getById('posts', id));
|
||||
}
|
||||
|
||||
/**
|
||||
Construct a vDOM containing an element for each post that is visible in the
|
||||
stream. Posts that have not been loaded will be rendered as placeholders.
|
||||
*/
|
||||
view() {
|
||||
function fadeIn(element, isInitialized, context) {
|
||||
if (!context.fadedIn) $(element).hide().fadeIn();
|
||||
context.fadedIn = true;
|
||||
}
|
||||
|
||||
var lastTime;
|
||||
|
||||
this.visibleEnd = this.sanitizeIndex(this.visibleEnd);
|
||||
this.viewingEnd = this.visibleEnd === this.count();
|
||||
|
||||
return m('div.discussion-posts.posts', {config: this.onload.bind(this)},
|
||||
this.posts().map((post, i) => {
|
||||
var content;
|
||||
var attributes = {};
|
||||
attributes['data-index'] = this.visibleStart + i;
|
||||
|
||||
if (post) {
|
||||
attributes.key = 'post'+post.id();
|
||||
|
||||
var PostComponent = app.postComponentRegistry[post.contentType()];
|
||||
content = PostComponent ? PostComponent.component({post}) : '';
|
||||
attributes.config = fadeIn;
|
||||
attributes['data-time'] = post.time().toISOString();
|
||||
attributes['data-number'] = post.number();
|
||||
|
||||
var dt = post.time() - lastTime;
|
||||
if (dt > 1000 * 60 * 60 * 24 * 4) { // 4 hours
|
||||
content = [
|
||||
m('div.time-gap', m('span', moment.duration(dt).humanize(), ' later')),
|
||||
content
|
||||
];
|
||||
}
|
||||
lastTime = post.time();
|
||||
} else {
|
||||
attributes.key = this.visibleStart + i;
|
||||
|
||||
content = PostLoading.component();
|
||||
}
|
||||
|
||||
return m('div.item', attributes, content);
|
||||
}),
|
||||
|
||||
// If we're viewing the end of the discussion, the user can reply, and
|
||||
// is not already doing so, then show a 'write a reply' placeholder.
|
||||
this.viewingEnd &&
|
||||
(!app.session.user() || this.discussion.canReply()) &&
|
||||
!app.composingReplyTo(this.discussion)
|
||||
? m('div.item', {key: 'reply'}, ReplyPlaceholder.component({discussion: this.discussion}))
|
||||
: ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
Store a reference to the component's DOM and begin listening for the
|
||||
window's scroll event.
|
||||
*/
|
||||
onload(element, isInitialized, context) {
|
||||
this.element(element);
|
||||
|
||||
if (isInitialized) { return; }
|
||||
|
||||
context.onunload = this.ondestroy.bind(this);
|
||||
|
||||
// This is wrapped in setTimeout due to the following Mithril issue:
|
||||
// https://github.com/lhorie/mithril.js/issues/637
|
||||
setTimeout(() => this.scrollListener.start());
|
||||
}
|
||||
|
||||
/**
|
||||
Stop listening for the window's scroll event, and cancel outstanding
|
||||
timeouts.
|
||||
*/
|
||||
ondestroy() {
|
||||
this.scrollListener.stop();
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
When the window is scrolled, check if either extreme of the post stream is
|
||||
in the viewport, and if so, trigger loading the next/previous page.
|
||||
*/
|
||||
onscroll(top) {
|
||||
if (this.paused()) return;
|
||||
|
||||
var marginTop = this.getMarginTop();
|
||||
var viewportHeight = $(window).height() - marginTop;
|
||||
var viewportTop = top + marginTop;
|
||||
var loadAheadDistance = 500;
|
||||
|
||||
if (this.visibleStart > 0) {
|
||||
var $item = this.$('.item[data-index='+this.visibleStart+']');
|
||||
|
||||
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
|
||||
this.loadPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.visibleEnd < this.count()) {
|
||||
var $item = this.$('.item[data-index='+(this.visibleEnd - 1)+']');
|
||||
|
||||
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
|
||||
this.loadNext();
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100);
|
||||
}
|
||||
|
||||
/**
|
||||
Load the next page of posts.
|
||||
*/
|
||||
loadNext() {
|
||||
var start = this.visibleEnd;
|
||||
var end = this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount);
|
||||
|
||||
// Unload the posts which are two pages back from the page we're currently
|
||||
// loading.
|
||||
var twoPagesAway = start - this.constructor.loadCount * 2;
|
||||
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
|
||||
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
}
|
||||
|
||||
this.loadPage(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
Load the previous page of posts.
|
||||
*/
|
||||
loadPrevious() {
|
||||
var end = this.visibleStart;
|
||||
var start = this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount);
|
||||
|
||||
// Unload the posts which are two pages back from the page we're currently
|
||||
// loading.
|
||||
var twoPagesAway = start + this.constructor.loadCount * 2;
|
||||
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
|
||||
this.visibleEnd = twoPagesAway;
|
||||
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
||||
}
|
||||
|
||||
this.loadPage(start, end, true);
|
||||
}
|
||||
|
||||
/**
|
||||
Load a page of posts into the stream and redraw.
|
||||
*/
|
||||
loadPage(start, end, backwards) {
|
||||
var redraw = () => {
|
||||
if (start < this.visibleStart || end > this.visibleEnd) return;
|
||||
|
||||
var anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
||||
anchorScroll(`.item[data-index=${anchorIndex}]`, () => m.redraw(true));
|
||||
|
||||
this.unpause();
|
||||
};
|
||||
redraw();
|
||||
|
||||
this.pagesLoading++;
|
||||
|
||||
this.loadPageTimeouts[start] = setTimeout(() => {
|
||||
this.loadRange(start, end).then(() => {
|
||||
redraw();
|
||||
this.pagesLoading--;
|
||||
});
|
||||
}, this.pagesLoading ? 1000 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
Load and inject the specified range of posts into the stream, without
|
||||
clearing it.
|
||||
*/
|
||||
loadRange(start, end) {
|
||||
const loadIds = [];
|
||||
const loaded = [];
|
||||
|
||||
this.discussion.postIds().slice(start, end).forEach(id => {
|
||||
const post = app.store.getById('posts', id);
|
||||
|
||||
if (!post) {
|
||||
loadIds.push(id);
|
||||
} else {
|
||||
loaded.push(post);
|
||||
}
|
||||
});
|
||||
|
||||
return loadIds.length
|
||||
? app.store.find('posts', loadIds)
|
||||
: m.deferred().resolve(loaded).promise;
|
||||
}
|
||||
|
||||
/**
|
||||
Clear the stream and load posts near a certain number. Returns a promise. If
|
||||
the post with the given number is already loaded, the promise will be
|
||||
resolved immediately.
|
||||
*/
|
||||
loadNearNumber(number) {
|
||||
if (this.posts().some(post => post && post.number() == number)) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
this.clear();
|
||||
|
||||
return app.store.find('posts', {
|
||||
filter: {discussion: this.discussion.id()},
|
||||
page: {near: number}
|
||||
}).then(this.setup.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
Clear the stream and load posts near a certain index. A page of posts
|
||||
surrounding the given index will be loaded. Returns a promise. If the given
|
||||
index is already loaded, the promise will be resolved immediately.
|
||||
*/
|
||||
loadNearIndex(index) {
|
||||
if (index >= this.visibleStart && index <= this.visibleEnd) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
var start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
|
||||
var end = start + this.constructor.loadCount;
|
||||
|
||||
this.clear(start, end);
|
||||
|
||||
return this.loadRange(start, end).then(this.setup.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
Work out which posts (by number) are currently visible in the viewport, and
|
||||
fire an event with the information.
|
||||
*/
|
||||
calculatePosition() {
|
||||
var marginTop = this.getMarginTop();
|
||||
var $window = $(window);
|
||||
var viewportHeight = $window.height() - marginTop;
|
||||
var scrollTop = $window.scrollTop() + marginTop;
|
||||
var startNumber;
|
||||
var endNumber;
|
||||
|
||||
this.$('.item').each(function() {
|
||||
var $item = $(this);
|
||||
var top = $item.offset().top;
|
||||
var height = $item.outerHeight(true);
|
||||
|
||||
if (top + height > scrollTop) {
|
||||
if (!startNumber) {
|
||||
startNumber = $item.data('number');
|
||||
}
|
||||
|
||||
if (top + height < scrollTop + viewportHeight) {
|
||||
if ($item.data('number')) {
|
||||
endNumber = $item.data('number');
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (startNumber) {
|
||||
this.trigger('positionChanged', startNumber || 1, endNumber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
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'), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
Scroll down to a certain post by number and 'flash' it.
|
||||
*/
|
||||
scrollToNumber(number, noAnimation) {
|
||||
var $item = this.$('.item[data-number='+number+']');
|
||||
|
||||
return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item));
|
||||
}
|
||||
|
||||
/**
|
||||
Scroll down to a certain post by index.
|
||||
*/
|
||||
scrollToIndex(index, noAnimation, bottom) {
|
||||
var $item = this.$('.item[data-index='+index+']');
|
||||
|
||||
return this.scrollToItem($item, noAnimation, true, bottom);
|
||||
}
|
||||
|
||||
/**
|
||||
Scroll down to the given post.
|
||||
*/
|
||||
scrollToItem($item, noAnimation, force, bottom) {
|
||||
var $container = $('html, body').stop(true);
|
||||
|
||||
if ($item.length) {
|
||||
var itemTop = $item.offset().top - this.getMarginTop();
|
||||
var itemBottom = itemTop + $item.height();
|
||||
var scrollTop = $(document).scrollTop();
|
||||
var scrollBottom = scrollTop + $(window).height();
|
||||
|
||||
// If the item is already in the viewport, we may not need to scroll.
|
||||
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||
var scrollTop = bottom ? itemBottom : ($item.is(':first-child') ? 0 : itemTop);
|
||||
|
||||
if (noAnimation) {
|
||||
$container.scrollTop(scrollTop);
|
||||
} else if (scrollTop !== $(document).scrollTop()) {
|
||||
$container.animate({scrollTop: scrollTop}, 'fast');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $container.promise();
|
||||
}
|
||||
|
||||
/**
|
||||
'Flash' the given post, drawing the user's attention to it.
|
||||
*/
|
||||
flashItem($item) {
|
||||
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
||||
}
|
||||
|
||||
/**
|
||||
Resume the stream's ability to auto-load posts on scroll.
|
||||
*/
|
||||
unpause() {
|
||||
this.paused(false);
|
||||
this.scrollListener.update(true);
|
||||
this.trigger('unpaused');
|
||||
}
|
||||
}
|
||||
|
||||
PostStream.loadCount = 20;
|
||||
|
||||
export default PostStream;
|
@ -1,31 +1,75 @@
|
||||
import Component from 'flarum/component';
|
||||
import SubtreeRetainer from 'flarum/utils/subtree-retainer';
|
||||
import DropdownButton from 'flarum/components/dropdown-button';
|
||||
import Component from 'flarum/Component';
|
||||
import SubtreeRetainer from 'flarum/utils/SubtreeRetainer';
|
||||
import Dropdown from 'flarum/components/Dropdown';
|
||||
import PostControls from 'flarum/utils/PostControls';
|
||||
|
||||
/**
|
||||
* The `Post` component displays a single post. The basic post template just
|
||||
* includes a controls dropdown; subclasses must implement `content` and `attrs`
|
||||
* methods.
|
||||
*
|
||||
* ### Props
|
||||
*
|
||||
* - `post`
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export default class Post extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* Set up a subtree retainer so that the post will not be redrawn
|
||||
* unless new data comes in.
|
||||
*
|
||||
* @type {SubtreeRetainer}
|
||||
*/
|
||||
this.subtree = new SubtreeRetainer(
|
||||
() => this.props.post.freshness,
|
||||
() => {
|
||||
var user = this.props.post.user();
|
||||
const user = this.props.post.user();
|
||||
return user && user.freshness;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
view(content, attrs) {
|
||||
var controls = this.props.post.controls(this).toArray();
|
||||
view() {
|
||||
const controls = PostControls.controls(this.props.post, this).toArray();
|
||||
const attrs = this.attrs();
|
||||
|
||||
return m('article.post', attrs, 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'
|
||||
}) : '',
|
||||
content
|
||||
]));
|
||||
attrs.className = 'post ' + (attrs.className || '');
|
||||
|
||||
return (
|
||||
<article {...attrs}>
|
||||
{this.subtree.retain() || (
|
||||
<div>
|
||||
{controls.length ? Dropdown.component({
|
||||
children: controls,
|
||||
className: 'contextual-controls',
|
||||
buttonClass: 'btn btn-default btn-icon btn-controls btn-naked',
|
||||
menuClass: 'pull-right'
|
||||
}) : ''}
|
||||
|
||||
{this.content()}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attributes for the post element.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
attrs() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the post's content.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
content() {
|
||||
}
|
||||
}
|
||||
|
@ -1,38 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import humanTime from 'flarum/helpers/human-time';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
import listItems from 'flarum/helpers/list-items';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
|
||||
export default class PostedActivity extends Component {
|
||||
view() {
|
||||
var activity = this.props.activity;
|
||||
var user = activity.user();
|
||||
var post = activity.subject();
|
||||
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.post-activity', {href: app.route('discussion.near', {
|
||||
id: discussion.id(),
|
||||
slug: discussion.slug(),
|
||||
near: post.number()
|
||||
}), config: m.route}, [
|
||||
m('ul.list-inline', listItems(this.headerItems().toArray())),
|
||||
m('div.body', m.trust(post.contentPlain().substring(0, 200)))
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
headerItems() {
|
||||
var items = new ItemList();
|
||||
|
||||
items.add('title', m('h3.title', this.props.activity.subject().discussion().title()));
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user