mirror of
https://github.com/flarum/core.git
synced 2025-07-19 15:51:16 +02:00
Merge branch 'master' into psr-7
Conflicts: composer.json composer.lock src/Api/Actions/TokenAction.php src/Core/Formatter/FormatterManager.php src/Core/Handlers/Events/EmailConfirmationMailer.php src/Forum/Actions/ConfirmEmailAction.php src/Forum/Actions/IndexAction.php src/Forum/Actions/ResetPasswordAction.php src/Forum/Actions/SavePasswordAction.php src/Forum/routes.php
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"misd/linkify": "1.1.*",
|
||||
"oyejorge/less.php": "dev-master",
|
||||
"intervention/image": "dev-master",
|
||||
"ezyang/htmlpurifier": "dev-master",
|
||||
"psr/http-message": "^1.0@dev",
|
||||
"nikic/fast-route": "dev-master",
|
||||
"dflydev/fig-cookies": "^1.0"
|
||||
|
133
composer.lock
generated
133
composer.lock
generated
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"hash": "7565e9df531dc59fa898d68cf3d7d69e",
|
||||
"hash": "57202a56eaacefdff162f426710d1cb2",
|
||||
"packages": [
|
||||
{
|
||||
"name": "danielstjules/stringy",
|
||||
@@ -181,6 +181,50 @@
|
||||
],
|
||||
"time": "2015-01-01 18:34:57"
|
||||
},
|
||||
{
|
||||
"name": "ezyang/htmlpurifier",
|
||||
"version": "dev-master",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ezyang/htmlpurifier.git",
|
||||
"reference": "0d7328dbb282875f995026ba9f9a732bf0d6c669"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/0d7328dbb282875f995026ba9f9a732bf0d6c669",
|
||||
"reference": "0d7328dbb282875f995026ba9f9a732bf0d6c669",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"HTMLPurifier": "library/"
|
||||
},
|
||||
"files": [
|
||||
"library/HTMLPurifier.composer.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Edward Z. Yang",
|
||||
"email": "admin@htmlpurifier.org",
|
||||
"homepage": "http://ezyang.com"
|
||||
}
|
||||
],
|
||||
"description": "Standards compliant HTML filter written in PHP",
|
||||
"homepage": "http://htmlpurifier.org/",
|
||||
"keywords": [
|
||||
"html"
|
||||
],
|
||||
"time": "2015-05-05 20:43:49"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/container",
|
||||
"version": "5.0.x-dev",
|
||||
@@ -1079,33 +1123,27 @@
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/guzzle",
|
||||
"version": "5.2.0",
|
||||
"version": "5.3.x-dev",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/guzzle.git",
|
||||
"reference": "475b29ccd411f2fa8a408e64576418728c032cfa"
|
||||
"reference": "fd861570a9c3c28d98f418feea1f43f6268bdfa0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/475b29ccd411f2fa8a408e64576418728c032cfa",
|
||||
"reference": "475b29ccd411f2fa8a408e64576418728c032cfa",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/fd861570a9c3c28d98f418feea1f43f6268bdfa0",
|
||||
"reference": "fd861570a9c3c28d98f418feea1f43f6268bdfa0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/ringphp": "~1.0",
|
||||
"guzzlehttp/ringphp": "^1.1",
|
||||
"php": ">=5.4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-curl": "*",
|
||||
"phpunit/phpunit": "~4.0",
|
||||
"psr/log": "~1.0"
|
||||
"phpunit/phpunit": "^4.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "5.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GuzzleHttp\\": "src/"
|
||||
@@ -1133,7 +1171,7 @@
|
||||
"rest",
|
||||
"web service"
|
||||
],
|
||||
"time": "2015-01-28 01:03:29"
|
||||
"time": "2015-06-03 05:11:42"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/ringphp",
|
||||
@@ -1141,12 +1179,12 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/RingPHP.git",
|
||||
"reference": "2498ee848cd01639aecdcf3d5a257bace8665b7c"
|
||||
"reference": "9465032ac5d6beaa55f10923403e6e1c36018d9c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/RingPHP/zipball/9465032ac5d6beaa55f10923403e6e1c36018d9c",
|
||||
"reference": "2498ee848cd01639aecdcf3d5a257bace8665b7c",
|
||||
"reference": "9465032ac5d6beaa55f10923403e6e1c36018d9c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1164,7 +1202,7 @@
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0-dev"
|
||||
"dev-master": "1.1-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -1184,7 +1222,7 @@
|
||||
}
|
||||
],
|
||||
"description": "Provides a simple API and specification that abstracts away the details of HTTP into a single PHP function.",
|
||||
"time": "2015-05-01 04:57:09"
|
||||
"time": "2015-05-21 17:23:02"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/streams",
|
||||
@@ -1513,12 +1551,12 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
|
||||
"reference": "9ef4b8cbf3e839a44a9b375d8c59e109ac7aa020"
|
||||
"reference": "688b6a58acb19c1899dc887b1efb6403dc6dc0bd"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/3ee57a4fa5c2228da154f88239a921c8d54fcedd",
|
||||
"reference": "9ef4b8cbf3e839a44a9b375d8c59e109ac7aa020",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/688b6a58acb19c1899dc887b1efb6403dc6dc0bd",
|
||||
"reference": "688b6a58acb19c1899dc887b1efb6403dc6dc0bd",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1541,7 +1579,7 @@
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.1.x-dev"
|
||||
"dev-master": "2.2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -1567,7 +1605,7 @@
|
||||
"testing",
|
||||
"xunit"
|
||||
],
|
||||
"time": "2015-05-09 04:40:58"
|
||||
"time": "2015-06-06 08:31:47"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-file-iterator",
|
||||
@@ -2742,51 +2780,6 @@
|
||||
"description": "Symfony Yaml Component",
|
||||
"homepage": "https://symfony.com",
|
||||
"time": "2015-05-12 15:16:46"
|
||||
},
|
||||
{
|
||||
"name": "zendframework/zend-escaper",
|
||||
"version": "dev-master",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/zendframework/zend-escaper.git",
|
||||
"reference": "84e0c15195adfb3fe3efebb459defc65a1e0314d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/zendframework/zend-escaper/zipball/dea2d94022ce072831d3424fbd0fd5514e81eb75",
|
||||
"reference": "84e0c15195adfb3fe3efebb459defc65a1e0314d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.23"
|
||||
},
|
||||
"require-dev": {
|
||||
"fabpot/php-cs-fixer": "1.7.*",
|
||||
"phpunit/phpunit": "~4.0",
|
||||
"satooshi/php-coveralls": "dev-master"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.4-dev",
|
||||
"dev-develop": "2.5-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Zend\\Escaper\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"homepage": "https://github.com/zendframework/zend-escaper",
|
||||
"keywords": [
|
||||
"escaper",
|
||||
"zf2"
|
||||
],
|
||||
"time": "2015-05-04 16:35:14"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
@@ -2796,10 +2789,12 @@
|
||||
"tobscure/permissible": 20,
|
||||
"oyejorge/less.php": 20,
|
||||
"intervention/image": 20,
|
||||
"ezyang/htmlpurifier": 20,
|
||||
"psr/http-message": 20,
|
||||
"nikic/fast-route": 20
|
||||
},
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": ">=5.4.0"
|
||||
},
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import App from 'flarum/utils/app';
|
||||
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';
|
||||
@@ -12,6 +13,7 @@ import boot from 'flarum/initializers/boot';
|
||||
var app = new App();
|
||||
|
||||
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);
|
||||
|
@@ -5,21 +5,39 @@ 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-group', [
|
||||
m('input.form-control[type=email][name=email][placeholder=Email]', {value: this.email(), onchange: m.withAttr('value', this.email)})
|
||||
]),
|
||||
m('div.form-group', [
|
||||
m('button.btn.btn-primary.btn-block[type=submit]', 'Save Changes')
|
||||
])
|
||||
]
|
||||
body: 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')
|
||||
])
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,12 +51,13 @@ export default class ChangeEmailModal extends FormModal {
|
||||
|
||||
this.loading(true);
|
||||
app.session.user().save({ email: this.email() }).then(() => {
|
||||
this.hide();
|
||||
this.loading(false);
|
||||
this.success(true);
|
||||
this.alert(null);
|
||||
m.redraw();
|
||||
}, response => {
|
||||
this.loading(false);
|
||||
this.alert = new Alert({ type: 'warning', message: response.errors.map((error, k) => [error.detail, k < response.errors.length - 1 ? m('br') : '']) });
|
||||
m.redraw();
|
||||
this.$('[name='+response.errors[0].path+']').select();
|
||||
this.handleErrors(response.errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@ export default class ChangePasswordModal extends FormModal {
|
||||
body: [
|
||||
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]', 'Send Password Reset Email')
|
||||
m('button.btn.btn-primary.btn-block[type=submit]', {disabled: this.loading()}, 'Send Password Reset Email')
|
||||
])
|
||||
]
|
||||
});
|
||||
|
@@ -41,7 +41,7 @@ export default class ComposerBody extends Component {
|
||||
|
||||
focus() {
|
||||
this.ready(true);
|
||||
m.redraw();
|
||||
m.redraw(true);
|
||||
|
||||
this.$(':input:enabled:visible:first').focus();
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@ class Composer extends Component {
|
||||
// (which is set when the resizing handle is dragged), and the composer's
|
||||
// current state.
|
||||
this.computedHeight = computed('height', 'position', function(height, position) {
|
||||
if (position === Composer.PositionEnum.MINIMIZED || position === Composer.PositionEnum.HIDDEN) {
|
||||
if (position === Composer.PositionEnum.MINIMIZED) {
|
||||
return '';
|
||||
} else if (position === Composer.PositionEnum.FULLSCREEN) {
|
||||
return $(window).height();
|
||||
@@ -133,8 +133,6 @@ class Composer extends Component {
|
||||
}
|
||||
|
||||
render(anchorToBottom) {
|
||||
if (this.position() === this.oldPosition) { this.component.focus(); return; }
|
||||
|
||||
var $composer = this.$().stop(true);
|
||||
var oldHeight = $composer.is(':visible') ? $composer.outerHeight() : 0;
|
||||
|
||||
|
@@ -10,7 +10,7 @@ import ActionButton from 'flarum/components/action-button';
|
||||
*/
|
||||
export default class DiscussionComposer extends ComposerBody {
|
||||
constructor(props) {
|
||||
props.placeholder = props.placeholder || 'Write a post...';
|
||||
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';
|
||||
|
@@ -1,6 +1,7 @@
|
||||
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 humanTime from 'flarum/utils/human-time';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import abbreviateNumber from 'flarum/utils/abbreviate-number';
|
||||
@@ -8,6 +9,7 @@ import ActionButton from 'flarum/components/action-button';
|
||||
import DropdownButton from 'flarum/components/dropdown-button';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
import TerminalPost from 'flarum/components/terminal-post';
|
||||
import PostPreview from 'flarum/components/post-preview';
|
||||
import SubtreeRetainer from 'flarum/utils/subtree-retainer';
|
||||
|
||||
export default class DiscussionList extends Component {
|
||||
@@ -30,16 +32,26 @@ export default class DiscussionList extends Component {
|
||||
params[i] = this.props.params[i];
|
||||
}
|
||||
params.sort = this.sortMap()[params.sort];
|
||||
if (params.q) {
|
||||
params.include.push('relevantPosts', 'relevantPosts.discussion', 'relevantPosts.user');
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
willBeRedrawn() {
|
||||
this.subtrees.map(subtree => subtree.invalidate());
|
||||
}
|
||||
|
||||
sortMap() {
|
||||
return {
|
||||
recent: '-lastTime',
|
||||
replies: '-commentsCount',
|
||||
newest: '-startTime',
|
||||
oldest: '+startTime'
|
||||
};
|
||||
var map = {};
|
||||
if (this.props.params.q) {
|
||||
map.relevance = '';
|
||||
}
|
||||
map.recent = '-lastTime';
|
||||
map.replies = '-commentsCount';
|
||||
map.newest = '-startTime';
|
||||
map.oldest = '+startTime';
|
||||
return map;
|
||||
}
|
||||
|
||||
refresh() {
|
||||
@@ -117,18 +129,18 @@ export default class DiscussionList extends Component {
|
||||
}
|
||||
|
||||
view() {
|
||||
return m('div', [
|
||||
m('ul.discussions-list', [
|
||||
return m('div.discussion-list', [
|
||||
m('ul', [
|
||||
this.discussions().map(discussion => {
|
||||
var startUser = discussion.startUser();
|
||||
var isUnread = discussion.isUnread();
|
||||
var displayUnread = this.countType() !== 'replies' && isUnread;
|
||||
var jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1);
|
||||
var relevantPosts = this.props.params.q ? discussion.relevantPosts() : '';
|
||||
|
||||
var controls = discussion.controls(this).toArray();
|
||||
|
||||
var discussionRoute = app.route('discussion', { id: discussion.id(), slug: discussion.slug() });
|
||||
var active = m.route().substr(0, discussionRoute.length) === discussionRoute;
|
||||
var active = m.route.param('id') === discussion.id();
|
||||
|
||||
var subtree = this.subtrees[discussion.id()];
|
||||
return m('li.discussion-summary'+(isUnread ? '.unread' : '')+(active ? '.active' : ''), {
|
||||
@@ -153,13 +165,16 @@ export default class DiscussionList extends Component {
|
||||
]),
|
||||
m('ul.badges', listItems(discussion.badges().toArray())),
|
||||
m('a.main', {href: app.route('discussion.near', {id: discussion.id(), slug: discussion.slug(), near: jumpTo}), config: m.route}, [
|
||||
m('h3.title', discussion.title()),
|
||||
m('h3.title', highlight(discussion.title(), this.props.params.q)),
|
||||
m('ul.info', listItems(this.infoItems(discussion).toArray()))
|
||||
]),
|
||||
m('span.count', {onclick: this.markAsRead.bind(this, discussion)}, [
|
||||
abbreviateNumber(discussion[displayUnread ? 'unreadCount' : 'repliesCount']()),
|
||||
m('span.label', displayUnread ? 'unread' : 'replies')
|
||||
])
|
||||
]),
|
||||
(relevantPosts && relevantPosts.length)
|
||||
? m('div.relevant-posts', relevantPosts.map(post => PostPreview.component({post, highlight: this.props.params.q})))
|
||||
: ''
|
||||
]))
|
||||
})
|
||||
]),
|
||||
|
@@ -1,10 +1,9 @@
|
||||
import Component from 'flarum/component';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import PostStream from 'flarum/utils/post-stream';
|
||||
import DiscussionList from 'flarum/components/discussion-list';
|
||||
import DiscussionHero from 'flarum/components/discussion-hero';
|
||||
import StreamContent from 'flarum/components/stream-content';
|
||||
import StreamScrubber from 'flarum/components/stream-scrubber';
|
||||
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';
|
||||
@@ -22,24 +21,13 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
super(props);
|
||||
|
||||
this.discussion = m.prop();
|
||||
|
||||
// Set up the stream. The stream is an object that represents the posts in
|
||||
// a discussion, as they're displayed on the screen (i.e. missing posts
|
||||
// are condensed into "load more" gaps).
|
||||
this.stream = m.prop();
|
||||
|
||||
// Get the discussion. We may already have a copy of it in our store, so
|
||||
// we'll start off with that. If we do have a copy of the discussion, and
|
||||
// its posts relationship has been loaded (i.e. we've viewed this
|
||||
// discussion before), then we can proceed with displaying it immediately.
|
||||
// If not, we'll make an API request first.
|
||||
this.refresh();
|
||||
|
||||
if (app.cache.discussionList) {
|
||||
if (!(app.current instanceof DiscussionPage)) {
|
||||
app.cache.discussionList.subtrees.map(subtree => subtree.invalidate());
|
||||
} else {
|
||||
m.redraw.strategy('diff'); // otherwise pane redraws (killing retained subtrees) and mouseenter even is triggered so it doesn't hide
|
||||
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();
|
||||
@@ -66,7 +54,7 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
params() {
|
||||
return {
|
||||
near: this.currentNear,
|
||||
include: ['posts', 'posts.user']
|
||||
include: ['posts', 'posts.user', 'posts.user.groups']
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,25 +62,6 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
|
||||
*/
|
||||
setupDiscussion(discussion) {
|
||||
this.discussion(discussion);
|
||||
|
||||
var includedPosts = [];
|
||||
discussion.payload.included && discussion.payload.included.forEach(record => {
|
||||
if (record.type === 'posts' && (record.contentType !== 'comment' || record.contentHtml)) {
|
||||
includedPosts.push(record.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up the post stream for this discussion, and add all of the posts we
|
||||
// have loaded so far.
|
||||
this.stream(new PostStream(discussion));
|
||||
this.stream().addPosts(discussion.posts().filter(value => value && includedPosts.indexOf(value.id()) !== -1));
|
||||
this.streamContent = new StreamContent({
|
||||
stream: this.stream(),
|
||||
className: 'discussion-posts posts',
|
||||
positionChanged: this.positionChanged.bind(this)
|
||||
});
|
||||
|
||||
// Hold up there skippy! If the slug in the URL doesn't match up, we'll
|
||||
// redirect so we have the correct one.
|
||||
// Waiting on https://github.com/lhorie/mithril.js/issues/539
|
||||
@@ -104,11 +73,22 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
this.streamContent.goToNumber(this.currentNear, true);
|
||||
|
||||
this.discussion(discussion);
|
||||
app.setTitle(discussion.title());
|
||||
|
||||
this.trigger('loaded');
|
||||
var includedPosts = [];
|
||||
discussion.payload.included && discussion.payload.included.forEach(record => {
|
||||
if (record.type === 'posts' && (record.contentType !== 'comment' || record.contentHtml)) {
|
||||
includedPosts.push(app.store.getById('posts', record.id));
|
||||
}
|
||||
});
|
||||
includedPosts.sort((a, b) => a.id() - b.id());
|
||||
|
||||
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) {
|
||||
@@ -134,7 +114,7 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
if (m.route.param('id') == discussion.id()) {
|
||||
e.preventDefault();
|
||||
if (m.route.param('near') != this.currentNear) {
|
||||
this.streamContent.goToNumber(m.route.param('near'));
|
||||
this.stream.goToNumber(m.route.param('near') || 1);
|
||||
}
|
||||
this.currentNear = null;
|
||||
return;
|
||||
@@ -160,7 +140,7 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
m('nav.discussion-nav', [
|
||||
m('ul', listItems(this.sidebarItems().toArray()))
|
||||
]),
|
||||
this.streamContent.view()
|
||||
this.stream.view()
|
||||
])
|
||||
] : LoadingIndicator.component({className: 'loading-indicator-block'}))
|
||||
]);
|
||||
@@ -219,8 +199,8 @@ export default class DiscussionPage extends mixin(Component, evented) {
|
||||
);
|
||||
|
||||
items.add('scrubber',
|
||||
StreamScrubber.component({
|
||||
streamContent: this.streamContent,
|
||||
PostScrubber.component({
|
||||
stream: this.stream,
|
||||
wrapperClass: 'title-control'
|
||||
})
|
||||
);
|
||||
|
36
js/forum/src/components/discussions-search-results.js
Normal file
36
js/forum/src/components/discussions-search-results.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
|
||||
export default class DiscussionsSearchResults {
|
||||
constructor() {
|
||||
this.results = {};
|
||||
}
|
||||
|
||||
search(string) {
|
||||
this.results[string] = [];
|
||||
return app.store.find('discussions', {q: string, page: {limit: 3}, include: 'relevantPosts,relevantPosts.discussion'}).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 post = discussion.relevantPosts()[0];
|
||||
return m('li.discussion-search-result', {'data-index': 'discussions'+discussion.id()},
|
||||
m('a', { href: app.route.discussion(discussion, post.number()), config: m.route },
|
||||
m('div.title', highlight(discussion.title(), string)),
|
||||
m('div.excerpt', highlight(post.contentPlain().substring(0, 100), string))
|
||||
)
|
||||
);
|
||||
}) : ''
|
||||
];
|
||||
}
|
||||
}
|
@@ -2,6 +2,7 @@ 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
|
||||
@@ -23,7 +24,7 @@ export default class EditComposer extends ComposerBody {
|
||||
var post = this.props.post;
|
||||
|
||||
items.add('title', m('h3', [
|
||||
'Editing ',
|
||||
icon('pencil'), ' ',
|
||||
m('a', {href: app.route.discussion(post.discussion(), post.number()), config: m.route}, 'Post #'+post.number()),
|
||||
' in ', post.discussion().title()
|
||||
]));
|
||||
|
@@ -10,7 +10,7 @@ export default class EventPost extends Post {
|
||||
var user = post.user();
|
||||
|
||||
attrs = attrs || {};
|
||||
attrs.className = 'event-post post-'+dasherize(post.contentType())+' '+(attrs.className || '');
|
||||
attrs.className = 'event-post '+dasherize(post.contentType())+'-post '+(attrs.className || '');
|
||||
|
||||
return super.view([
|
||||
icon(iconName+' post-icon'),
|
||||
|
@@ -21,13 +21,13 @@ export default class ForgotPasswordModal extends FormModal {
|
||||
title: 'Forgot Password',
|
||||
body: this.success()
|
||||
? [
|
||||
m('p.help-text', 'OK, 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. Yeah, sometimes we get put through to spam - can you believe it?!'),
|
||||
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', 'Forgot your password? Don\'t worry, it happens all the time. Simply enter your email address and we\'ll send you instructions on how to set up a new one.'),
|
||||
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()})
|
||||
]),
|
||||
@@ -57,12 +57,11 @@ export default class ForgotPasswordModal extends FormModal {
|
||||
}).then(response => {
|
||||
this.loading(false);
|
||||
this.success(true);
|
||||
this.alert = null;
|
||||
this.alert(null);
|
||||
m.redraw();
|
||||
}, response => {
|
||||
this.loading(false);
|
||||
m.redraw();
|
||||
this.ready();
|
||||
this.handleErrors(response.errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -7,13 +7,14 @@ export default class FormModal extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.alert = null;
|
||||
this.alert = m.prop();
|
||||
this.loading = m.prop(false);
|
||||
}
|
||||
|
||||
view(options) {
|
||||
if (this.alert) {
|
||||
this.alert.props.dismissible = false;
|
||||
var alert = this.alert();
|
||||
if (alert) {
|
||||
alert.props.dismissible = false;
|
||||
}
|
||||
|
||||
return m('div.modal-dialog', {className: options.className, config: this.element}, [
|
||||
@@ -21,7 +22,7 @@ export default class FormModal extends Component {
|
||||
m('a[href=javascript:;].btn.btn-icon.btn-link.close.back-control', {onclick: this.hide.bind(this)}, icon('times')),
|
||||
m('form', {onsubmit: this.onsubmit.bind(this)}, [
|
||||
m('div.modal-header', m('h3.title-control', options.title)),
|
||||
this.alert ? m('div.modal-alert', this.alert.view()) : '',
|
||||
alert ? m('div.modal-alert', alert) : '',
|
||||
m('div.modal-body', [
|
||||
m('div.form-centered', options.body)
|
||||
]),
|
||||
@@ -39,4 +40,19 @@ export default class FormModal extends Component {
|
||||
hide() {
|
||||
app.modal.close();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -16,6 +16,8 @@ export default class HeaderSecondary extends Component {
|
||||
items() {
|
||||
var items = new ItemList();
|
||||
|
||||
items.add('search', app.search.view());
|
||||
|
||||
if (app.session.user()) {
|
||||
items.add('notifications', UserNotifications.component({ user: app.session.user() }))
|
||||
items.add('user', UserDropdown.component({ user: app.session.user() }));
|
||||
|
@@ -17,12 +17,32 @@ import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
import DropdownSelect from 'flarum/components/dropdown-select';
|
||||
|
||||
export default class IndexPage extends Component {
|
||||
/**
|
||||
* @param {Object} props
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
var params = this.params();
|
||||
|
||||
if (app.cache.discussionList) {
|
||||
app.cache.discussionList.subtrees.map(subtree => subtree.invalidate());
|
||||
// The discussion list component is stored in the app's cache so that it
|
||||
// can persist across interfaces. Since we will soon be redrawing the
|
||||
// discussion list from scratch, we need to invalidate the component's
|
||||
// subtree cache to ensure that it re-constructs the view.
|
||||
app.cache.discussionList.willBeRedrawn();
|
||||
|
||||
// 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.
|
||||
Object.keys(params).some(key => {
|
||||
if (app.cache.discussionList.props.params[key] !== params[key]) {
|
||||
app.cache.discussionList = null;
|
||||
@@ -30,151 +50,53 @@ export default class IndexPage extends Component {
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!app.cache.discussionList) {
|
||||
app.cache.discussionList = new DiscussionList({params});
|
||||
}
|
||||
|
||||
if (app.current instanceof DiscussionPage) {
|
||||
this.lastDiscussion = app.current.discussion();
|
||||
if (!app.cache.discussionList) {
|
||||
app.cache.discussionList = new DiscussionList({ params });
|
||||
}
|
||||
|
||||
app.history.push('index');
|
||||
app.current = this;
|
||||
}
|
||||
|
||||
onunload() {
|
||||
app.cache.scrollTop = $(window).scrollTop();
|
||||
app.composer.minimize();
|
||||
}
|
||||
|
||||
/**
|
||||
Params that stick between filter changes
|
||||
*/
|
||||
stickyParams() {
|
||||
return {
|
||||
sort: m.route.param('sort'),
|
||||
show: m.route.param('show'),
|
||||
q: m.route.param('q')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Params which are passed to the DiscussionList
|
||||
*/
|
||||
params() {
|
||||
var params = this.stickyParams();
|
||||
params.filter = m.route.param('filter');
|
||||
return params;
|
||||
}
|
||||
|
||||
reorder(sort) {
|
||||
var params = this.params();
|
||||
if (sort === 'recent') {
|
||||
delete params.sort;
|
||||
} else {
|
||||
params.sort = sort;
|
||||
}
|
||||
m.route(app.route(this.props.routeName, params));
|
||||
}
|
||||
|
||||
/**
|
||||
Render the component.
|
||||
|
||||
@method view
|
||||
@return void
|
||||
* Render the component.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
view() {
|
||||
var sortOptions = {};
|
||||
for (var i in app.cache.discussionList.sortMap()) {
|
||||
sortOptions[i] = i.substr(0, 1).toUpperCase()+i.substr(1);
|
||||
}
|
||||
|
||||
return m('div.index-area', {config: this.onload.bind(this)}, [
|
||||
WelcomeHero.component(),
|
||||
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('div.index-toolbar-view', [
|
||||
SelectInput.component({
|
||||
options: sortOptions,
|
||||
value: m.route.param('sort'),
|
||||
onchange: this.reorder.bind(this)
|
||||
}),
|
||||
]),
|
||||
m('div.index-toolbar-action', [
|
||||
ActionButton.component({
|
||||
title: 'Mark All as Read',
|
||||
icon: 'check',
|
||||
className: 'control-markAllAsRead btn btn-default btn-icon',
|
||||
onclick: this.markAllAsRead.bind(this)
|
||||
})
|
||||
])
|
||||
m('ul.index-toolbar-view', listItems(this.viewItems().toArray())),
|
||||
m('ul.index-toolbar-action', listItems(this.actionItems().toArray()))
|
||||
]),
|
||||
app.cache.discussionList.view()
|
||||
])
|
||||
])
|
||||
])
|
||||
}
|
||||
|
||||
onload(element, isInitialized, context) {
|
||||
if (isInitialized) { return; }
|
||||
|
||||
this.element(element);
|
||||
|
||||
$('body').addClass('index-page');
|
||||
context.onunload = function() {
|
||||
$('body').removeClass('index-page');
|
||||
}
|
||||
|
||||
|
||||
var heroHeight = this.$('.hero').css('height', '').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 (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.setTitle('');
|
||||
}
|
||||
|
||||
newDiscussion() {
|
||||
if (app.session.user()) {
|
||||
app.composer.load(new DiscussionComposer({ user: app.session.user() }));
|
||||
app.composer.show();
|
||||
return true;
|
||||
} else {
|
||||
app.modal.show(new LoginModal({
|
||||
message: 'You must be logged in to do that.',
|
||||
callback: this.newDiscussion.bind(this)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
app.session.user().save({ readTime: new Date() });
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
Build an item list for the sidebar of the index page. By default this is a
|
||||
"New Discussion" button, and then a DropdownSelect component containing a
|
||||
list of navigation items (see this.navItems).
|
||||
* Get the component to display as the hero.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
hero() {
|
||||
return WelcomeHero.component();
|
||||
}
|
||||
|
||||
@return {ItemList}
|
||||
/**
|
||||
* Build an item list for the sidebar of the index page. By default this is a
|
||||
* "New Discussion" button, and then a DropdownSelect component containing a
|
||||
* list of navigation items (see this.navItems).
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
sidebarItems() {
|
||||
var items = new ItemList();
|
||||
@@ -200,14 +122,14 @@ export default class IndexPage extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
Build an item list for the navigation in the sidebar of the index page. By
|
||||
default this is just the 'All Discussions' link.
|
||||
|
||||
@return {ItemList}
|
||||
* Build an item list for the navigation in the sidebar of the index page. By
|
||||
* default this is just the 'All Discussions' link.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
navItems() {
|
||||
var items = new ItemList();
|
||||
var params = {sort: m.route.param('sort')};
|
||||
var params = this.stickyParams();
|
||||
|
||||
items.add('allDiscussions',
|
||||
IndexNavItem.component({
|
||||
@@ -221,12 +143,182 @@ export default class IndexPage extends Component {
|
||||
}
|
||||
|
||||
/**
|
||||
Setup the sidebar DOM element to be affixed to the top of the viewport
|
||||
using Bootstrap's affix plugin.
|
||||
* Build an item list for the part of the toolbar which is concerned with how
|
||||
* the results are displayed. By default this is just a select box to change
|
||||
* the way discussions are sorted.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
viewItems() {
|
||||
var items = new ItemList();
|
||||
|
||||
@param {DOMElement} element
|
||||
@param {Boolean} isInitialized
|
||||
@return {void}
|
||||
var sortOptions = {};
|
||||
for (var i in app.cache.discussionList.sortMap()) {
|
||||
sortOptions[i] = i.substr(0, 1).toUpperCase()+i.substr(1);
|
||||
}
|
||||
|
||||
items.add('sort',
|
||||
SelectInput.component({
|
||||
options: sortOptions,
|
||||
value: this.params.sort,
|
||||
onchange: this.reorder.bind(this)
|
||||
})
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list for the part of the toolbar which is about taking action
|
||||
* on the results. By default this is just a "mark all as read" button.
|
||||
*
|
||||
* @return {ItemList}
|
||||
*/
|
||||
actionItems() {
|
||||
var items = new ItemList();
|
||||
|
||||
if (app.session.user()) {
|
||||
items.add('markAllAsRead',
|
||||
ActionButton.component({
|
||||
title: 'Mark All as Read',
|
||||
icon: 'check',
|
||||
className: 'control-markAllAsRead btn btn-default btn-icon',
|
||||
onclick: this.markAllAsRead.bind(this)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current search query, if any. This is implemented to activate
|
||||
* the search box in the header.
|
||||
*
|
||||
* @see module:flarum/components/search-box
|
||||
* @return {String}
|
||||
*/
|
||||
searching() {
|
||||
return this.params().q;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
clearSearch() {
|
||||
var params = this.params();
|
||||
delete params.q;
|
||||
m.route(app.route('index', params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to
|
||||
* @param {[type]} sort [description]
|
||||
* @return {[type]}
|
||||
*/
|
||||
reorder(sort) {
|
||||
var 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL parameters that stick between filter changes.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
stickyParams() {
|
||||
return {
|
||||
sort: m.route.param('sort'),
|
||||
q: m.route.param('q')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get parameters to pass to the DiscussionList component.
|
||||
*
|
||||
* @return {Object}
|
||||
*/
|
||||
params() {
|
||||
var params = this.stickyParams();
|
||||
|
||||
params.filter = m.route.param('filter');
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
};
|
||||
|
||||
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; }
|
||||
@@ -243,4 +335,28 @@ export default class IndexPage extends Component {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the composer for a new discussion.
|
||||
*
|
||||
* @todo return a promise
|
||||
* @return void
|
||||
*/
|
||||
newDiscussion() {
|
||||
if (app.session.user()) {
|
||||
app.composer.load(new DiscussionComposer({ user: app.session.user() }));
|
||||
app.composer.show();
|
||||
return true;
|
||||
}
|
||||
app.modal.show(new LoginModal({ onlogin: this.newDiscussion.bind(this) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all discussions as read.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
markAllAsRead() {
|
||||
app.session.user().save({ readTime: new Date() });
|
||||
}
|
||||
};
|
||||
|
@@ -3,6 +3,7 @@ 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 {
|
||||
@@ -29,7 +30,11 @@ export default class LoginModal extends FormModal {
|
||||
])
|
||||
],
|
||||
footer: [
|
||||
m('p.forgot-password-link', m('a[href=javascript:;]', {onclick: () => app.modal.show(new ForgotPasswordModal({email: this.email()}))}, 'Forgot password?')),
|
||||
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: () => {
|
||||
@@ -50,12 +55,26 @@ export default class LoginModal extends FormModal {
|
||||
onsubmit(e) {
|
||||
e.preventDefault();
|
||||
this.loading(true);
|
||||
app.session.login(this.email(), this.password()).then(() => {
|
||||
var email = this.email();
|
||||
var password = this.password();
|
||||
|
||||
app.session.login(email, password).then(() => {
|
||||
this.hide();
|
||||
this.props.callback && this.props.callback();
|
||||
this.props.onlogin && this.props.onlogin();
|
||||
}, response => {
|
||||
this.loading(false);
|
||||
this.alert = new Alert({ type: 'warning', message: 'Your login details were incorrect.' });
|
||||
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();
|
||||
});
|
||||
|
11
js/forum/src/components/post-loading.js
Normal file
11
js/forum/src/components/post-loading.js
Normal file
@@ -0,0 +1,11 @@
|
||||
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'))
|
||||
);
|
||||
}
|
||||
}
|
@@ -2,12 +2,27 @@ 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';
|
||||
|
||||
export default class PostPreview extends Component {
|
||||
view() {
|
||||
var post = this.props.post;
|
||||
var user = post.user();
|
||||
|
||||
var excerpt = post.contentPlain();
|
||||
var start = 0;
|
||||
|
||||
if (highlight) {
|
||||
var regexp = new RegExp(this.props.highlight, 'gi');
|
||||
start = Math.max(0, excerpt.search(regexp) - 100);
|
||||
}
|
||||
|
||||
excerpt = (start > 0 ? '...' : '')+excerpt.substring(start, start + 200)+(excerpt.length > start + 200 ? '...' : '');
|
||||
|
||||
if (highlight) {
|
||||
excerpt = highlight(excerpt, regexp);
|
||||
}
|
||||
|
||||
return m('a.post-preview', {
|
||||
href: app.route.post(post),
|
||||
config: m.route,
|
||||
@@ -16,7 +31,7 @@ export default class PostPreview extends Component {
|
||||
avatar(user), ' ',
|
||||
username(user), ' ',
|
||||
humanTime(post.time()), ' ',
|
||||
post.excerpt()
|
||||
excerpt
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
@@ -3,30 +3,24 @@ 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 StreamScrubber extends Component {
|
||||
export default class PostScrubber extends Component {
|
||||
/**
|
||||
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
var streamContent = this.props.streamContent;
|
||||
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.
|
||||
streamContent.on('loadingIndex', this.handlers.loadingIndex = this.loadingIndex.bind(this));
|
||||
streamContent.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this));
|
||||
|
||||
/**
|
||||
Disable the scrubber if the stream's initial content isn't loaded, or
|
||||
if all of the posts in the discussion are visible in the viewport.
|
||||
*/
|
||||
this.disabled = () => !streamContent.loaded() || this.visible() >= this.count();
|
||||
stream.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this));
|
||||
|
||||
/**
|
||||
The integer index of the last item that is visible in the viewport. This
|
||||
@@ -36,7 +30,7 @@ export default class StreamScrubber extends Component {
|
||||
return Math.min(count, Math.ceil(Math.max(0, index) + visible));
|
||||
});
|
||||
|
||||
this.count = () => this.props.streamContent.props.stream.count();
|
||||
this.count = () => this.props.stream.count();
|
||||
this.index = m.prop(-1);
|
||||
this.visible = m.prop(1);
|
||||
this.description = m.prop();
|
||||
@@ -53,42 +47,72 @@ export default class StreamScrubber extends Component {
|
||||
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 streamContent = this.props.streamContent;
|
||||
var stream = this.props.stream;
|
||||
var unreadCount = this.props.stream.discussion.unreadCount();
|
||||
var unreadPercent = unreadCount / this.count();
|
||||
|
||||
// @todo clean up duplication
|
||||
return m('div.stream-scrubber.dropdown'+(this.disabled() ? '.disabled' : ''), {config: this.onload.bind(this)}, [
|
||||
m('a.btn.btn-default.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', [
|
||||
m('span.index', retain || this.visibleIndex()), ' of ', m('span.count', this.count()), ' posts ',
|
||||
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: streamContent.goToFirst.bind(streamContent)}, [icon('angle-double-up'), ' Original Post']),
|
||||
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 || this.visibleIndex()), ' of ', m('span.count', this.count()), ' posts']),
|
||||
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')
|
||||
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: streamContent.goToLast.bind(streamContent)}, [icon('angle-double-down'), ' Now'])
|
||||
m('a.scrubber-last[href=javascript:;]', {onclick: () => {
|
||||
stream.goToLast();
|
||||
this.index(stream.count());
|
||||
this.renderScrollbar(true);
|
||||
}}, [icon('angle-double-down'), ' Now'])
|
||||
])
|
||||
])
|
||||
])
|
||||
}
|
||||
|
||||
onscroll(top) {
|
||||
var streamContent = this.props.streamContent;
|
||||
var stream = this.props.stream;
|
||||
|
||||
if (!streamContent.active() || !streamContent.$()) { return; }
|
||||
if (stream.paused() || !stream.$()) { return; }
|
||||
|
||||
this.update(top);
|
||||
this.renderScrollbar();
|
||||
@@ -99,10 +123,10 @@ export default class StreamScrubber extends Component {
|
||||
current scroll position.
|
||||
*/
|
||||
update(top) {
|
||||
var streamContent = this.props.streamContent;
|
||||
var stream = this.props.stream;
|
||||
|
||||
var $window = $(window);
|
||||
var marginTop = streamContent.getMarginTop();
|
||||
var marginTop = stream.getMarginTop();
|
||||
var scrollTop = $window.scrollTop() + marginTop;
|
||||
var windowHeight = $window.height() - marginTop;
|
||||
|
||||
@@ -110,8 +134,8 @@ export default class StreamScrubber extends Component {
|
||||
// properties to a 'default' state. These values reflect what would be
|
||||
// seen if the browser were scrolled right up to the top of the page,
|
||||
// and the viewport had a height of 0.
|
||||
var $items = streamContent.$('.item');
|
||||
var index = $items.first().data('end') - 1;
|
||||
var $items = stream.$('> .item');
|
||||
var index = $items.first().data('index');
|
||||
var visible = 0;
|
||||
var period = '';
|
||||
|
||||
@@ -128,7 +152,7 @@ export default class StreamScrubber extends Component {
|
||||
// loop.
|
||||
if (top + height < scrollTop) {
|
||||
visible = (top + height - scrollTop) / height;
|
||||
index = parseFloat($this.data('end')) + 1 - visible;
|
||||
index = parseFloat($this.data('index')) + 1 - visible;
|
||||
return;
|
||||
}
|
||||
if (top > scrollTop + windowHeight) {
|
||||
@@ -136,14 +160,10 @@ export default class StreamScrubber extends Component {
|
||||
}
|
||||
|
||||
// If the bottom half of this item is visible at the top of the
|
||||
// viewport, then add the visible proportion to the visible
|
||||
// counter, and set the scrollbar index to whatever the visible
|
||||
// proportion represents. For example, if a gap represents indexes
|
||||
// 0-9, and the bottom 50% of the gap is visible in the viewport,
|
||||
// then the scrollbar index will be 5.
|
||||
// viewport
|
||||
if (top <= scrollTop && top + height > scrollTop) {
|
||||
visible = (top + height - scrollTop) / height;
|
||||
index = parseFloat($this.data('end')) + 1 - visible;
|
||||
index = parseFloat($this.data('index')) + 1 - visible;
|
||||
}
|
||||
|
||||
// If the top half of this item is visible at the bottom of the
|
||||
@@ -188,69 +208,30 @@ export default class StreamScrubber extends Component {
|
||||
// so that it fills the height of the sidebar.
|
||||
$(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
|
||||
|
||||
var self = this;
|
||||
|
||||
// When any part of the whole scrollbar is clicked, we want to jump to
|
||||
// that position.
|
||||
this.$('.scrubber-scrollbar')
|
||||
.bind('click touchstart', function(e) {
|
||||
if (!self.props.streamContent.active()) { return; }
|
||||
.bind('click', this.onclick.bind(this))
|
||||
|
||||
// Calculate the index which we want to jump to based on the
|
||||
// click position.
|
||||
// 1. Get the offset of the click from the top of the
|
||||
// scrollbar, as a percentage of the scrollbar's height.
|
||||
var $this = $(this);
|
||||
var offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $this.offset().top + $('body').scrollTop();
|
||||
var offsetPercent = offsetPixels / $this.outerHeight() * 100;
|
||||
|
||||
// 2. We want the handle of the scrollbar to end up centered
|
||||
// on the click position. Thus, we calculate the height of
|
||||
// the handle in percent and use that to find a new
|
||||
// offset percentage.
|
||||
offsetPercent = offsetPercent - parseFloat($this.find('.scrubber-slider')[0].style.height) / 2;
|
||||
|
||||
// 3. Now we can convert the percentage into an index, and
|
||||
// tell the stream-content component to jump to that index.
|
||||
var offsetIndex = offsetPercent / self.percentPerPost().index;
|
||||
offsetIndex = Math.max(0, Math.min(self.count() - 1, offsetIndex));
|
||||
self.props.streamContent.goToIndex(Math.floor(offsetIndex));
|
||||
|
||||
self.$().removeClass('open');
|
||||
});
|
||||
|
||||
// Now we want to make the scrollbar handle draggable. Let's start by
|
||||
// preventing default browser events from messing things up.
|
||||
this.$('.scrubber-scrollbar')
|
||||
.css({
|
||||
cursor: 'pointer',
|
||||
'user-select': 'none'
|
||||
})
|
||||
.bind('dragstart mousedown touchstart', function(e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
// 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.handle = null;
|
||||
|
||||
this.$('.scrubber-slider')
|
||||
.css('cursor', 'move')
|
||||
.bind('mousedown touchstart', function(e) {
|
||||
self.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
||||
self.indexStart = self.index();
|
||||
self.handle = $(this);
|
||||
self.props.streamContent.paused(true);
|
||||
$('body').css('cursor', 'move');
|
||||
})
|
||||
.bind('mousedown touchstart', this.onmousedown.bind(this))
|
||||
|
||||
// Exempt the scrollbar handle from the 'jump to' click event.
|
||||
.click(function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
.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
|
||||
@@ -264,8 +245,7 @@ export default class StreamScrubber extends Component {
|
||||
ondestroy() {
|
||||
this.scrollListener.stop();
|
||||
|
||||
this.props.streamContent.off('loadingIndex', this.handlers.loadingIndex);
|
||||
this.props.streamContent.off('unpaused', this.handlers.unpaused);
|
||||
this.props.stream.off('unpaused', this.handlers.unpaused);
|
||||
|
||||
$(window)
|
||||
.off('resize', this.handlers.onresize);
|
||||
@@ -286,8 +266,7 @@ export default class StreamScrubber extends Component {
|
||||
var visible = this.visible();
|
||||
|
||||
var $scrubber = this.$();
|
||||
$scrubber.find('.index').text(this.visibleIndex());
|
||||
// $scrubber.find('.count').text(count);
|
||||
$scrubber.find('.index').text(formatNumber(this.visibleIndex()));
|
||||
$scrubber.find('.description').text(this.description());
|
||||
$scrubber.toggleClass('disabled', this.disabled());
|
||||
|
||||
@@ -332,16 +311,7 @@ export default class StreamScrubber extends Component {
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
When the stream-content component begins loading posts at a certain
|
||||
index, we want our scrubber scrollbar to jump to that position.
|
||||
*/
|
||||
loadingIndex(index) {
|
||||
this.index(index);
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
onresize(event) {
|
||||
onresize() {
|
||||
this.scrollListener.update(true);
|
||||
|
||||
// Adjust the height of the scrollbar so that it fills the height of
|
||||
@@ -350,81 +320,68 @@ export default class StreamScrubber extends Component {
|
||||
scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - parseInt($('.global-page').css('padding-bottom')));
|
||||
}
|
||||
|
||||
onmousemove(event) {
|
||||
if (! this.handle) { return; }
|
||||
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 = (event.clientY || event.originalEvent.touches[0].clientY) - this.mouseStart;
|
||||
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();
|
||||
|
||||
if (! this.$().is('.open')) {
|
||||
this.scrollToIndex(newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
onmouseup(event) {
|
||||
if (!this.handle) { return; }
|
||||
onmouseup(e) {
|
||||
if (!this.dragging) { return; }
|
||||
this.mouseStart = 0;
|
||||
this.indexStart = 0;
|
||||
this.handle = null;
|
||||
this.dragging = false;
|
||||
$('body').css('cursor', '');
|
||||
|
||||
if (this.$().is('.open')) {
|
||||
this.scrollToIndex(this.index());
|
||||
this.$().removeClass('open');
|
||||
}
|
||||
this.$().removeClass('open');
|
||||
|
||||
// If the index we've landed on is in a gap, then tell the stream-
|
||||
// content that we want to load those posts.
|
||||
var intIndex = Math.floor(this.index());
|
||||
if (!this.props.streamContent.props.stream.findNearestToIndex(intIndex).post) {
|
||||
this.props.streamContent.goToIndex(intIndex);
|
||||
} else {
|
||||
this.props.streamContent.paused(false);
|
||||
}
|
||||
this.props.stream.goToIndex(intIndex);
|
||||
this.renderScrollbar(true);
|
||||
}
|
||||
|
||||
/**
|
||||
Instantly scroll to a certain index in the discussion. The index doesn't
|
||||
have to be an integer; any fraction of a post will be scrolled to.
|
||||
*/
|
||||
scrollToIndex(index) {
|
||||
var streamContent = this.props.streamContent;
|
||||
onclick(e) {
|
||||
// Calculate the index which we want to jump to based on the click position.
|
||||
|
||||
index = Math.min(index, this.count() - 1);
|
||||
// 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;
|
||||
|
||||
// Find the item for this index, whether it's a post corresponding to
|
||||
// the index, or a gap which the index is within.
|
||||
var indexFloor = Math.max(0, Math.floor(index));
|
||||
var $nearestItem = streamContent.findNearestToIndex(indexFloor);
|
||||
// 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;
|
||||
|
||||
// Calculate the position of this item so that we can scroll to it. If
|
||||
// the item is a gap, then we will mark it as 'active' to indicate to
|
||||
// the user that it will expand if they release their mouse.
|
||||
// Otherwise, we will add a proportion of the item's height onto the
|
||||
// scroll position.
|
||||
var pos = $nearestItem.offset().top - streamContent.getMarginTop();
|
||||
if ($nearestItem.is('.gap')) {
|
||||
$nearestItem.addClass('active');
|
||||
} else {
|
||||
if (index >= 0) {
|
||||
pos += $nearestItem.outerHeight(true) * (index - indexFloor);
|
||||
} else {
|
||||
pos += $nearestItem.offset().top * index;
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
|
||||
// Remove the 'active' class from other gaps.
|
||||
streamContent.$().find('.gap').not($nearestItem).removeClass('active');
|
||||
|
||||
$('html, body').scrollTop(pos);
|
||||
this.$().removeClass('open');
|
||||
}
|
||||
}
|
474
js/forum/src/components/post-stream.js
Normal file
474
js/forum/src/components/post-stream.js
Normal file
@@ -0,0 +1,474 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
Update the stream to reflect any posts that have been added/removed from the
|
||||
discussion.
|
||||
*/
|
||||
sync() {
|
||||
var addedPosts = this.discussion.addedPosts();
|
||||
if (addedPosts) addedPosts.forEach(this.pushPost.bind(this));
|
||||
this.discussion.pushData({links: {addedPosts: null}});
|
||||
|
||||
var removedPosts = this.discussion.removedPosts();
|
||||
if (removedPosts) removedPosts.forEach(this.removePost.bind(this));
|
||||
this.discussion.pushData({removedPosts: null});
|
||||
}
|
||||
|
||||
/**
|
||||
Add a post to the end of the stream. Nothing will be done if the end of the
|
||||
stream is not visible.
|
||||
*/
|
||||
pushPost(post) {
|
||||
if (this.visibleEnd >= this.count() - 1) {
|
||||
this.posts.push(post);
|
||||
this.visibleEnd++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Search for and remove a specific post from the stream. Nothing will be done
|
||||
if the post is not visible.
|
||||
*/
|
||||
removePost(id) {
|
||||
this.posts.some((item, i) => {
|
||||
if (item && item.id() == id) {
|
||||
this.posts.splice(i, 1);
|
||||
this.visibleEnd--;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
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.posts = posts;
|
||||
this.visibleStart = this.discussion.postIds().indexOf(posts[0].id());
|
||||
this.visibleEnd = this.visibleStart + posts.length;
|
||||
}
|
||||
|
||||
/**
|
||||
Clear the stream and fill it with placeholder posts.
|
||||
*/
|
||||
clear(start, end) {
|
||||
this.visibleStart = start || 0;
|
||||
this.visibleEnd = end || this.constructor.loadCount;
|
||||
this.posts = [];
|
||||
for (var i = this.visibleStart; i < this.visibleEnd; i++) {
|
||||
this.posts.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
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;
|
||||
}
|
||||
|
||||
return m('div.discussion-posts.posts', {config: this.onload.bind(this)},
|
||||
this.posts.map((post, i) => {
|
||||
var content;
|
||||
var attributes = {};
|
||||
attributes['data-index'] = attributes.key = this.visibleStart + i;
|
||||
|
||||
if (post) {
|
||||
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();
|
||||
} else {
|
||||
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.visibleEnd === this.count() &&
|
||||
(!app.session.user() || this.discussion.canReply()) &&
|
||||
!app.composingReplyTo(this.discussion)
|
||||
? m('div.item', 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 = viewportHeight;
|
||||
|
||||
if (this.visibleStart > 0) {
|
||||
var $item = this.$('.item[data-index='+this.visibleStart+']');
|
||||
|
||||
if ($item.offset().top > viewportTop - loadAheadDistance) {
|
||||
this.loadPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.visibleEnd < this.count()) {
|
||||
var $item = this.$('.item[data-index='+(this.visibleEnd - 1)+']');
|
||||
|
||||
if ($item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
|
||||
this.loadNext();
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(this.calculatePositionTimeout);
|
||||
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 500);
|
||||
}
|
||||
|
||||
/**
|
||||
Load the next page of posts.
|
||||
*/
|
||||
loadNext() {
|
||||
var start = this.visibleEnd;
|
||||
var end = this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount);
|
||||
|
||||
for (var i = start; i < end; i++) {
|
||||
this.posts.push(null);
|
||||
}
|
||||
|
||||
// If the posts which are two pages back from the page we're currently
|
||||
// loading still haven't loaded, we can assume that the user is scrolling
|
||||
// pretty fast. Thus, we will unload them.
|
||||
var twoPagesAway = start - this.constructor.loadCount * 2;
|
||||
if (twoPagesAway >= 0 && !this.posts[twoPagesAway - this.visibleStart]) {
|
||||
this.posts.splice(0, twoPagesAway + this.constructor.loadCount - this.visibleStart);
|
||||
this.visibleStart = twoPagesAway + this.constructor.loadCount;
|
||||
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);
|
||||
|
||||
for (var i = start; i < end; i++) {
|
||||
this.posts.unshift(null);
|
||||
}
|
||||
|
||||
// If the posts which are two pages back from the page we're currently
|
||||
// loading still haven't loaded, we can assume that the user is scrolling
|
||||
// pretty fast. Thus, we will unload them.
|
||||
var twoPagesAway = start + this.constructor.loadCount * 2;
|
||||
if (twoPagesAway <= this.count() && !this.posts[twoPagesAway - this.visibleStart]) {
|
||||
this.posts.splice(twoPagesAway - this.visibleStart);
|
||||
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(this.$('.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) {
|
||||
return app.store.find('posts', this.discussion.postIds().slice(start, end)).then(posts => {
|
||||
if (start < this.visibleStart || end > this.visibleEnd) return;
|
||||
|
||||
this.posts.splice.apply(this.posts, [start - this.visibleStart, end - start].concat(posts));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
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.number() == number)) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
this.clear();
|
||||
|
||||
return app.store.find('posts', {
|
||||
discussions: this.discussion.id(),
|
||||
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);
|
||||
|
||||
var ids = this.discussion.postIds().slice(start, end);
|
||||
|
||||
return app.store.find('posts', ids).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'));
|
||||
}
|
||||
|
||||
/**
|
||||
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, true);
|
||||
}
|
||||
|
||||
/**
|
||||
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;
|
@@ -3,10 +3,11 @@ import ComposerBody from 'flarum/components/composer-body';
|
||||
import Alert from 'flarum/components/alert';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
import Composer from 'flarum/components/composer';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
|
||||
export default class ReplyComposer extends ComposerBody {
|
||||
constructor(props) {
|
||||
props.placeholder = props.placeholder || 'Write your reply...';
|
||||
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?';
|
||||
|
||||
@@ -25,7 +26,7 @@ export default class ReplyComposer extends ComposerBody {
|
||||
!app.current.discussion ||
|
||||
app.current.discussion() !== this.props.discussion) {
|
||||
items.add('title', m('h3', [
|
||||
'Replying to ',
|
||||
icon('reply'), ' ',
|
||||
m('a', {href: app.route.discussion(this.props.discussion), config: m.route}, this.props.discussion.title())
|
||||
]));
|
||||
}
|
||||
@@ -67,7 +68,7 @@ export default class ReplyComposer extends ComposerBody {
|
||||
// If we're currently viewing the discussion which this reply was made
|
||||
// in, then we can add the post to the end of the post stream.
|
||||
if (app.current && app.current.discussion && app.current.discussion().id() === discussion.id()) {
|
||||
app.current.stream().addPostToEnd(post);
|
||||
app.current.stream.pushPost(post);
|
||||
m.route(app.route('discussion.near', {
|
||||
id: discussion.id(),
|
||||
slug: discussion.slug(),
|
||||
|
10
js/forum/src/components/reply-placeholder.js
Normal file
10
js/forum/src/components/reply-placeholder.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Component from 'flarum/component';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
|
||||
export default class ReplyPlaceholder extends Component {
|
||||
view() {
|
||||
return m('article.post.reply-post', {onmousedown: () => this.props.discussion.replyAction(true)}, [
|
||||
m('header.post-header', avatar(app.session.user()), 'Write a Reply...'),
|
||||
]);
|
||||
}
|
||||
}
|
222
js/forum/src/components/search-box.js
Normal file
222
js/forum/src/components/search-box.js
Normal file
@@ -0,0 +1,222 @@
|
||||
import Component from 'flarum/component';
|
||||
import DiscussionPage from 'flarum/components/discussion-page';
|
||||
import IndexPage from 'flarum/components/index-page';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import classList from 'flarum/utils/class-list';
|
||||
import listItems from 'flarum/helpers/list-items';
|
||||
import icon from 'flarum/helpers/icon';
|
||||
import DiscussionsSearchResults from 'flarum/components/discussions-search-results';
|
||||
import UsersSearchResults from 'flarum/components/users-search-results';
|
||||
|
||||
/**
|
||||
* A search box, which 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 SearchBox extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.value = m.prop(this.getCurrentSearch() || '');
|
||||
this.hasFocus = m.prop(false);
|
||||
|
||||
this.sources = this.sourceItems().toArray();
|
||||
this.loadingSources = 0;
|
||||
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).
|
||||
*/
|
||||
this.index = m.prop(0);
|
||||
}
|
||||
|
||||
getCurrentSearch() {
|
||||
return typeof app.current.searching === 'function' && app.current.searching();
|
||||
}
|
||||
|
||||
view() {
|
||||
var currentSearch = this.getCurrentSearch();
|
||||
|
||||
return m('div.search-box.dropdown', {
|
||||
config: this.onload.bind(this),
|
||||
className: classList({
|
||||
open: this.value() && this.hasFocus(),
|
||||
active: !!currentSearch,
|
||||
loading: !!this.loadingSources,
|
||||
})
|
||||
},
|
||||
m('div.search-input',
|
||||
m('input.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
|
||||
? m('button.clear.btn.btn-icon.btn-link', {onclick: this.clear.bind(this)}, icon('times-circle'))
|
||||
: ''
|
||||
),
|
||||
m('ul.dropdown-menu.dropdown-menu-right.search-results', this.sources.map(source => source.view(this.value())))
|
||||
);
|
||||
}
|
||||
|
||||
onload(element, isInitialized, context) {
|
||||
this.element(element);
|
||||
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
|
||||
if (isInitialized) return;
|
||||
|
||||
var self = 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(e) {
|
||||
self.setIndex(
|
||||
self.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();
|
||||
this.getItem(this.index()).find('a')[0].dispatchEvent(new Event('click'));
|
||||
break;
|
||||
|
||||
case 27: // Escape
|
||||
this.clear();
|
||||
break;
|
||||
}
|
||||
})
|
||||
|
||||
// Handle input key events on the search input, triggering results to
|
||||
// load.
|
||||
.on('input focus', function(e) {
|
||||
var value = this.value.toLowerCase();
|
||||
|
||||
if (value) {
|
||||
clearTimeout(self.searchTimeout);
|
||||
self.searchTimeout = setTimeout(() => {
|
||||
if (self.searched.indexOf(value) === -1) {
|
||||
if (value.length >= 3) {
|
||||
self.sources.map(source => {
|
||||
if (source.search) {
|
||||
self.loadingSources++;
|
||||
source.search(value).then(() => {
|
||||
self.loadingSources--;
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
self.searched.push(value);
|
||||
m.redraw();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.value('');
|
||||
if (this.getCurrentSearch()) {
|
||||
app.current.clearSearch();
|
||||
} else {
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
sourceItems() {
|
||||
var items = new ItemList();
|
||||
|
||||
items.add('discussions', new DiscussionsSearchResults());
|
||||
items.add('users', new UsersSearchResults());
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
selectableItems() {
|
||||
return this.$('.search-results > li:not(.dropdown-header)');
|
||||
}
|
||||
|
||||
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) {
|
||||
var $items = this.selectableItems();
|
||||
var $item = $items.filter('[data-index='+index+']');
|
||||
|
||||
if (!$item.length) {
|
||||
$item = $items.eq(index);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
setIndex(index, scrollToItem) {
|
||||
var $items = this.selectableItems();
|
||||
var $dropdown = $items.parent();
|
||||
|
||||
if (index < 0) {
|
||||
index = $items.length - 1;
|
||||
} else if (index >= $items.length) {
|
||||
index = 0;
|
||||
}
|
||||
|
||||
var $item = $items.removeClass('active').eq(index).addClass('active');
|
||||
|
||||
this.index($item.attr('data-index') || index);
|
||||
|
||||
if (scrollToItem) {
|
||||
var dropdownScroll = $dropdown.scrollTop();
|
||||
var dropdownTop = $dropdown.offset().top;
|
||||
var dropdownBottom = dropdownTop + $dropdown.outerHeight();
|
||||
var itemTop = $item.offset().top;
|
||||
var itemBottom = itemTop + $item.outerHeight();
|
||||
|
||||
var scrollTop;
|
||||
if (itemTop < dropdownTop) {
|
||||
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'));
|
||||
} else if (itemBottom > dropdownBottom) {
|
||||
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'));
|
||||
}
|
||||
|
||||
if (typeof scrollTop !== 'undefined') {
|
||||
$dropdown.stop(true).animate({scrollTop}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -67,6 +67,14 @@ export default class SignupModal extends FormModal {
|
||||
return vdom;
|
||||
}
|
||||
|
||||
ready() {
|
||||
if (this.props.username) {
|
||||
this.$('[name=email]').select();
|
||||
} else {
|
||||
super.ready();
|
||||
}
|
||||
}
|
||||
|
||||
fadeIn(element, isInitialized) {
|
||||
if (isInitialized) { return; }
|
||||
$(element).hide().fadeIn();
|
||||
@@ -86,9 +94,7 @@ export default class SignupModal extends FormModal {
|
||||
m.redraw();
|
||||
}, response => {
|
||||
this.loading(false);
|
||||
this.alert = new Alert({ type: 'warning', message: response.errors.map((error, k) => [error.detail, k < response.errors.length - 1 ? m('br') : '']) });
|
||||
m.redraw();
|
||||
this.$('[name='+response.errors[0].path+']').select();
|
||||
this.handleErrors(response.errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,360 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import StreamItem from 'flarum/components/stream-item';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
import ScrollListener from 'flarum/utils/scroll-listener';
|
||||
import mixin from 'flarum/utils/mixin';
|
||||
import evented from 'flarum/utils/evented';
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
export default class StreamContent extends mixin(Component, evented) {
|
||||
/**
|
||||
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.loaded = () => this.props.stream.loadedCount();
|
||||
this.paused = m.prop(false);
|
||||
this.active = () => this.loaded() && !this.paused();
|
||||
|
||||
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
|
||||
|
||||
this.on('loadingIndex', this.loadingIndex.bind(this));
|
||||
this.on('loadedIndex', this.loadedIndex.bind(this));
|
||||
|
||||
this.on('loadingNumber', this.loadingNumber.bind(this));
|
||||
this.on('loadedNumber', this.loadedNumber.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
view() {
|
||||
var stream = this.props.stream;
|
||||
|
||||
return m('div', {className: 'stream '+(this.props.className || ''), config: this.onload.bind(this)},
|
||||
stream ? stream.content.map(item => StreamItem.component({
|
||||
key: item.start+'-'+item.end,
|
||||
item: item,
|
||||
loadRange: stream.loadRange.bind(stream),
|
||||
ondelete: this.ondelete.bind(this)
|
||||
}))
|
||||
: LoadingIndicator.component());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
onload(element, isInitialized, context) {
|
||||
this.element(element);
|
||||
|
||||
if (isInitialized) { return; }
|
||||
|
||||
context.onunload = this.ondestroy.bind(this);
|
||||
this.scrollListener.start();
|
||||
}
|
||||
|
||||
ondelete(post) {
|
||||
this.props.stream.removePost(post.id());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
ondestroy() {
|
||||
this.scrollListener.stop();
|
||||
clearTimeout(this.positionChangedTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
onscroll(top) {
|
||||
if (!this.active()) { return; }
|
||||
|
||||
var $items = this.$('.item');
|
||||
|
||||
var marginTop = this.getMarginTop();
|
||||
var $window = $(window);
|
||||
var viewportHeight = $window.height() - marginTop;
|
||||
var scrollTop = top + marginTop;
|
||||
var loadAheadDistance = 300;
|
||||
var startNumber;
|
||||
var endNumber;
|
||||
|
||||
// Loop through each of the items in the stream. An 'item' is either a
|
||||
// single post or a 'gap' of one or more posts that haven't been loaded
|
||||
// yet.
|
||||
$items.each(function() {
|
||||
var $this = $(this);
|
||||
var top = $this.offset().top;
|
||||
var height = $this.outerHeight();
|
||||
|
||||
// If this item is above the top of the viewport (plus a bit of leeway
|
||||
// for loading-ahead gaps), skip to the next one. If it's below the
|
||||
// bottom of the viewport, break out of the loop.
|
||||
if (top + height < scrollTop - loadAheadDistance) { return; }
|
||||
if (top > scrollTop + viewportHeight + loadAheadDistance) { return false; }
|
||||
|
||||
// If this item is a gap, then we may proceed to check if it's a
|
||||
// *terminal* gap and trigger its loading mechanism.
|
||||
if ($this.hasClass('gap')) {
|
||||
var first = $this.is(':first-child');
|
||||
var last = $this.is(':last-child');
|
||||
var item = $this[0].instance.props.item;
|
||||
if ((first || last) && !item.loading) {
|
||||
item.direction = first ? 'up' : 'down';
|
||||
$this[0].instance.load();
|
||||
}
|
||||
} else {
|
||||
if (top + height < scrollTop + viewportHeight) {
|
||||
endNumber = $this.data('number');
|
||||
}
|
||||
|
||||
// Check if this item is in the viewport, minus the distance we allow
|
||||
// for load-ahead gaps. If we haven't yet stored a post's number, then
|
||||
// this item must be the FIRST item in the viewport. Therefore, we'll
|
||||
// grab its post number so we can update the controller's state later.
|
||||
if (top + height > scrollTop && !startNumber) {
|
||||
startNumber = $this.data('number');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Finally, we want to update the controller's state with regards to the
|
||||
// current viewing position of the discussion. However, we don't want to
|
||||
// do this on every single scroll event as it will slow things down. So,
|
||||
// let's do it at a minimum of 250ms by clearing and setting a timeout.
|
||||
clearTimeout(this.positionChangedTimeout);
|
||||
this.positionChangedTimeout = setTimeout(() => this.props.positionChanged(startNumber || 1, endNumber), 500);
|
||||
}
|
||||
|
||||
/**
|
||||
Get the distance from the top of the viewport to the point at which we
|
||||
would consider a post to be the first one visible.
|
||||
*/
|
||||
getMarginTop() {
|
||||
return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top'));
|
||||
}
|
||||
|
||||
/**
|
||||
Scroll down to a certain post by number (or the gap which we think the
|
||||
post is in) and highlight it.
|
||||
*/
|
||||
scrollToNumber(number, noAnimation) {
|
||||
// Clear the highlight class from all posts, and attempt to find and
|
||||
// highlight a post with the specified number. However, we don't apply
|
||||
// the highlight to the first post in the stream because it's pretty
|
||||
// obvious that it's the top one.
|
||||
var $item = this.$('.item').removeClass('highlight').filter('[data-number='+number+']');
|
||||
if (!$item.is(':first-child')) {
|
||||
$item.addClass('highlight');
|
||||
}
|
||||
|
||||
// If we didn't have any luck, then a post with this number either
|
||||
// doesn't exist, or it hasn't been loaded yet. We'll find the item
|
||||
// that's closest to the post with this number and scroll to that
|
||||
// instead.
|
||||
if (!$item.length) {
|
||||
$item = this.findNearestToNumber(number);
|
||||
}
|
||||
|
||||
return this.scrollToItem($item, noAnimation);
|
||||
}
|
||||
|
||||
/**
|
||||
Scroll down to a certain post by index (or the gap the post is in.)
|
||||
*/
|
||||
scrollToIndex(index, noAnimation) {
|
||||
var $item = this.findNearestToIndex(index);
|
||||
return this.scrollToItem($item, noAnimation);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
scrollToItem($item, noAnimation) {
|
||||
var $container = $('html, body').stop(true);
|
||||
if ($item.length) {
|
||||
var 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, just flash it, we don't need to
|
||||
// scroll anywhere.
|
||||
if (itemTop > scrollTop && itemBottom < scrollBottom) {
|
||||
this.flashItem($item);
|
||||
} else {
|
||||
var scrollTop = $item.is(':first-child') ? 0 : itemTop;
|
||||
if (noAnimation) {
|
||||
$container.scrollTop(scrollTop);
|
||||
} else if (scrollTop !== $(document).scrollTop()) {
|
||||
$container.animate({scrollTop: scrollTop}, 'fast', this.flashItem.bind(this, $item));
|
||||
} else {
|
||||
this.flashItem($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $container.promise();
|
||||
}
|
||||
|
||||
flashItem($item) {
|
||||
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
||||
}
|
||||
|
||||
/**
|
||||
Find the DOM element of the item that is nearest to a post with a certain
|
||||
number. This will either be another post (if the requested post doesn't
|
||||
exist,) or a gap presumed to contain the requested post.
|
||||
*/
|
||||
findNearestToNumber(number) {
|
||||
var $nearestItem = $();
|
||||
this.$('.item').each(function() {
|
||||
var $this = $(this);
|
||||
if ($this.data('number') > number) {
|
||||
return false;
|
||||
}
|
||||
$nearestItem = $this;
|
||||
});
|
||||
return $nearestItem;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
findNearestToIndex(index) {
|
||||
var $nearestItem = this.$('.item[data-start='+index+'][data-end='+index+']');
|
||||
if (!$nearestItem.length) {
|
||||
this.$('.item').each(function() {
|
||||
$nearestItem = $(this);
|
||||
if ($nearestItem.data('end') >= index) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
return $nearestItem;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
loadingIndex(index, noAnimation) {
|
||||
// The post at this index is being loaded. We want to scroll to where we
|
||||
// think it will appear. We may be scrolling to the edge of the page,
|
||||
// but we don't want to trigger any terminal post gaps to load by doing
|
||||
// that. So, we'll disable the window's scroll handler for now.
|
||||
this.paused(true);
|
||||
this.scrollToIndex(index, noAnimation);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
loadedIndex(index, noAnimation) {
|
||||
m.redraw(true);
|
||||
|
||||
// The post at this index has been loaded. After we scroll to this post,
|
||||
// we want to resume scroll events.
|
||||
this.scrollToIndex(index, noAnimation).done(this.unpause.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
loadingNumber(number, noAnimation) {
|
||||
// The post with this number is being loaded. We want to scroll to where
|
||||
// we think it will appear. We may be scrolling to the edge of the page,
|
||||
// but we don't want to trigger any terminal post gaps to load by doing
|
||||
// that. So, we'll disable the window's scroll handler for now.
|
||||
this.paused(true);
|
||||
if (this.$()) {
|
||||
this.scrollToNumber(number, noAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
loadedNumber(number, noAnimation) {
|
||||
m.redraw(true);
|
||||
|
||||
// The post with this number has been loaded. After we scroll to this
|
||||
// post, we want to resume scroll events.
|
||||
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
unpause() {
|
||||
this.paused(false);
|
||||
this.scrollListener.update(true);
|
||||
this.trigger('unpaused');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
goToNumber(number, noAnimation) {
|
||||
number = Math.max(number, 1);
|
||||
|
||||
// Let's start by telling our listeners that we're going to load
|
||||
// posts near this number. Elsewhere we will listen and
|
||||
// consequently scroll down to the appropriate position.
|
||||
this.trigger('loadingNumber', number, noAnimation);
|
||||
|
||||
// Now we have to actually make sure the posts around this new start
|
||||
// position are loaded. We will tell our listeners when they are.
|
||||
// Again, a listener will scroll down to the appropriate post.
|
||||
var promise = this.props.stream.loadNearNumber(number);
|
||||
m.redraw();
|
||||
|
||||
return promise.then(() => this.trigger('loadedNumber', number, noAnimation));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
goToIndex(index, backwards, noAnimation) {
|
||||
// Let's start by telling our listeners that we're going to load
|
||||
// posts at this index. Elsewhere we will listen and consequently
|
||||
// scroll down to the appropriate position.
|
||||
this.trigger('loadingIndex', index, noAnimation);
|
||||
|
||||
// Now we have to actually make sure the posts around this index
|
||||
// are loaded. We will tell our listeners when they are. Again, a
|
||||
// listener will scroll down to the appropriate post.
|
||||
var promise = this.props.stream.loadNearIndex(index, backwards);
|
||||
m.redraw();
|
||||
|
||||
return promise.then(() => this.trigger('loadedIndex', index, noAnimation));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
goToFirst() {
|
||||
return this.goToIndex(0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
goToLast() {
|
||||
var promise = this.goToIndex(this.props.stream.count() - 1, true);
|
||||
|
||||
// If the post stream is loading some new posts, then after it's
|
||||
// done we'll want to immediately scroll down to the bottom of the
|
||||
// page.
|
||||
var items = this.props.stream.content;
|
||||
if (!items[items.length - 1].post) {
|
||||
promise.then(() => $('html, body').stop(true).scrollTop($('body').height()));
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
}
|
@@ -1,112 +0,0 @@
|
||||
import Component from 'flarum/component';
|
||||
import classList from 'flarum/utils/class-list';
|
||||
import LoadingIndicator from 'flarum/components/loading-indicator';
|
||||
|
||||
export default class StreamItem extends Component {
|
||||
/**
|
||||
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.element = m.prop();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
view() {
|
||||
var component = this;
|
||||
var item = this.props.item;
|
||||
|
||||
var gap = !item.post;
|
||||
var direction = item.direction;
|
||||
var loading = item.loading;
|
||||
var count = item.end - item.start + 1;
|
||||
var classes = { item: true, gap, loading, direction };
|
||||
|
||||
var attributes = {
|
||||
className: classList(classes),
|
||||
config: this.element,
|
||||
'data-start': item.start,
|
||||
'data-end': item.end
|
||||
};
|
||||
if (!gap) {
|
||||
attributes['data-time'] = item.post.time().toISOString();
|
||||
attributes['data-number'] = item.post.number();
|
||||
} else {
|
||||
attributes['config'] = (element) => {
|
||||
this.element(element);
|
||||
element.instance = this;
|
||||
};
|
||||
attributes['onclick'] = this.load.bind(this);
|
||||
attributes['onmouseenter'] = function(e) {
|
||||
if (!item.loading) {
|
||||
var $this = $(this);
|
||||
var up = e.clientY > $this.offset().top - $(document).scrollTop() + $this.outerHeight(true) / 2;
|
||||
$this.removeClass('up down').addClass(item.direction = up ? 'up' : 'down');
|
||||
}
|
||||
m.redraw.strategy('none');
|
||||
};
|
||||
}
|
||||
|
||||
var content;
|
||||
if (gap) {
|
||||
content = m('span', loading ? LoadingIndicator.component() : count+' more post'+(count !== 1 ? 's' : ''));
|
||||
} else {
|
||||
var PostComponent = app.postComponentRegistry[item.post.contentType()];
|
||||
if (PostComponent) {
|
||||
content = PostComponent.component({post: item.post, ondelete: this.props.ondelete});
|
||||
}
|
||||
}
|
||||
|
||||
return m('div', attributes, content);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
*/
|
||||
load() {
|
||||
var item = this.props.item;
|
||||
|
||||
// If this item is not a gap, or if we're already loading its posts,
|
||||
// then we don't need to do anything.
|
||||
if (item.post || item.loading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If new posts are being loaded in an upwards direction, then when
|
||||
// they are rendered, the rest of the posts will be pushed down the
|
||||
// page. If loaded in a downwards direction from the end of a
|
||||
// discussion, the terminal gap will disappear and the page will
|
||||
// scroll up a bit before the new posts are rendered. In order to
|
||||
// maintain the current scroll position relative to the content
|
||||
// before/after the gap, we need to find item directly after the gap
|
||||
// and use it as an anchor.
|
||||
var siblingFunc = item.direction === 'up' ? 'nextAll' : 'prevAll';
|
||||
var anchor = this.$()[siblingFunc]('.item:first');
|
||||
|
||||
// Tell the controller that we want to load the range of posts that this
|
||||
// gap represents. We also specify which direction we want to load the
|
||||
// posts from.
|
||||
this.props.loadRange(item.start, item.end, item.direction === 'up').then(function() {
|
||||
// Immediately after the posts have been loaded (but before they
|
||||
// have been rendered,) we want to grab the distance from the top of
|
||||
// the viewport to the top of the anchor element.
|
||||
if (anchor.length) {
|
||||
var scrollOffset = anchor.offset().top - $(document).scrollTop();
|
||||
}
|
||||
|
||||
m.redraw(true);
|
||||
|
||||
// After they have been rendered, we scroll back to a position
|
||||
// so that the distance from the top of the viewport to the top
|
||||
// of the anchor element is the same as before. If there is no
|
||||
// anchor (i.e. this gap is terminal,) then we'll scroll to the
|
||||
// bottom of the document.
|
||||
$('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
|
||||
});
|
||||
|
||||
m.redraw();
|
||||
}
|
||||
}
|
@@ -2,8 +2,6 @@ import Component from 'flarum/component';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
import IndexPage from 'flarum/components/index-page';
|
||||
import DiscussionList from 'flarum/components/discussion-list';
|
||||
import StreamContent from 'flarum/components/stream-content';
|
||||
import StreamScrubber from 'flarum/components/stream-scrubber';
|
||||
import UserCard from 'flarum/components/user-card';
|
||||
import ReplyComposer from 'flarum/components/reply-composer';
|
||||
import ActionButton from 'flarum/components/action-button';
|
||||
|
22
js/forum/src/components/users-search-results.js
Normal file
22
js/forum/src/components/users-search-results.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import highlight from 'flarum/helpers/highlight';
|
||||
import avatar from 'flarum/helpers/avatar';
|
||||
|
||||
export default class UsersSearchResults {
|
||||
search(string) {
|
||||
return app.store.find('users', {q: string, page: {limit: 5}});
|
||||
}
|
||||
|
||||
view(string) {
|
||||
var results = app.store.all('users').filter(user => user.username().toLowerCase().substr(0, string.length) === string);
|
||||
|
||||
return results.length ? [
|
||||
m('li.dropdown-header', 'Users'),
|
||||
results.map(user => m('li.user-search-result', {'data-index': 'users'+user.id()},
|
||||
m('a', {
|
||||
href: app.route.user(user),
|
||||
config: m.route
|
||||
}, avatar(user), highlight(user.username(), string))
|
||||
))
|
||||
] : '';
|
||||
}
|
||||
}
|
@@ -11,8 +11,7 @@ import FooterSecondary from 'flarum/components/footer-secondary';
|
||||
import Composer from 'flarum/components/composer';
|
||||
import Modal from 'flarum/components/modal';
|
||||
import Alerts from 'flarum/components/alerts';
|
||||
import SignupModal from 'flarum/components/signup-modal';
|
||||
import LoginModal from 'flarum/components/login-modal';
|
||||
import SearchBox from 'flarum/components/search-box';
|
||||
|
||||
export default function(app) {
|
||||
var id = id => document.getElementById(id);
|
||||
@@ -43,5 +42,7 @@ export default function(app) {
|
||||
m.route.mode = 'hash';
|
||||
m.route(id('content'), '/', mapRoutes(app.routes));
|
||||
|
||||
app.search = new SearchBox();
|
||||
|
||||
new ScrollListener(top => $('body').toggleClass('scrolled', top > 0)).start();
|
||||
}
|
||||
|
@@ -8,25 +8,41 @@ import ItemList from 'flarum/utils/item-list';
|
||||
|
||||
export default function(app) {
|
||||
Discussion.prototype.replyAction = function(goToLast, forceRefresh) {
|
||||
if (app.session.user() && this.canReply()) {
|
||||
if (goToLast && app.current.discussion && app.current.discussion().id() === this.id()) {
|
||||
app.current.streamContent.goToLast();
|
||||
var deferred = m.deferred();
|
||||
|
||||
var reply = () => {
|
||||
if (this.canReply()) {
|
||||
if (goToLast && app.viewingDiscussion(this)) {
|
||||
app.current.stream.goToLast();
|
||||
}
|
||||
|
||||
var component = app.composer.component;
|
||||
if (!app.composingReplyTo(this) || forceRefresh) {
|
||||
component = new ReplyComposer({
|
||||
user: app.session.user(),
|
||||
discussion: this
|
||||
});
|
||||
app.composer.load(component);
|
||||
}
|
||||
app.composer.show(goToLast);
|
||||
|
||||
deferred.resolve(component);
|
||||
} else {
|
||||
deferred.reject();
|
||||
}
|
||||
var component = app.composer.component;
|
||||
if (!(component instanceof ReplyComposer) || component.props.discussion !== this || component.props.user !== app.session.user() || forceRefresh) {
|
||||
component = new ReplyComposer({
|
||||
user: app.session.user(),
|
||||
discussion: this
|
||||
});
|
||||
app.composer.load(component);
|
||||
}
|
||||
app.composer.show(goToLast);
|
||||
return component;
|
||||
} else if (!app.session.user()) {
|
||||
app.modal.show(new LoginModal({
|
||||
callback: () => app.current.one('loaded', this.replyAction.bind(this, goToLast, forceRefresh))
|
||||
}));
|
||||
};
|
||||
|
||||
if (app.session.user()) {
|
||||
reply();
|
||||
} else {
|
||||
app.modal.show(
|
||||
new LoginModal({
|
||||
onlogin: () => app.current.one('loaded', reply)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
Discussion.prototype.deleteAction = function() {
|
||||
@@ -47,7 +63,7 @@ export default function(app) {
|
||||
if (title && title !== currentTitle) {
|
||||
this.save({title}).then(discussion => {
|
||||
if (app.current instanceof DiscussionPage) {
|
||||
app.current.stream().sync();
|
||||
app.current.stream.sync();
|
||||
}
|
||||
m.redraw();
|
||||
});
|
||||
|
@@ -23,8 +23,9 @@ export default function(app) {
|
||||
|
||||
function deleteAction() {
|
||||
this.delete();
|
||||
this.discussion().pushData({removedPosts: [this.id()]});
|
||||
if (app.current instanceof DiscussionPage) {
|
||||
app.current.stream().removePost(this.id());
|
||||
app.current.stream.removePost(this.id());
|
||||
}
|
||||
}
|
||||
|
||||
|
14
js/forum/src/initializers/state-helpers.js
Normal file
14
js/forum/src/initializers/state-helpers.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import ReplyComposer from 'flarum/components/reply-composer';
|
||||
import DiscussionPage from 'flarum/components/discussion-page';
|
||||
|
||||
export default function(app) {
|
||||
app.composingReplyTo = function(discussion) {
|
||||
return this.composer.component instanceof ReplyComposer &&
|
||||
this.composer.component.props.discussion === discussion;
|
||||
};
|
||||
|
||||
app.viewingDiscussion = function(discussion) {
|
||||
return this.current instanceof DiscussionPage &&
|
||||
this.current.discussion() === discussion;
|
||||
};
|
||||
};
|
@@ -1,170 +0,0 @@
|
||||
export default class PostStream {
|
||||
constructor(discussion) {
|
||||
this.discussion = discussion
|
||||
this.ids = this.discussion.data().links.posts.linkage.map((link) => link.id)
|
||||
|
||||
var item = this.makeItem(0, this.ids.length - 1)
|
||||
item.loading = true
|
||||
this.content = [item]
|
||||
|
||||
this.postLoadCount = 20
|
||||
}
|
||||
|
||||
count() {
|
||||
return this.ids.length;
|
||||
}
|
||||
|
||||
loadedCount() {
|
||||
return this.content.filter((item) => item.post).length;
|
||||
}
|
||||
|
||||
loadRange(start, end, backwards) {
|
||||
// Find the appropriate gap objects in the post stream. When we find
|
||||
// one, we will turn on its loading flag.
|
||||
this.content.forEach(function(item) {
|
||||
if (!item.post && ((item.start >= start && item.start <= end) || (item.end >= start && item.end <= end))) {
|
||||
item.loading = true
|
||||
item.direction = backwards ? 'up' : 'down'
|
||||
}
|
||||
});
|
||||
|
||||
// Get a list of post numbers that we'll want to retrieve. If there are
|
||||
// more post IDs than the number of posts we want to load, then take a
|
||||
// slice of the array in the appropriate direction.
|
||||
var ids = this.ids.slice(start, end + 1);
|
||||
var limit = this.postLoadCount
|
||||
ids = backwards ? ids.slice(-limit) : ids.slice(0, limit)
|
||||
|
||||
return this.loadPosts(ids)
|
||||
}
|
||||
|
||||
loadPosts(ids) {
|
||||
if (!ids.length) {
|
||||
return m.deferred().resolve().promise;
|
||||
}
|
||||
|
||||
return app.store.find('posts', ids).then(this.addPosts.bind(this));
|
||||
}
|
||||
|
||||
loadNearNumber(number) {
|
||||
// Find the item in the post stream which is nearest to this number. If
|
||||
// it turns out the be the actual post we're trying to load, then we can
|
||||
// return a resolved promise (i.e. we don't need to make an API
|
||||
// request.) Or, if it's a gap, we'll switch on its loading flag.
|
||||
var item = this.findNearestToNumber(number)
|
||||
if (item) {
|
||||
if (item.post && item.post.number() === number) {
|
||||
return m.deferred().resolve([item.post]).promise;
|
||||
} else if (!item.post) {
|
||||
item.direction = 'down'
|
||||
item.loading = true;
|
||||
}
|
||||
}
|
||||
|
||||
var stream = this
|
||||
return app.store.find('posts', {
|
||||
discussions: this.discussion.id(),
|
||||
near: number,
|
||||
count: this.postLoadCount
|
||||
}).then(this.addPosts.bind(this))
|
||||
}
|
||||
|
||||
loadNearIndex(index, backwards) {
|
||||
// Find the item in the post stream which is nearest to this index. If
|
||||
// it turns out the be the actual post we're trying to load, then we can
|
||||
// return a resolved promise (i.e. we don't need to make an API
|
||||
// request.) Or, if it's a gap, we'll switch on its loading flag.
|
||||
var item = this.findNearestToIndex(index)
|
||||
if (item) {
|
||||
if (item.post) {
|
||||
return m.deferred().resolve([item.post]).promise;
|
||||
}
|
||||
return this.loadRange(Math.max(item.start, index - this.postLoadCount / 2), item.end, backwards);
|
||||
}
|
||||
}
|
||||
|
||||
addPosts(posts) {
|
||||
posts.forEach(this.addPost.bind(this))
|
||||
}
|
||||
|
||||
addPost(post) {
|
||||
var index = this.ids.indexOf(post.id())
|
||||
var content = this.content
|
||||
var makeItem = this.makeItem
|
||||
|
||||
// Here we loop through each item in the post stream, and find the gap
|
||||
// in which this post should be situated. When we find it, we can replace
|
||||
// it with the post, and new gaps either side if appropriate.
|
||||
content.some(function(item, i) {
|
||||
if (item.start <= index && item.end >= index) {
|
||||
var newItems = []
|
||||
if (item.start < index) {
|
||||
newItems.push(makeItem(item.start, index - 1))
|
||||
}
|
||||
newItems.push(makeItem(index, index, post))
|
||||
if (item.end > index) {
|
||||
newItems.push(makeItem(index + 1, item.end))
|
||||
}
|
||||
var args = [i, 1].concat(newItems);
|
||||
[].splice.apply(content, args)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// @todo rename to pushPost
|
||||
addPostToEnd(post) {
|
||||
if (this.ids.indexOf(post.id()) === -1) {
|
||||
var index = this.ids.length;
|
||||
this.ids.push(post.id());
|
||||
this.content.push(this.makeItem(index, index, post));
|
||||
}
|
||||
}
|
||||
|
||||
removePost(id) {
|
||||
this.ids.splice(this.ids.indexOf(id), 1);
|
||||
this.content.some((item, i) => {
|
||||
if (item.post && item.post.id() === id) {
|
||||
this.content.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sync() {
|
||||
var discussion = this.discussion;
|
||||
|
||||
var addedPosts = discussion.addedPosts();
|
||||
addedPosts && addedPosts.forEach(this.addPostToEnd.bind(this));
|
||||
discussion.pushData({links: {addedPosts: null}});
|
||||
|
||||
var removedPosts = discussion.removedPosts();
|
||||
removedPosts && removedPosts.forEach(this.removePost.bind(this));
|
||||
discussion.pushData({removedPosts: null});
|
||||
}
|
||||
|
||||
makeItem(start, end, post) {
|
||||
var item = {start, end}
|
||||
if (post) {
|
||||
item.post = post
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
findNearestTo(index, property) {
|
||||
var nearestItem
|
||||
this.content.some(function(item) {
|
||||
if (property(item) > index) { return true }
|
||||
nearestItem = item
|
||||
})
|
||||
return nearestItem
|
||||
}
|
||||
|
||||
findNearestToNumber(number) {
|
||||
return this.findNearestTo(number, (item) => item.post && item.post.number())
|
||||
}
|
||||
|
||||
findNearestToIndex(index) {
|
||||
return this.findNearestTo(index, (item) => item.start)
|
||||
}
|
||||
}
|
@@ -12,6 +12,12 @@ export default class ActionButton extends Component {
|
||||
var label = attrs.label;
|
||||
delete attrs.label;
|
||||
|
||||
if (attrs.disabled) {
|
||||
attrs.className = (attrs.className || '')+' disabled';
|
||||
delete attrs.onclick;
|
||||
delete attrs.disabled;
|
||||
}
|
||||
|
||||
attrs.href = attrs.href || 'javascript:;';
|
||||
return m('a'+(iconName ? '.has-icon' : ''), attrs, [
|
||||
iconName ? icon(iconName+' icon') : '',
|
||||
|
@@ -61,6 +61,11 @@ export default class TextEditor extends Component {
|
||||
$textarea.focus();
|
||||
}
|
||||
|
||||
getSelectionRange() {
|
||||
var $textarea = this.$('textarea');
|
||||
return [$textarea[0].selectionStart, $textarea[0].selectionEnd];
|
||||
}
|
||||
|
||||
insertAtCursor(insert) {
|
||||
var textarea = this.$('textarea')[0];
|
||||
var content = this.value();
|
||||
|
13
js/lib/helpers/highlight.js
Normal file
13
js/lib/helpers/highlight.js
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function(string, regexp) {
|
||||
if (!regexp) {
|
||||
return string;
|
||||
}
|
||||
|
||||
if (!(regexp instanceof RegExp)) {
|
||||
regexp = new RegExp(regexp, 'gi');
|
||||
}
|
||||
|
||||
return m.trust(
|
||||
$('<div/>').text(string).html().replace(regexp, '<mark>$&</mark>')
|
||||
);
|
||||
}
|
@@ -1,140 +1,10 @@
|
||||
import humanTime from 'flarum/utils/human-time';
|
||||
|
||||
export default function(app) {
|
||||
// perhaps get rid of this and just m.redraw every minute?
|
||||
|
||||
// Livestamp.js / v1.1.2 / (c) 2012 Matt Bradley / MIT License
|
||||
// @todo rewrite this to be simpler and cleaner
|
||||
(function($, moment) {
|
||||
var updateInterval = 1e3,
|
||||
paused = false,
|
||||
$livestamps = $([]),
|
||||
|
||||
init = function() {
|
||||
livestampGlobal.resume();
|
||||
},
|
||||
|
||||
prep = function($el, timestamp) {
|
||||
var oldData = $el.data('livestampdata');
|
||||
if (typeof timestamp == 'number')
|
||||
timestamp *= 1e3;
|
||||
|
||||
$el.removeAttr('data-humantime')
|
||||
.removeData('humantime');
|
||||
|
||||
timestamp = moment(timestamp);
|
||||
if (moment().diff(timestamp) > 60 * 60) {
|
||||
return;
|
||||
}
|
||||
if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
|
||||
var newData = $.extend({ }, { 'original': $el.contents() }, oldData);
|
||||
newData.moment = moment(timestamp);
|
||||
|
||||
$el.data('livestampdata', newData).empty();
|
||||
$livestamps.push($el[0]);
|
||||
}
|
||||
},
|
||||
|
||||
run = function() {
|
||||
if (paused) return;
|
||||
livestampGlobal.update();
|
||||
setTimeout(run, updateInterval);
|
||||
},
|
||||
|
||||
livestampGlobal = {
|
||||
update: function() {
|
||||
$('[data-humantime]').each(function() {
|
||||
var $this = $(this);
|
||||
prep($this, $this.attr('datetime'));
|
||||
});
|
||||
|
||||
var toRemove = [];
|
||||
$livestamps.each(function() {
|
||||
var $this = $(this),
|
||||
data = $this.data('livestampdata');
|
||||
|
||||
if (data === undefined)
|
||||
toRemove.push(this);
|
||||
else if (moment.isMoment(data.moment)) {
|
||||
var from = $this.html(),
|
||||
to = humanTime(data.moment);
|
||||
// to = data.moment.fromNow();
|
||||
|
||||
if (from != to) {
|
||||
var e = $.Event('change.livestamp');
|
||||
$this.trigger(e, [from, to]);
|
||||
if (!e.isDefaultPrevented())
|
||||
$this.html(to);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$livestamps = $livestamps.not(toRemove);
|
||||
},
|
||||
|
||||
pause: function() {
|
||||
paused = true;
|
||||
},
|
||||
|
||||
resume: function() {
|
||||
paused = false;
|
||||
run();
|
||||
},
|
||||
|
||||
interval: function(interval) {
|
||||
if (interval === undefined)
|
||||
return updateInterval;
|
||||
updateInterval = interval;
|
||||
}
|
||||
},
|
||||
|
||||
livestampLocal = {
|
||||
add: function($el, timestamp) {
|
||||
if (typeof timestamp == 'number')
|
||||
timestamp *= 1e3;
|
||||
timestamp = moment(timestamp);
|
||||
|
||||
if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
|
||||
$el.each(function() {
|
||||
prep($(this), timestamp);
|
||||
});
|
||||
livestampGlobal.update();
|
||||
}
|
||||
|
||||
return $el;
|
||||
},
|
||||
|
||||
destroy: function($el) {
|
||||
$livestamps = $livestamps.not($el);
|
||||
$el.each(function() {
|
||||
var $this = $(this),
|
||||
data = $this.data('livestampdata');
|
||||
|
||||
if (data === undefined)
|
||||
return $el;
|
||||
|
||||
$this
|
||||
.html(data.original ? data.original : '')
|
||||
.removeData('livestampdata');
|
||||
});
|
||||
|
||||
return $el;
|
||||
},
|
||||
|
||||
isLivestamp: function($el) {
|
||||
return $el.data('livestampdata') !== undefined;
|
||||
}
|
||||
};
|
||||
|
||||
$.livestamp = livestampGlobal;
|
||||
$(init);
|
||||
$.fn.livestamp = function(method, options) {
|
||||
if (!livestampLocal[method]) {
|
||||
options = method;
|
||||
method = 'add';
|
||||
}
|
||||
|
||||
return livestampLocal[method](this, options);
|
||||
};
|
||||
})(jQuery, moment);
|
||||
setInterval(function() {
|
||||
$('[data-humantime]').each(function() {
|
||||
var $this = $(this);
|
||||
$this.html(humanTime($this.attr('datetime')));
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
@@ -34,6 +34,21 @@ export default class Model {
|
||||
}
|
||||
}
|
||||
|
||||
// clone the relevant parts of the model's old data so that we can revert
|
||||
// back if the save fails
|
||||
var oldData = {};
|
||||
var currentData = this.data();
|
||||
for (var i in data) {
|
||||
if (i === 'links') {
|
||||
oldData[i] = oldData[i] || {};
|
||||
for (var j in currentData[i]) {
|
||||
oldData[i][j] = currentData[i][j];
|
||||
}
|
||||
} else {
|
||||
oldData[i] = currentData[i];
|
||||
}
|
||||
}
|
||||
|
||||
this.pushData(data);
|
||||
|
||||
return app.request({
|
||||
@@ -45,6 +60,9 @@ export default class Model {
|
||||
}).then(payload => {
|
||||
this.store.data[payload.data.type][payload.data.id] = this;
|
||||
return this.store.pushPayload(payload);
|
||||
}, response => {
|
||||
this.pushData(oldData);
|
||||
throw response;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,23 +80,29 @@ export default class Model {
|
||||
static prop(name, transform) {
|
||||
return function() {
|
||||
var data = this.data()[name];
|
||||
return transform ? transform(data) : data
|
||||
return transform ? transform(data) : data;
|
||||
}
|
||||
}
|
||||
|
||||
static one(name) {
|
||||
return function() {
|
||||
var link = this.data().links[name];
|
||||
return link && app.store.getById(link.linkage.type, link.linkage.id)
|
||||
var data = this.data();
|
||||
if (data.links) {
|
||||
var link = data.links[name];
|
||||
return link && app.store.getById(link.linkage.type, link.linkage.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static many(name) {
|
||||
return function() {
|
||||
var link = this.data().links[name];
|
||||
return link && link.linkage.map(function(link) {
|
||||
return app.store.getById(link.type, link.id)
|
||||
})
|
||||
var data = this.data();
|
||||
if (data.links) {
|
||||
var link = this.data().links[name];
|
||||
return link && link.linkage.map(function(link) {
|
||||
return app.store.getById(link.type, link.id)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -3,6 +3,25 @@ import computed from 'flarum/utils/computed';
|
||||
import ItemList from 'flarum/utils/item-list';
|
||||
|
||||
class Discussion extends Model {
|
||||
pushData(newData) {
|
||||
super.pushData(newData);
|
||||
|
||||
var posts = this.data().links.posts;
|
||||
if (posts) {
|
||||
if (newData.removedPosts) {
|
||||
posts.linkage.forEach((linkage, i) => {
|
||||
if (newData.removedPosts.indexOf(linkage.id) !== -1) {
|
||||
posts.linkage.splice(i, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (newData.links && newData.links.addedPosts) {
|
||||
[].push.apply(posts.linkage, newData.links.addedPosts.linkage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unreadCount() {
|
||||
var user = app.session.user();
|
||||
if (user && user.readTime() < this.lastTime()) {
|
||||
@@ -37,6 +56,7 @@ Discussion.prototype.commentsCount = Model.prop('commentsCount');
|
||||
Discussion.prototype.repliesCount = computed('commentsCount', commentsCount => commentsCount - 1);
|
||||
|
||||
Discussion.prototype.posts = Model.many('posts');
|
||||
Discussion.prototype.postIds = function() { return this.data().links.posts.linkage.map((link) => link.id); };
|
||||
Discussion.prototype.relevantPosts = Model.many('relevantPosts');
|
||||
Discussion.prototype.addedPosts = Model.many('addedPosts');
|
||||
Discussion.prototype.removedPosts = Model.prop('removedPosts');
|
||||
|
@@ -12,7 +12,7 @@ Post.prototype.user = Model.one('user');
|
||||
Post.prototype.contentType = Model.prop('contentType');
|
||||
Post.prototype.content = Model.prop('content');
|
||||
Post.prototype.contentHtml = Model.prop('contentHtml');
|
||||
Post.prototype.excerpt = Model.prop('excerpt');
|
||||
Post.prototype.contentPlain = computed('contentHtml', contentHtml => $('<div/>').html(contentHtml.replace(/(<\/p>|<br>)/g, '$1 ')).text());
|
||||
|
||||
Post.prototype.editTime = Model.prop('editTime', Model.date);
|
||||
Post.prototype.editUser = Model.one('editUser');
|
||||
|
@@ -1,3 +1,9 @@
|
||||
export default function(number) {
|
||||
return ''+number; // todo
|
||||
if (number >= 1000000) {
|
||||
return Math.floor(number / 1000000)+'M';
|
||||
} else if (number >= 1000) {
|
||||
return Math.floor(number / 1000)+'K';
|
||||
} else {
|
||||
return number.toString();
|
||||
}
|
||||
}
|
||||
|
7
js/lib/utils/anchor-scroll.js
Normal file
7
js/lib/utils/anchor-scroll.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function anchorScroll(element, callback) {
|
||||
var scrollAnchor = $(element).offset().top - $(window).scrollTop();
|
||||
|
||||
callback();
|
||||
|
||||
$(window).scrollTop($(element).offset().top - scrollAnchor);
|
||||
}
|
3
js/lib/utils/format-number.js
Normal file
3
js/lib/utils/format-number.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function(number) {
|
||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
@@ -1,5 +1,3 @@
|
||||
@import "@{lib-path}/config.less";
|
||||
|
||||
@lib-path: "../lib";
|
||||
|
||||
@import "@{lib-path}/bootstrap.less";
|
||||
@@ -22,6 +20,7 @@
|
||||
@import "@{lib-path}/modals.less";
|
||||
@import "@{lib-path}/layout.less";
|
||||
@import "@{lib-path}/side-nav.less";
|
||||
@import "@{lib-path}/search.less";
|
||||
|
||||
@import "composer.less";
|
||||
@import "notifications.less";
|
||||
|
@@ -17,27 +17,27 @@
|
||||
|
||||
& > li {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
margin-right: -4px;
|
||||
}
|
||||
& h3 {
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5em;
|
||||
|
||||
&, & input, & a {
|
||||
color: @fl-body-muted-color;
|
||||
font-size: 15px;
|
||||
color: @fl-secondary-color;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
transition: color 0.2s;
|
||||
|
||||
.active & {
|
||||
color: @fl-body-primary-color;
|
||||
}
|
||||
& input {
|
||||
&, &[disabled], &:focus {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0 20px 0 0;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
& input, & input[disabled] {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
& .fa {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@
|
||||
background: @fl-body-bg;
|
||||
}
|
||||
&.active:not(.full-screen) {
|
||||
box-shadow: 0 2px 6px @fl-shadow-color, 0 0 0 2px @fl-body-primary-color;
|
||||
box-shadow: 0 0 0 2px @fl-body-primary-color, 0 2px 6px @fl-shadow-color;
|
||||
}
|
||||
&.minimized {
|
||||
height: 50px;
|
||||
@@ -134,10 +134,22 @@
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
.composer-controls {
|
||||
.full-screen & .btn {
|
||||
padding: 13px;
|
||||
|
||||
& .fa {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.composer-header {
|
||||
.minimized & {
|
||||
pointer-events: none;
|
||||
}
|
||||
.full-screen & {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
.composer-content {
|
||||
padding: 20px 20px 0;
|
||||
@@ -167,14 +179,14 @@
|
||||
float: left;
|
||||
.avatar-size(64px);
|
||||
|
||||
.minimized & {
|
||||
.minimized &, .full-screen & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.composer-body {
|
||||
margin-left: 90px;
|
||||
|
||||
.minimized & {
|
||||
.minimized &, .full-screen & {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -182,15 +194,19 @@
|
||||
.minimized & {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.full-screen & textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @desktop, @desktop-hd {
|
||||
.composer {
|
||||
.composer:not(.full-screen) {
|
||||
margin-left: -20px;
|
||||
margin-right: 180px;
|
||||
|
||||
.index-page &:not(.full-screen) {
|
||||
.index-page & {
|
||||
margin-left: 205px;
|
||||
margin-right: -20px;
|
||||
}
|
||||
@@ -218,6 +234,7 @@
|
||||
|
||||
&, &:focus, &[disabled] {
|
||||
background: none;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +254,12 @@
|
||||
padding: 15px 20px;
|
||||
border-top: 1px solid @fl-body-secondary-color;
|
||||
|
||||
.full-screen & {
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
& .btn-primary {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
|
@@ -73,67 +73,25 @@
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
}
|
||||
.gap {
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
border: 2px dashed @fl-body-bg;
|
||||
background: #f2f2f2;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
.transition(padding 0.2s);
|
||||
|
||||
&:hover, &.loading, &.active {
|
||||
padding: 50px 0;
|
||||
|
||||
&.up:before, &.down:after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&.loading {
|
||||
.transition(none);
|
||||
}
|
||||
&:before, &:after {
|
||||
font-family: 'FontAwesome';
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
height: 15px;
|
||||
color: #aaa;
|
||||
}
|
||||
&.up:before {
|
||||
content: '\f077';
|
||||
margin-top: -25px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
&.down:after {
|
||||
content: '\f078';
|
||||
margin-bottom: -25px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
&:only-child {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: @fl-primary-color;
|
||||
&:before, &:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
& .loading-indicator {
|
||||
color: #aaa;
|
||||
}
|
||||
@keyframes blink {
|
||||
0% {opacity: 0.5}
|
||||
50% {opacity: 1}
|
||||
100% {opacity: 0.5}
|
||||
}
|
||||
.loading-post {
|
||||
animation: blink 1s linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
.fake-text {
|
||||
background: @fl-body-secondary-color;
|
||||
height: 12px;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
border-radius: @border-radius-base;
|
||||
|
||||
@media @phone {
|
||||
.gap {
|
||||
margin-left: -15px;
|
||||
margin-right: -15px;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
.post-header & {
|
||||
height: 16px;
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,18 +133,18 @@
|
||||
background: @fl-primary-color;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes pulsate {
|
||||
@keyframes pulsate {
|
||||
0% {transform: scale(1)}
|
||||
50% {transform: scale(1.02)}
|
||||
100% {transform: scale(1)}
|
||||
}
|
||||
.item.pulsate {
|
||||
-webkit-animation: pulsate 1s ease-in-out;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
animation: pulsate 1s ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
.item.flash {
|
||||
-webkit-animation: pulsate 0.2s ease-in-out;
|
||||
-webkit-animation-iteration-count: 1;
|
||||
animation: pulsate 0.2s ease-in-out;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
.post-header {
|
||||
margin-bottom: 10px;
|
||||
@@ -275,10 +233,29 @@
|
||||
& pre {
|
||||
border: 0;
|
||||
padding: 15px;
|
||||
background: #fafafa;
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
font-size: 90%;
|
||||
}
|
||||
& h1 {
|
||||
font-size: 160%;
|
||||
}
|
||||
& h2 {
|
||||
font-size: 120%;
|
||||
font-weight: bold;
|
||||
}
|
||||
& h3 {
|
||||
font-size: 100%;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
& h4, & h5, & h6 {
|
||||
font-size: 100%;
|
||||
font-weight: bold;
|
||||
}
|
||||
& img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
.post.is-hidden {
|
||||
& .post-header, & .post-header a, & .post-user h3, & .post-user h3 a {
|
||||
@@ -482,6 +459,28 @@
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
.reply-post {
|
||||
font-size: 15px;
|
||||
cursor: text;
|
||||
overflow: hidden;
|
||||
margin: 50px -20px 0;
|
||||
border: 2px dashed transparent;
|
||||
color: @fl-body-muted-color;
|
||||
border-radius: 10px;
|
||||
padding: 20px 20px 20px 110px;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
& .post-header {
|
||||
padding-top: 18px;
|
||||
color: inherit;
|
||||
}
|
||||
& .avatar {
|
||||
margin-top: -18px;
|
||||
}
|
||||
&:hover {
|
||||
border-color: @fl-body-secondary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// Scrubber
|
||||
@@ -509,8 +508,23 @@
|
||||
.scrubber-before, .scrubber-after {
|
||||
border-left: 1px solid @fl-body-secondary-color;
|
||||
}
|
||||
.scrubber-unread {
|
||||
position: absolute;
|
||||
border-left: 1px solid lighten(@fl-body-muted-color, 10%);
|
||||
width: 100%;
|
||||
background-image: linear-gradient(to right, @fl-body-secondary-color, fade(@fl-body-secondary-color, 0) 10px, fade(@fl-body-secondary-color, 0));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: @fl-body-muted-color;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
padding-left: 13px;
|
||||
}
|
||||
.scrubber-slider {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: @fl-body-bg;
|
||||
width: 100%;
|
||||
padding: 5px 0;
|
||||
}
|
||||
@@ -522,6 +536,7 @@
|
||||
float: left;
|
||||
margin-left: -2px;
|
||||
transition: background 0.2s;
|
||||
|
||||
.disabled & {
|
||||
background: @fl-body-secondary-color;
|
||||
}
|
||||
@@ -533,6 +548,7 @@
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
left: 15px;
|
||||
|
||||
& strong {
|
||||
display: block;
|
||||
}
|
||||
|
@@ -3,14 +3,15 @@
|
||||
background: @fl-body-hero-bg;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
color: @fl-body-hero-color;
|
||||
|
||||
&, & a {
|
||||
color: @fl-body-hero-color;
|
||||
& a {
|
||||
color: inherit;
|
||||
}
|
||||
& .close {
|
||||
float: right;
|
||||
margin-top: -10px;
|
||||
color: @fl-body-hero-muted-color;
|
||||
color: inherit;
|
||||
}
|
||||
& h2 {
|
||||
margin: 0;
|
||||
@@ -21,7 +22,6 @@
|
||||
& .subtitle {
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.5em;
|
||||
color: @fl-body-hero-muted-color;
|
||||
}
|
||||
}
|
||||
@media @phone {
|
||||
|
@@ -17,18 +17,15 @@
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.index-toolbar-view {
|
||||
display: inline-block;
|
||||
&:extend(.list-inline);
|
||||
|
||||
& .control-show {
|
||||
margin-right: 10px;
|
||||
}
|
||||
display: inline-block;
|
||||
}
|
||||
.index-toolbar-action {
|
||||
&:extend(.list-inline);
|
||||
|
||||
float: right;
|
||||
}
|
||||
.index-results .loading-indicator {
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
@media @phone, @tablet {
|
||||
.offset-content {
|
||||
@@ -84,10 +81,11 @@
|
||||
& .hero, & .index-nav, & .index-toolbar {
|
||||
display: none;
|
||||
}
|
||||
& .discussions-list > li {
|
||||
& .discussion-list > ul > li {
|
||||
margin: 0;
|
||||
padding-left: 57px + 15px;
|
||||
padding-right: 65px + 15px;
|
||||
|
||||
&.active {
|
||||
background: @fl-body-control-bg;
|
||||
}
|
||||
@@ -99,6 +97,9 @@
|
||||
& .count strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
& .relevant-posts {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,15 +133,21 @@
|
||||
// ------------------------------------
|
||||
// Discussions List
|
||||
|
||||
.discussions-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
position: relative;
|
||||
.discussion-list {
|
||||
& .loading-indicator {
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
& > ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@media @phone {
|
||||
.discussions-list > li {
|
||||
.discussion-list > ul > li {
|
||||
padding-right: 45px;
|
||||
|
||||
& .contextual-controls {
|
||||
@@ -150,11 +157,9 @@
|
||||
}
|
||||
|
||||
@media @tablet, @desktop, @desktop-hd {
|
||||
.discussions-list {
|
||||
& > li {
|
||||
margin-right: -25px;
|
||||
padding-right: 65px + 25px;
|
||||
}
|
||||
.discussion-list > ul > li {
|
||||
margin-right: -25px;
|
||||
padding-right: 65px + 25px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +208,7 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
& .title {
|
||||
margin: 0 0 7px;
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.3;
|
||||
color: @fl-secondary-color;
|
||||
}
|
||||
@@ -236,6 +241,30 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
& .relevant-posts {
|
||||
margin-bottom: 20px;
|
||||
|
||||
& .post-preview {
|
||||
background: @fl-body-secondary-color;
|
||||
display: block;
|
||||
padding: 10px 15px;
|
||||
border-bottom: 2px dotted @fl-body-bg;
|
||||
|
||||
& .avatar, & time {
|
||||
display: none;
|
||||
}
|
||||
& .post-preview-content {
|
||||
padding-left: 0;
|
||||
}
|
||||
&:first-child {
|
||||
border-radius: @border-radius-base @border-radius-base 0 0;
|
||||
}
|
||||
&:hover {
|
||||
background: darken(@fl-body-secondary-color, 2%);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.load-more {
|
||||
text-align: center;
|
||||
|
@@ -32,7 +32,7 @@
|
||||
margin: -2px -3px;
|
||||
}
|
||||
&.unread .notifications-icon {
|
||||
background: #e7562e;
|
||||
background: @fl-body-primary-color;
|
||||
color: #fff;
|
||||
}
|
||||
.notifications-header {
|
||||
|
@@ -5,7 +5,7 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: @border-radius-base;
|
||||
padding: 40px 30px;
|
||||
padding: 50px 30px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
|
@@ -166,7 +166,7 @@
|
||||
overflow: hidden;
|
||||
|
||||
& .title {
|
||||
margin: 0 0 10px;
|
||||
margin: 0 0 5px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
|
||||
|
@@ -14,6 +14,8 @@
|
||||
padding: 12px 16px;
|
||||
border-radius: @border-radius-base;
|
||||
background: #FFF2AE;
|
||||
line-height: 1.5;
|
||||
|
||||
&, & a, & a:hover {
|
||||
color: #AD6C00;
|
||||
}
|
||||
@@ -38,6 +40,12 @@
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
& .btn {
|
||||
margin: -10px;
|
||||
|
@@ -10,8 +10,24 @@
|
||||
|
||||
.loading-indicator {
|
||||
position: relative;
|
||||
color: @fl-secondary-color;
|
||||
color: @fl-body-muted-color;
|
||||
}
|
||||
.loading-indicator-inline {
|
||||
display: inline-block;
|
||||
width: 25px;
|
||||
}
|
||||
.loading-indicator-block {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: 2px solid @fl-body-secondary-color;
|
||||
}
|
||||
|
||||
mark {
|
||||
background: #FFE300;
|
||||
color: @fl-body-color;
|
||||
padding: 1px;
|
||||
border-radius: @border-radius-base;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
@@ -1,11 +0,0 @@
|
||||
// ---------------------------------
|
||||
// CONFIG
|
||||
|
||||
// Color palette:
|
||||
// #8F3B3B #9E5541 #99793F #8F8A49 #778F53 #638F53 #537F8F #536F90 #76538F #8F5373 #797979
|
||||
|
||||
@fl-primary-color: #536F90;
|
||||
@fl-secondary-color: #536F90;
|
||||
|
||||
@fl-dark-mode: false;
|
||||
@fl-colored-hdr: false;
|
@@ -44,9 +44,23 @@
|
||||
}
|
||||
}
|
||||
& .divider {
|
||||
margin: 10px 0;
|
||||
margin: 8px 0;
|
||||
background-color: @fl-body-control-bg;
|
||||
}
|
||||
& .dropdown-header {
|
||||
padding: 10px 15px;
|
||||
color: @fl-body-heading-color;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid @fl-body-control-bg;
|
||||
|
||||
&:first-child {
|
||||
margin-top: -8px;
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media @tablet, @desktop, @desktop-hd {
|
||||
.dropdown-split {
|
||||
|
@@ -3,11 +3,14 @@
|
||||
}
|
||||
.form-control {
|
||||
.box-shadow(none);
|
||||
border-width: 2px;
|
||||
|
||||
&:focus,
|
||||
&.focus {
|
||||
background-color: #fff;
|
||||
color: @fl-body-color;
|
||||
.box-shadow(none);
|
||||
border: 2px solid @fl-body-primary-color;
|
||||
}
|
||||
}
|
||||
legend {
|
||||
@@ -17,47 +20,6 @@ legend {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
// Search inputs
|
||||
// @todo Extract some of this into header-specific definitions
|
||||
.search-input {
|
||||
overflow: hidden;
|
||||
|
||||
&:before {
|
||||
.fa();
|
||||
content: @fa-var-search;
|
||||
float: left;
|
||||
margin-right: -36px;
|
||||
width: 36px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
color: @fl-body-muted-color;
|
||||
position: relative;
|
||||
padding: @padding-base-vertical - 1 0;
|
||||
line-height: @line-height-base;
|
||||
pointer-events: none;
|
||||
}
|
||||
& .form-control {
|
||||
float: left;
|
||||
width: 225px;
|
||||
padding-left: 36px;
|
||||
padding-right: 36px;
|
||||
.transition(~"all 0.4s");
|
||||
}
|
||||
& .clear {
|
||||
float: left;
|
||||
margin-left: -36px;
|
||||
vertical-align: top;
|
||||
opacity: 0;
|
||||
width: 36px !important;
|
||||
.rotate(-180deg);
|
||||
.transition(~"transform 0.2s, opacity 0.2s");
|
||||
}
|
||||
&.clearable .clear {
|
||||
opacity: 1;
|
||||
.rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Select inputs
|
||||
.select-input {
|
||||
display: inline-block;
|
||||
|
@@ -169,7 +169,7 @@ body {
|
||||
background: fadein(@fl-drawer-control-bg, 5%);
|
||||
}
|
||||
}
|
||||
& .search-input:before {
|
||||
& .search-input {
|
||||
color: @fl-drawer-control-color;
|
||||
}
|
||||
& .btn-default, & .btn-default:hover {
|
||||
@@ -311,12 +311,8 @@ body {
|
||||
.header-secondary {
|
||||
float: right;
|
||||
|
||||
& .search-input {
|
||||
& .search-box {
|
||||
margin-right: 10px;
|
||||
|
||||
&:focus {
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -14,6 +14,10 @@
|
||||
& .alert {
|
||||
border-radius: 0;
|
||||
}
|
||||
& .alert-controls {
|
||||
margin: 0;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.modal-body {
|
||||
background-color: @fl-body-secondary-color;
|
||||
|
82
less/lib/search.less
Normal file
82
less/lib/search.less
Normal file
@@ -0,0 +1,82 @@
|
||||
.search-box {
|
||||
& input:focus, &.active input, & .search-results {
|
||||
width: 400px;
|
||||
}
|
||||
}
|
||||
.search-results {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
|
||||
& > li > a {
|
||||
white-space: normal;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
& mark {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-weight: bold;
|
||||
color: inherit;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
overflow: hidden;
|
||||
color: @fl-body-muted-color;
|
||||
|
||||
&:before {
|
||||
.fa();
|
||||
content: @fa-var-search;
|
||||
float: left;
|
||||
margin-right: -36px;
|
||||
width: 36px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
padding: @padding-base-vertical - 1 0;
|
||||
line-height: @line-height-base;
|
||||
pointer-events: none;
|
||||
}
|
||||
& input {
|
||||
float: left;
|
||||
width: 225px;
|
||||
padding-left: 36px;
|
||||
padding-right: 36px;
|
||||
.transition(~"all 0.4s");
|
||||
|
||||
.active & {
|
||||
background: @fl-body-bg;
|
||||
border: 2px solid @fl-body-secondary-color;
|
||||
|
||||
&:focus {
|
||||
&:extend(.form-control:focus);
|
||||
}
|
||||
}
|
||||
}
|
||||
& .btn {
|
||||
float: left;
|
||||
margin-left: -36px;
|
||||
width: 36px !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.discussion-search-result {
|
||||
& .title {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
& .excerpt {
|
||||
color: @fl-body-muted-color;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
.user-search-result {
|
||||
& .avatar {
|
||||
.avatar-size(24px);
|
||||
margin: -2px 10px -2px 0;
|
||||
}
|
||||
}
|
@@ -1,3 +1,8 @@
|
||||
@fl-primary-color: #536F90;
|
||||
@fl-secondary-color: #536F90;
|
||||
@fl-dark-mode: false;
|
||||
@fl-colored-hdr: false;
|
||||
|
||||
// ---------------------------------
|
||||
// HELPERS
|
||||
|
||||
@@ -13,16 +18,16 @@
|
||||
.define-body-variables(@fl-dark-mode);
|
||||
.define-body-variables(false) {
|
||||
@fl-body-primary-color: @fl-primary-color;
|
||||
@fl-body-secondary-color: hsl(@fl-secondary-hue, min(50%, @fl-secondary-sat), 95%);
|
||||
@fl-body-secondary-color: hsl(@fl-secondary-hue, min(50%, @fl-secondary-sat), 93%);
|
||||
|
||||
@fl-body-bg: #fff;
|
||||
@fl-body-color: #444;
|
||||
@fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 68%);
|
||||
@fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 66%);
|
||||
@fl-body-muted-more-color: #bbb;
|
||||
@fl-shadow-color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.define-body-variables(true) {
|
||||
@fl-body-primary-color: mix(@fl-primary-color, #000);
|
||||
@fl-body-primary-color: mix(@fl-primary-color, #000, 80%);
|
||||
@fl-body-secondary-color: hsl(@fl-secondary-hue, min(20%, @fl-secondary-sat), 13%);
|
||||
|
||||
@fl-body-bg: hsl(@fl-secondary-hue, min(20%, @fl-secondary-sat), 10%);
|
||||
|
@@ -5,7 +5,6 @@ use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class CreateAccessTokensTable extends Migration
|
||||
{
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
@@ -14,9 +13,10 @@ class CreateAccessTokensTable extends Migration
|
||||
public function up()
|
||||
{
|
||||
Schema::create('access_tokens', function (Blueprint $table) {
|
||||
|
||||
$table->string('id', 100)->primary();
|
||||
$table->integer('user_id')->unsigned();
|
||||
$table->timestamp('created_at');
|
||||
$table->timestamp('expires_at');
|
||||
});
|
||||
}
|
||||
|
||||
|
32
migrations/2015_02_24_000000_create_email_tokens_table.php
Normal file
32
migrations/2015_02_24_000000_create_email_tokens_table.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class CreateEmailTokensTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('email_tokens', function (Blueprint $table) {
|
||||
$table->string('id', 100)->primary();
|
||||
$table->integer('user_id')->unsigned();
|
||||
$table->string('email', 150);
|
||||
$table->timestamp('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::drop('email_tokens');
|
||||
}
|
||||
}
|
@@ -3,7 +3,7 @@
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class CreateResetTokensTable extends Migration
|
||||
class CreatePasswordTokensTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
@@ -12,9 +12,10 @@ class CreateResetTokensTable extends Migration
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('reset_tokens', function (Blueprint $table) {
|
||||
$table->string('id');
|
||||
Schema::create('password_tokens', function (Blueprint $table) {
|
||||
$table->string('id', 100)->primary();
|
||||
$table->integer('user_id')->unsigned();
|
||||
$table->timestamp('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +26,6 @@ class CreateResetTokensTable extends Migration
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::drop('reset_tokens');
|
||||
Schema::drop('password_tokens');
|
||||
}
|
||||
}
|
@@ -2,6 +2,7 @@
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use DB;
|
||||
|
||||
class CreatePostsTable extends Migration
|
||||
{
|
||||
@@ -14,7 +15,6 @@ class CreatePostsTable extends Migration
|
||||
public function up()
|
||||
{
|
||||
Schema::create('posts', function (Blueprint $table) {
|
||||
|
||||
$table->increments('id');
|
||||
$table->integer('discussion_id')->unsigned();
|
||||
$table->integer('number')->unsigned()->nullable();
|
||||
@@ -29,10 +29,11 @@ class CreatePostsTable extends Migration
|
||||
$table->integer('edit_user_id')->unsigned()->nullable();
|
||||
$table->dateTime('hide_time')->nullable();
|
||||
$table->integer('hide_user_id')->unsigned()->nullable();
|
||||
|
||||
$table->unique(['discussion_id', 'number']);
|
||||
});
|
||||
|
||||
// add fulltext index to content (and title?)
|
||||
// add unique index on [discussion_id, number] !!!
|
||||
DB::statement('ALTER TABLE posts ADD FULLTEXT content (content)');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -17,8 +17,6 @@ class CreateUsersTable extends Migration
|
||||
$table->increments('id');
|
||||
$table->string('username', 100)->unique();
|
||||
$table->string('email', 150)->unique();
|
||||
$table->boolean('is_confirmed')->default(0);
|
||||
$table->string('confirmation_token', 50)->nullable();
|
||||
$table->boolean('is_activated')->default(0);
|
||||
$table->string('password', 100);
|
||||
$table->text('bio')->nullable();
|
||||
|
@@ -41,7 +41,9 @@ class IndexAction extends SerializeCollectionAction
|
||||
'lastUser' => true,
|
||||
'startPost' => false,
|
||||
'lastPost' => false,
|
||||
'relevantPosts' => false
|
||||
'relevantPosts' => false,
|
||||
'relevantPosts.discussion' => false,
|
||||
'relevantPosts.user' => false
|
||||
];
|
||||
|
||||
/**
|
||||
|
@@ -51,7 +51,7 @@ class ShowAction extends SerializeResourceAction
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $link = ['posts'];
|
||||
public static $link = ['posts', 'posts.discussion'];
|
||||
|
||||
/**
|
||||
* The fields that are available to be sorted by.
|
||||
|
@@ -74,6 +74,7 @@ class IndexAction extends SerializeCollectionAction
|
||||
|
||||
$user->markNotificationsAsRead()->save();
|
||||
|
||||
return $this->notifications->findByUser($user, $request->limit, $request->offset);
|
||||
return $this->notifications->findByUser($user, $request->limit, $request->offset)
|
||||
->load($request->include);
|
||||
}
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ use Flarum\Api\Request;
|
||||
use Flarum\Core\Commands\GenerateAccessTokenCommand;
|
||||
use Flarum\Core\Repositories\UserRepositoryInterface;
|
||||
use Flarum\Core\Exceptions\PermissionDeniedException;
|
||||
use Flarum\Core\Events\UserEmailChangeWasRequested;
|
||||
use Illuminate\Contracts\Bus\Dispatcher;
|
||||
|
||||
class TokenAction extends JsonApiAction
|
||||
@@ -36,6 +37,14 @@ class TokenAction extends JsonApiAction
|
||||
throw new PermissionDeniedException;
|
||||
}
|
||||
|
||||
if (! $user->is_activated) {
|
||||
event(new UserEmailChangeWasRequested($user, $user->email));
|
||||
return $this->json([
|
||||
'code' => 'confirm_email',
|
||||
'email' => $user->email
|
||||
], 401);
|
||||
}
|
||||
|
||||
$token = $this->bus->dispatch(
|
||||
new GenerateAccessTokenCommand($user->id)
|
||||
);
|
||||
|
@@ -17,7 +17,7 @@ class ShowAction extends SerializeResourceAction
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public static $serializer = 'Flarum\Api\Serializers\UserSerializer';
|
||||
public static $serializer = 'Flarum\Api\Serializers\CurrentUserSerializer';
|
||||
|
||||
/**
|
||||
* The relationships that are available to be included, and which ones are
|
||||
|
@@ -49,12 +49,12 @@ abstract class BaseSerializer extends SerializerAbstract
|
||||
$relation = $caller['function'];
|
||||
}
|
||||
|
||||
return function ($model, $include, $links) use ($serializer, $many, $relation) {
|
||||
return function ($model, $include, $included, $links) use ($serializer, $many, $relation) {
|
||||
if ($relation instanceof Closure) {
|
||||
$data = $relation($model, $include);
|
||||
} else {
|
||||
if ($include) {
|
||||
$data = !is_null($model->$relation) ? $model->$relation : ($many ? $model->$relation()->get() : $model->$relation()->first());
|
||||
$data = !is_null($model->$relation) ? $model->$relation : $model->$relation()->getResults();
|
||||
} elseif ($many) {
|
||||
$relationIds = $relation.'_ids';
|
||||
$data = $model->$relationIds ?: $model->$relation()->get(['id'])->fetch('id')->all();
|
||||
@@ -67,7 +67,7 @@ abstract class BaseSerializer extends SerializerAbstract
|
||||
if ($serializer instanceof Closure) {
|
||||
$serializer = $serializer($model, $data);
|
||||
}
|
||||
$serializer = new $serializer($this->actor, $links);
|
||||
$serializer = new $serializer($this->actor, $included, $links);
|
||||
return $many ? $serializer->collection($data) : $serializer->resource($data);
|
||||
};
|
||||
}
|
||||
|
21
src/Api/Serializers/CurrentUserSerializer.php
Normal file
21
src/Api/Serializers/CurrentUserSerializer.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php namespace Flarum\Api\Serializers;
|
||||
|
||||
class CurrentUserSerializer extends UserSerializer
|
||||
{
|
||||
protected function attributes($user)
|
||||
{
|
||||
$attributes = parent::attributes($user);
|
||||
|
||||
$actingUser = $this->actor->getUser();
|
||||
|
||||
if ($user->id === $actingUser->id) {
|
||||
$attributes += [
|
||||
'readTime' => $user->read_time ? $user->read_time->toRFC3339String() : null,
|
||||
'unreadNotificationsCount' => $user->getUnreadNotificationsCount(),
|
||||
'preferences' => $user->preferences
|
||||
];
|
||||
}
|
||||
|
||||
return $this->extendAttributes($user, $attributes);
|
||||
}
|
||||
}
|
@@ -2,13 +2,6 @@
|
||||
|
||||
class DiscussionSerializer extends DiscussionBasicSerializer
|
||||
{
|
||||
/**
|
||||
* Default relations to include.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $include = ['startUser', 'lastUser'];
|
||||
|
||||
/**
|
||||
* Serialize attributes of a Discussion model for JSON output.
|
||||
*
|
||||
|
@@ -9,20 +9,6 @@ class PostBasicSerializer extends BaseSerializer
|
||||
*/
|
||||
protected $type = 'posts';
|
||||
|
||||
/**
|
||||
* Default relations to link.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $link = ['discussion'];
|
||||
|
||||
/**
|
||||
* Default relations to include.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $include = ['user'];
|
||||
|
||||
/**
|
||||
* Serialize attributes of a Post model for JSON output.
|
||||
*
|
||||
@@ -39,7 +25,7 @@ class PostBasicSerializer extends BaseSerializer
|
||||
];
|
||||
|
||||
if ($post->type === 'comment') {
|
||||
$attributes['excerpt'] = str_limit($post->contentPlain, 200);
|
||||
$attributes['contentHtml'] = $post->content_html;
|
||||
} else {
|
||||
$attributes['content'] = $post->content;
|
||||
}
|
||||
|
@@ -2,13 +2,6 @@
|
||||
|
||||
class PostSerializer extends PostBasicSerializer
|
||||
{
|
||||
/**
|
||||
* Default relations to include.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $include = ['user', 'editUser', 'hideUser'];
|
||||
|
||||
/**
|
||||
* Serialize attributes of a Post model for JSON output.
|
||||
*
|
||||
|
@@ -2,13 +2,6 @@
|
||||
|
||||
class UserSerializer extends UserBasicSerializer
|
||||
{
|
||||
/**
|
||||
* Default relations to include.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $include = ['groups'];
|
||||
|
||||
/**
|
||||
* Serialize attributes of a User model for JSON output.
|
||||
*
|
||||
@@ -19,8 +12,8 @@ class UserSerializer extends UserBasicSerializer
|
||||
{
|
||||
$attributes = parent::attributes($user);
|
||||
|
||||
$actorUser = $this->actor->getUser();
|
||||
$canEdit = $user->can($actorUser, 'edit');
|
||||
$actingUser = $this->actor->getUser();
|
||||
$canEdit = $user->can($actingUser, 'edit');
|
||||
|
||||
$attributes += [
|
||||
'bioHtml' => $user->bio_html,
|
||||
@@ -28,7 +21,7 @@ class UserSerializer extends UserBasicSerializer
|
||||
'discussionsCount' => (int) $user->discussions_count,
|
||||
'commentsCount' => (int) $user->comments_count,
|
||||
'canEdit' => $canEdit,
|
||||
'canDelete' => $user->can($actorUser, 'delete'),
|
||||
'canDelete' => $user->can($actingUser, 'delete'),
|
||||
];
|
||||
|
||||
if ($user->preference('discloseOnline')) {
|
||||
@@ -46,14 +39,6 @@ class UserSerializer extends UserBasicSerializer
|
||||
];
|
||||
}
|
||||
|
||||
if ($user->id === $actorUser->id) {
|
||||
$attributes += [
|
||||
'readTime' => $user->read_time ? $user->read_time->toRFC3339String() : null,
|
||||
'unreadNotificationsCount' => $user->getUnreadNotificationsCount(),
|
||||
'preferences' => $user->preferences
|
||||
];
|
||||
}
|
||||
|
||||
return $this->extendAttributes($user, $attributes);
|
||||
}
|
||||
}
|
||||
|
@@ -28,8 +28,8 @@ class SeedCommand extends Command
|
||||
*/
|
||||
public function fire()
|
||||
{
|
||||
$this->call('db:seed', ['--class' => 'Flarum\Core\Seeders\DiscussionsTableSeeder']);
|
||||
$this->call('db:seed', ['--class' => 'Flarum\Core\Seeders\UsersTableSeeder']);
|
||||
$this->call('db:seed', ['--class' => 'Flarum\Core\Seeders\DiscussionsTableSeeder']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -11,6 +11,10 @@ class Core
|
||||
|
||||
public static function config($key, $default = null)
|
||||
{
|
||||
if (! static::isInstalled()) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (is_null($value = DB::table('config')->where('key', $key)->pluck('value'))) {
|
||||
$value = $default;
|
||||
}
|
||||
|
@@ -2,13 +2,10 @@
|
||||
|
||||
class ConfirmEmailCommand
|
||||
{
|
||||
public $userId;
|
||||
|
||||
public $token;
|
||||
|
||||
public function __construct($userId, $token)
|
||||
public function __construct($token)
|
||||
{
|
||||
$this->userId = $userId;
|
||||
$this->token = $token;
|
||||
}
|
||||
}
|
||||
|
@@ -92,6 +92,11 @@ class CoreServiceProvider extends ServiceProvider
|
||||
'Flarum\Core\Repositories\EloquentActivityRepository'
|
||||
);
|
||||
|
||||
$this->app->bind(
|
||||
'Flarum\Core\Search\Discussions\Fulltext\DriverInterface',
|
||||
'Flarum\Core\Search\Discussions\Fulltext\MySqlFulltextDriver'
|
||||
);
|
||||
|
||||
$avatarFilesystem = function (Container $app) {
|
||||
return $app->make('Illuminate\Contracts\Filesystem\Factory')->disk('flarum-avatars')->getDriver();
|
||||
};
|
||||
|
16
src/Core/Events/NotificationWillBeSent.php
Normal file
16
src/Core/Events/NotificationWillBeSent.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php namespace Flarum\Core\Events;
|
||||
|
||||
use Flarum\Core\Notifications\NotificationInterface;
|
||||
|
||||
class NotificationWillBeSent
|
||||
{
|
||||
public $notification;
|
||||
|
||||
public $users;
|
||||
|
||||
public function __construct(NotificationInterface $notification, array &$users)
|
||||
{
|
||||
$this->notification = $notification;
|
||||
$this->users = $users;
|
||||
}
|
||||
}
|
16
src/Core/Events/UserEmailChangeWasRequested.php
Normal file
16
src/Core/Events/UserEmailChangeWasRequested.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php namespace Flarum\Core\Events;
|
||||
|
||||
use Flarum\Core\Models\User;
|
||||
|
||||
class UserEmailChangeWasRequested
|
||||
{
|
||||
public $user;
|
||||
|
||||
public $email;
|
||||
|
||||
public function __construct(User $user, $email)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->email = $email;
|
||||
}
|
||||
}
|
48
src/Core/Formatter/FormatterAbstract.php
Normal file
48
src/Core/Formatter/FormatterAbstract.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php namespace Flarum\Core\Formatter;
|
||||
|
||||
use Flarum\Core\Models\Post;
|
||||
use Closure;
|
||||
|
||||
abstract class FormatterAbstract
|
||||
{
|
||||
public function beforePurification($text, Post $post = null)
|
||||
{
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function afterPurification($text, Post $post = null)
|
||||
{
|
||||
return $text;
|
||||
}
|
||||
|
||||
protected function ignoreTags($text, array $tags, Closure $callback)
|
||||
{
|
||||
$chunks = preg_split('/(<.+?>)/is', $text, 0, PREG_SPLIT_DELIM_CAPTURE);
|
||||
|
||||
$openTag = null;
|
||||
|
||||
for ($i = 0; $i < count($chunks); $i++) {
|
||||
if ($i % 2 === 0) { // even numbers are text
|
||||
// Only process this chunk if there are no unclosed $ignoreTags
|
||||
if (null === $openTag) {
|
||||
$chunks[$i] = $callback($chunks[$i]);
|
||||
}
|
||||
} else { // odd numbers are tags
|
||||
// Only process this tag if there are no unclosed $ignoreTags
|
||||
if (null === $openTag) {
|
||||
// Check whether this tag is contained in $ignoreTags and is not self-closing
|
||||
if (preg_match("`<(" . implode('|', $tags) . ").*(?<!/)>$`is", $chunks[$i], $matches)) {
|
||||
$openTag = $matches[1];
|
||||
}
|
||||
} else {
|
||||
// Otherwise, check whether this is the closing tag for $openTag.
|
||||
if (preg_match('`</\s*' . $openTag . '>`i', $chunks[$i], $matches)) {
|
||||
$openTag = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return implode($chunks);
|
||||
}
|
||||
}
|
10
src/Core/Formatter/FormatterInterface.php
Normal file
10
src/Core/Formatter/FormatterInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php namespace Flarum\Core\Formatter;
|
||||
|
||||
use Flarum\Core\Models\Post;
|
||||
|
||||
interface FormatterInterface
|
||||
{
|
||||
public function beforePurification($text, Post $post = null);
|
||||
|
||||
public function afterPurification($text, Post $post = null);
|
||||
}
|
@@ -1,6 +1,8 @@
|
||||
<?php namespace Flarum\Core\Formatter;
|
||||
|
||||
use Illuminate\Contracts\Container\Container;
|
||||
use HTMLPurifier;
|
||||
use HTMLPurifier_Config;
|
||||
|
||||
class FormatterManager
|
||||
{
|
||||
@@ -55,20 +57,32 @@ class FormatterManager
|
||||
|
||||
public function format($text, $post = null)
|
||||
{
|
||||
$formatters = [];
|
||||
foreach ($this->getFormatters() as $formatter) {
|
||||
$text = $this->container->make($formatter)->format($text, $post);
|
||||
$formatters[] = $this->container->make($formatter);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
foreach ($formatters as $formatter) {
|
||||
$text = $formatter->beforePurification($text, $post);
|
||||
}
|
||||
|
||||
public function strip($text)
|
||||
{
|
||||
foreach ($this->getFormatters() as $formatter) {
|
||||
$formatter = $this->container->make($formatter);
|
||||
if (method_exists($formatter, 'strip')) {
|
||||
$text = $formatter->strip($text);
|
||||
}
|
||||
// Studio does not yet merge autoload_files...
|
||||
// https://github.com/franzliedke/studio/commit/4f0f4314db4ed3e36c869a5f79b855c97bdd1be7
|
||||
require __DIR__.'/../../../vendor/ezyang/htmlpurifier/library/HTMLPurifier.composer.php';
|
||||
|
||||
$config = HTMLPurifier_Config::createDefault();
|
||||
$config->set('Core.Encoding', 'UTF-8');
|
||||
$config->set('Core.EscapeInvalidTags', true);
|
||||
$config->set('HTML.Doctype', 'HTML 4.01 Strict');
|
||||
$config->set('HTML.Allowed', 'p,em,strong,a[href|title],ul,ol,li,code,pre,blockquote,h1,h2,h3,h4,h5,h6,br,hr');
|
||||
$config->set('HTML.Nofollow', true);
|
||||
|
||||
$purifier = new HTMLPurifier($config);
|
||||
|
||||
$text = $purifier->purify($text);
|
||||
|
||||
foreach ($formatters as $formatter) {
|
||||
$text = $formatter->afterPurification($text, $post);
|
||||
}
|
||||
|
||||
return $text;
|
||||
|
@@ -1,8 +1,9 @@
|
||||
<?php namespace Flarum\Core\Formatter;
|
||||
|
||||
use Flarum\Core\Models\Post;
|
||||
use Misd\Linkify\Linkify;
|
||||
|
||||
class LinkifyFormatter
|
||||
class LinkifyFormatter extends FormatterAbstract
|
||||
{
|
||||
protected $linkify;
|
||||
|
||||
@@ -11,7 +12,7 @@ class LinkifyFormatter
|
||||
$this->linkify = $linkify;
|
||||
}
|
||||
|
||||
public function format($text)
|
||||
public function beforePurification($text, Post $post = null)
|
||||
{
|
||||
return $this->linkify->process($text, ['attr' => ['target' => '_blank']]);
|
||||
}
|
||||
|
@@ -3,6 +3,8 @@
|
||||
use Flarum\Core\Repositories\UserRepositoryInterface as UserRepository;
|
||||
use Flarum\Core\Events\UserWillBeSaved;
|
||||
use Flarum\Core\Support\DispatchesEvents;
|
||||
use Flarum\Core\Exceptions\InvalidConfirmationTokenException;
|
||||
use Flarum\Core\Models\EmailToken;
|
||||
|
||||
class ConfirmEmailCommandHandler
|
||||
{
|
||||
@@ -17,10 +19,14 @@ class ConfirmEmailCommandHandler
|
||||
|
||||
public function handle($command)
|
||||
{
|
||||
$user = $this->users->findOrFail($command->userId);
|
||||
$token = EmailToken::find($command->token)->first();
|
||||
|
||||
$user->assertConfirmationTokenValid($command->token);
|
||||
$user->confirmEmail();
|
||||
if (! $token) {
|
||||
throw new InvalidConfirmationTokenException;
|
||||
}
|
||||
|
||||
$user = $token->user;
|
||||
$user->changeEmail($token->email);
|
||||
|
||||
if (! $user->is_activated) {
|
||||
$user->activate();
|
||||
@@ -31,6 +37,8 @@ class ConfirmEmailCommandHandler
|
||||
$user->save();
|
||||
$this->dispatchEventsFor($user);
|
||||
|
||||
$token->delete();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
@@ -23,11 +23,12 @@ class EditUserCommandHandler
|
||||
$userToEdit->assertCan($user, 'edit');
|
||||
|
||||
if (isset($command->data['username'])) {
|
||||
$userToEdit->assertCan($user, 'rename');
|
||||
$userToEdit->rename($command->data['username']);
|
||||
}
|
||||
|
||||
if (isset($command->data['email'])) {
|
||||
$userToEdit->changeEmail($command->data['email']);
|
||||
$userToEdit->requestEmailChange($command->data['email']);
|
||||
}
|
||||
|
||||
if (isset($command->data['password'])) {
|
||||
|
@@ -1,10 +1,11 @@
|
||||
<?php namespace Flarum\Core\Handlers\Commands;
|
||||
|
||||
use Flarum\Core\Commands\RequestPasswordResetCommand;
|
||||
use Flarum\Core\Models\ResetToken;
|
||||
use Flarum\Core\Models\PasswordToken;
|
||||
use Flarum\Core\Repositories\UserRepositoryInterface;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Flarum\Core;
|
||||
|
||||
class RequestPasswordResetCommandHandler
|
||||
{
|
||||
@@ -34,15 +35,16 @@ class RequestPasswordResetCommandHandler
|
||||
throw new ModelNotFoundException;
|
||||
}
|
||||
|
||||
$token = ResetToken::generate($user->id);
|
||||
$token = PasswordToken::generate($user->id);
|
||||
$token->save();
|
||||
|
||||
$data = [
|
||||
'username' => $user->username,
|
||||
'url' => route('flarum.forum.resetPassword', ['token' => $token->id])
|
||||
'url' => route('flarum.forum.resetPassword', ['token' => $token->id]),
|
||||
'forumTitle' => Core::config('forum_title')
|
||||
];
|
||||
|
||||
$this->mailer->send(['text' => 'flarum::emails.reset'], $data, function ($message) use ($user) {
|
||||
$this->mailer->send(['text' => 'flarum::emails.resetPassword'], $data, function ($message) use ($user) {
|
||||
$message->to($user->email);
|
||||
$message->subject('Reset Your Password');
|
||||
});
|
||||
|
@@ -1,8 +1,9 @@
|
||||
<?php namespace Flarum\Core\Handlers\Events;
|
||||
|
||||
use Config;
|
||||
use Flarum\Core\Events\UserWasRegistered;
|
||||
use Flarum\Core\Events\EmailWasChanged;
|
||||
use Flarum\Core\Events\UserEmailChangeWasRequested;
|
||||
use Flarum\Core;
|
||||
use Flarum\Core\Models\EmailToken;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
|
||||
@@ -23,28 +24,47 @@ class EmailConfirmationMailer
|
||||
public function subscribe(Dispatcher $events)
|
||||
{
|
||||
$events->listen('Flarum\Core\Events\UserWasRegistered', __CLASS__.'@whenUserWasRegistered');
|
||||
$events->listen('Flarum\Core\Events\EmailWasChanged', __CLASS__.'@whenEmailWasChanged');
|
||||
$events->listen('Flarum\Core\Events\UserEmailChangeWasRequested', __CLASS__.'@whenUserEmailChangeWasRequested');
|
||||
}
|
||||
|
||||
public function whenUserWasRegistered(UserWasRegistered $event)
|
||||
{
|
||||
$user = $event->user;
|
||||
$data = $this->getPayload($user, $user->email);
|
||||
|
||||
$forumTitle = Config::get('flarum::forum_title');
|
||||
|
||||
$data = [
|
||||
'username' => $user->username,
|
||||
'forumTitle' => $forumTitle,
|
||||
'url' => route('flarum.forum.confirm', ['id' => $user->id, 'token' => $user->confirmation_token])
|
||||
];
|
||||
|
||||
$this->mailer->send(['text' => 'flarum::emails.confirm'], $data, function ($message) use ($user) {
|
||||
$this->mailer->send(['text' => 'flarum::emails.activateAccount'], $data, function ($message) use ($user) {
|
||||
$message->to($user->email);
|
||||
$message->subject('Confirm Your Email Address');
|
||||
$message->subject('Activate Your New Account');
|
||||
});
|
||||
}
|
||||
|
||||
public function whenEmailWasChanged(EmailWasChanged $event)
|
||||
public function whenUserEmailChangeWasRequested(UserEmailChangeWasRequested $event)
|
||||
{
|
||||
$email = $event->email;
|
||||
$data = $this->getPayload($event->user, $email);
|
||||
|
||||
$this->mailer->send(['text' => 'flarum::emails.confirmEmail'], $data, function ($message) use ($email) {
|
||||
$message->to($email);
|
||||
$message->subject('Confirm Your New Email Address');
|
||||
});
|
||||
}
|
||||
|
||||
protected function generateToken($user, $email)
|
||||
{
|
||||
$token = EmailToken::generate($user->id, $email);
|
||||
$token->save();
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
protected function getPayload($user, $email)
|
||||
{
|
||||
$token = $this->generateToken($user, $email);
|
||||
|
||||
return [
|
||||
'username' => $user->username,
|
||||
'url' => route('flarum.forum.confirmEmail', ['token' => $token->id]),
|
||||
'forumTitle' => Core::config('forum_title')
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -16,18 +16,28 @@ class AccessToken extends Model
|
||||
*/
|
||||
public $incrementing = false;
|
||||
|
||||
/**
|
||||
* The attributes that should be mutated to dates.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $dates = ['created_at', 'expires_at'];
|
||||
|
||||
/**
|
||||
* Generate an access token for the specified user.
|
||||
*
|
||||
* @param int $userId
|
||||
* @param int $minutes
|
||||
* @return static
|
||||
*/
|
||||
public static function generate($userId)
|
||||
public static function generate($userId, $minutes = 60)
|
||||
{
|
||||
$token = new static;
|
||||
|
||||
$token->id = str_random(40);
|
||||
$token->user_id = $userId;
|
||||
$token->created_at = time();
|
||||
$token->expires_at = time() + $minutes * 60;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
@@ -119,17 +119,6 @@ class CommentPost extends Post
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content formatter as HTML.
|
||||
*
|
||||
* @param string $value
|
||||
* @return string
|
||||
*/
|
||||
public function getContentPlainAttribute()
|
||||
{
|
||||
return static::$formatter->strip($this->content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text formatter instance.
|
||||
*
|
||||
|
@@ -295,6 +295,11 @@ class Discussion extends Model
|
||||
*/
|
||||
public function stateFor(User $user)
|
||||
{
|
||||
$loadedState = array_get($this->relations, 'state');
|
||||
if ($loadedState && $loadedState->user_id === $user->id) {
|
||||
return $loadedState;
|
||||
}
|
||||
|
||||
$state = $this->state($user)->first();
|
||||
|
||||
if (! $state) {
|
||||
|
46
src/Core/Models/EmailToken.php
Normal file
46
src/Core/Models/EmailToken.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php namespace Flarum\Core\Models;
|
||||
|
||||
class EmailToken extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'email_tokens';
|
||||
|
||||
/**
|
||||
* Use a custom primary key for this model.
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
public $incrementing = false;
|
||||
|
||||
/**
|
||||
* Generate a reset token for the specified user.
|
||||
*
|
||||
* @param int $userId
|
||||
* @return static
|
||||
*/
|
||||
public static function generate($userId, $email)
|
||||
{
|
||||
$token = new static;
|
||||
|
||||
$token->id = str_random(40);
|
||||
$token->user_id = $userId;
|
||||
$token->email = $email;
|
||||
$token->created_at = time();
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the relationship with the owner of this reset token.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo('Flarum\Core\Models\User');
|
||||
}
|
||||
}
|
@@ -110,14 +110,19 @@ class Model extends Eloquent
|
||||
*/
|
||||
public function assertValid()
|
||||
{
|
||||
$validation = $this->makeValidator();
|
||||
if ($validation->fails()) {
|
||||
throw (new ValidationFailureException)
|
||||
->setErrors($validation->errors())
|
||||
->setInput($validation->getData());
|
||||
$validator = $this->makeValidator();
|
||||
if ($validator->fails()) {
|
||||
$this->throwValidationFailureException($validator);
|
||||
}
|
||||
}
|
||||
|
||||
protected function throwValidationFailureException($validator)
|
||||
{
|
||||
throw (new ValidationFailureException)
|
||||
->setErrors($validator->errors())
|
||||
->setInput($validator->getData());
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a new validator instance for this model.
|
||||
*
|
||||
@@ -125,9 +130,11 @@ class Model extends Eloquent
|
||||
*/
|
||||
protected function makeValidator()
|
||||
{
|
||||
$rules = $this->expandUniqueRules(static::$rules);
|
||||
$dirty = $this->getDirty();
|
||||
|
||||
return static::$validator->make($this->attributes, $rules);
|
||||
$rules = $this->expandUniqueRules(array_only(static::$rules, array_keys($dirty)));
|
||||
|
||||
return static::$validator->make($dirty, $rules);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -1,13 +1,13 @@
|
||||
<?php namespace Flarum\Core\Models;
|
||||
|
||||
class ResetToken extends Model
|
||||
class PasswordToken extends Model
|
||||
{
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'reset_tokens';
|
||||
protected $table = 'password_tokens';
|
||||
|
||||
/**
|
||||
* Use a custom primary key for this model.
|
||||
@@ -28,6 +28,7 @@ class ResetToken extends Model
|
||||
|
||||
$token->id = str_random(40);
|
||||
$token->user_id = $userId;
|
||||
$token->created_at = time();
|
||||
|
||||
return $token;
|
||||
}
|
@@ -13,6 +13,7 @@ use Flarum\Core\Events\UserBioWasChanged;
|
||||
use Flarum\Core\Events\UserAvatarWasChanged;
|
||||
use Flarum\Core\Events\UserWasActivated;
|
||||
use Flarum\Core\Events\UserEmailWasConfirmed;
|
||||
use Flarum\Core\Events\UserEmailChangeWasRequested;
|
||||
|
||||
class User extends Model
|
||||
{
|
||||
@@ -31,7 +32,7 @@ class User extends Model
|
||||
* @var array
|
||||
*/
|
||||
public static $rules = [
|
||||
'username' => 'required|unique',
|
||||
'username' => 'required|alpha_dash|unique',
|
||||
'email' => 'required|email|unique',
|
||||
'password' => 'required',
|
||||
'join_time' => 'date',
|
||||
@@ -94,8 +95,6 @@ class User extends Model
|
||||
$user->password = $password;
|
||||
$user->join_time = time();
|
||||
|
||||
$user->refreshConfirmationToken();
|
||||
|
||||
$user->raise(new UserWasRegistered($user));
|
||||
|
||||
return $user;
|
||||
@@ -111,6 +110,7 @@ class User extends Model
|
||||
{
|
||||
if ($username !== $this->username) {
|
||||
$this->username = $username;
|
||||
|
||||
$this->raise(new UserWasRenamed($this));
|
||||
}
|
||||
|
||||
@@ -127,12 +127,31 @@ class User extends Model
|
||||
{
|
||||
if ($email !== $this->email) {
|
||||
$this->email = $email;
|
||||
|
||||
$this->raise(new UserEmailWasChanged($this));
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function requestEmailChange($email)
|
||||
{
|
||||
if ($email !== $this->email) {
|
||||
$validator = static::$validator->make(
|
||||
compact('email'),
|
||||
$this->expandUniqueRules(array_only(static::$rules, 'email'))
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$this->throwValidationFailureException($validator);
|
||||
}
|
||||
|
||||
$this->raise(new UserEmailChangeWasRequested($this, $email));
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the user's password.
|
||||
*
|
||||
@@ -155,7 +174,7 @@ class User extends Model
|
||||
*/
|
||||
public function setPasswordAttribute($value)
|
||||
{
|
||||
$this->attributes['password'] = $value ? static::$hasher->make($value) : null;
|
||||
$this->attributes['password'] = $value ? static::$hasher->make($value) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,41 +276,12 @@ class User extends Model
|
||||
public function activate()
|
||||
{
|
||||
$this->is_activated = true;
|
||||
$this->groups()->sync([3]);
|
||||
|
||||
$this->raise(new UserWasActivated($this));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given confirmation token is valid for this user.
|
||||
*
|
||||
* @param string $token
|
||||
* @return boolean
|
||||
*/
|
||||
public function assertConfirmationTokenValid($token)
|
||||
{
|
||||
if ($this->is_confirmed ||
|
||||
! $token ||
|
||||
$this->confirmation_token !== $token) {
|
||||
throw new InvalidConfirmationTokenException;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new confirmation token for the user.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function refreshConfirmationToken()
|
||||
{
|
||||
$this->is_confirmed = false;
|
||||
$this->confirmation_token = str_random(30);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's email.
|
||||
*
|
||||
@@ -461,7 +451,13 @@ class User extends Model
|
||||
*/
|
||||
public function permissions()
|
||||
{
|
||||
return Permission::whereIn('group_id', array_merge([Group::GUEST_ID], $this->groups->lists('id')));
|
||||
$groupIds = [Group::GUEST_ID];
|
||||
|
||||
if ($this->is_activated) {
|
||||
$groupIds = array_merge($groupIds, [Group::MEMBER_ID], $this->groups->lists('id'));
|
||||
}
|
||||
|
||||
return Permission::whereIn('group_id', $groupIds);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
use Flarum\Core\Repositories\NotificationRepositoryInterface;
|
||||
use Flarum\Core\Models\Notification;
|
||||
use Flarum\Core\Events\NotificationWillBeSent;
|
||||
use Carbon\Carbon;
|
||||
use Closure;
|
||||
|
||||
@@ -66,6 +67,8 @@ class NotificationSyncer
|
||||
if (count($newRecipients)) {
|
||||
$now = Carbon::now('utc')->toDateTimeString();
|
||||
|
||||
event(new NotificationWillBeSent($notification, $newRecipients));
|
||||
|
||||
Notification::insert(
|
||||
array_map(function ($user) use ($attributes, $notification, $now) {
|
||||
return $attributes + ['user_id' => $user->id, 'time' => $now];
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user