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:
Toby Zerner
2015-07-15 14:00:11 +09:30
parent 4480e0a83f
commit ab6c03c0cc
220 changed files with 9785 additions and 5919 deletions

View File

@ -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;
}

View File

@ -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"
}
}

View File

@ -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
View 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;
}
}

View File

@ -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;

View 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() {
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
);
}
}

View 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
);
}
}

View 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();
}
}

View 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() {
}
}

View 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';
}
}

View 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&#39;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 &quot;DELETE&quot; 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());
}
}

View 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
);
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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();
}
}
}

View 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'];
}
}

View 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), '.'];
}
}

View 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>
);
})
];
}
}

View 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
);
}
}

View 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() {
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
);
}
}

View 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();
}
}

View 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;
}
}

View File

@ -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()});
}
};
}

View 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';
}
}

View 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>
);
}
}

View 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();
}
);
}
}

View 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();
}
}
}

View 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;
}
}

View 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});
}
});
}
}

View 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;
}
}
}

View 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>;
}
}

View 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();
}
}

View 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>
);
}
}

View 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>
);
}
}

View 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;

View 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');
}
}

View 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();
});
}
}

View 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;
}
}

View 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);
}
);
}
}

View 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>
);
}
}

View 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);
}
}
}
}

View 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() {
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
);
}
}

View 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>
);
}
}

View 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());
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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>
))
];
}
}

View 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;
}
}

View File

@ -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)
})) : '')
]);
}
}

View File

@ -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();
});
}
}

View File

@ -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);
});
}
}

View File

@ -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();
});
}
}

View File

@ -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();
}
}

View File

@ -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) {
//
}
}

View File

@ -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;

View File

@ -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());
}
}

View File

@ -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();
});
}
}

View File

@ -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;
}
}

View File

@ -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();
});
}
}
};

View File

@ -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)
})) : '')
]);
}
}

View File

@ -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();
}
}
}

View File

@ -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']
});
}
}

View File

@ -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), '.']);
}
}

View File

@ -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)) : ''
)
);
}) : ''
];
}
}

View File

@ -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();
});
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
});
}
}

View File

@ -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();
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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');
};
}
}

View File

@ -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())
])
]);
}
}

View File

@ -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();
});
}
}

View File

@ -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);
}
}

View File

@ -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});
}
})
}
}

View File

@ -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});
}
}

View File

@ -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());
}
}

View File

@ -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'));
}
}

View File

@ -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()})
])
);
}
}

View File

@ -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'));
}
}

View File

@ -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);
});
}
}

View File

@ -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'))
);
}
}

View File

@ -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
]));
}
}

View File

@ -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');
}
}

View File

@ -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;

View File

@ -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() {
}
}

View File

@ -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