mirror of
https://github.com/flarum/core.git
synced 2025-08-04 15:37:51 +02:00
Upgrade to Ember 1.11-beta.1
HTMLBars goodness! Since there was some breakage and a lot of fiddling around to get some things working, I took this opportunity to do a big cleanup of the whole Ember app. I accidentally worked on some new features too :3 Note that the app is still broken right now, pending on https://github.com/emberjs/ember.js/issues/10401 Cleanup: - Restructuring of components - Consolidation of some stuff into mixins, cleanup of some APIs that will be public - Change all instances of .property() / .observes() / .on() to Ember.computed() / Ember.observer() / Ember.on() respectively (I think it is more readable) - More comments - Start conforming to a code style (2 spaces for indentation) New features: - Post hiding/restoring - Mark individual discussions as read by clicking - Clicking on a read discussion jumps to the end - Mark all discussions as read - Progressively mark the discussion as read as the page is scrolled - Unordered list post formatting - Post permalink popup Demo once that Ember regression is fixed!
This commit is contained in:
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
var EmberApp = require('ember-cli/lib/broccoli/ember-app');
|
var EmberApp = require('ember-cli/lib/broccoli/ember-app');
|
||||||
|
|
||||||
var app = new EmberApp();
|
var app = new EmberApp({
|
||||||
|
vendorFiles: {
|
||||||
|
'handlebars.js': null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.import('bower_components/ember/ember-template-compiler.js');
|
||||||
|
|
||||||
app.import('bower_components/bootstrap/dist/js/bootstrap.js');
|
app.import('bower_components/bootstrap/dist/js/bootstrap.js');
|
||||||
app.import('bower_components/spin.js/spin.js');
|
app.import('bower_components/spin.js/spin.js');
|
||||||
|
53
ember/README.md
Normal file
53
ember/README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Flarum
|
||||||
|
|
||||||
|
This README outlines the details of collaborating on this Ember application.
|
||||||
|
A short introduction of this app could easily go here.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
You will need the following things properly installed on your computer.
|
||||||
|
|
||||||
|
* [Git](http://git-scm.com/)
|
||||||
|
* [Node.js](http://nodejs.org/) (with NPM)
|
||||||
|
* [Bower](http://bower.io/)
|
||||||
|
* [Ember CLI](http://www.ember-cli.com/)
|
||||||
|
* [PhantomJS](http://phantomjs.org/)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
* `git clone <repository-url>` this repository
|
||||||
|
* change into the new directory
|
||||||
|
* `npm install`
|
||||||
|
* `bower install`
|
||||||
|
|
||||||
|
## Running / Development
|
||||||
|
|
||||||
|
* `ember server`
|
||||||
|
* Visit your app at [http://localhost:4200](http://localhost:4200).
|
||||||
|
|
||||||
|
### Code Generators
|
||||||
|
|
||||||
|
Make use of the many generators for code, try `ember help generate` for more details
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
* `ember test`
|
||||||
|
* `ember test --server`
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
* `ember build` (development)
|
||||||
|
* `ember build --environment production` (production)
|
||||||
|
|
||||||
|
### Deploying
|
||||||
|
|
||||||
|
Specify what it takes to deploy your app.
|
||||||
|
|
||||||
|
## Further Reading / Useful Links
|
||||||
|
|
||||||
|
* [ember.js](http://emberjs.com/)
|
||||||
|
* [ember-cli](http://www.ember-cli.com/)
|
||||||
|
* Development Browser Extensions
|
||||||
|
* [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi)
|
||||||
|
* [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)
|
||||||
|
|
@@ -1,24 +1,38 @@
|
|||||||
import DS from 'ember-data';
|
import DS from 'ember-data';
|
||||||
import JsonApiAdapter from 'ember-json-api/json-api-adapter';
|
import JsonApiAdapter from 'ember-json-api/json-api-adapter';
|
||||||
import config from '../config/environment';
|
|
||||||
|
import config from 'flarum/config/environment';
|
||||||
|
import AlertMessage from 'flarum/components/ui/alert-message';
|
||||||
|
|
||||||
export default JsonApiAdapter.extend({
|
export default JsonApiAdapter.extend({
|
||||||
host: config.apiURL,
|
host: config.apiURL,
|
||||||
|
|
||||||
ajaxError: function(jqXHR) {
|
ajaxError: function(jqXHR) {
|
||||||
var errors = this._super(jqXHR);
|
var errors = this._super(jqXHR);
|
||||||
|
|
||||||
|
// Reparse the errors in accordance with the JSON-API spec to fit with
|
||||||
|
// Ember Data style. Hopefully something like this will eventually be a
|
||||||
|
// part of the JsonApiAdapter.
|
||||||
if (errors instanceof DS.InvalidError) {
|
if (errors instanceof DS.InvalidError) {
|
||||||
var newErrors = {};
|
var newErrors = {};
|
||||||
for (var i in errors.errors) {
|
for (var i in errors.errors) {
|
||||||
var error = errors.errors[i];
|
var error = errors.errors[i];
|
||||||
newErrors[error.path] = error.detail;
|
newErrors[error.path] = error.detail;
|
||||||
}
|
}
|
||||||
errors = new DS.InvalidError(newErrors);
|
return new DS.InvalidError(newErrors);
|
||||||
} else if (errors instanceof JsonApiAdapter.ServerError) {
|
|
||||||
// @todo show an alert message
|
|
||||||
console.log(errors);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If it's a server error, show an alert message. The alerts controller
|
||||||
|
// has been injected into this adapter.
|
||||||
|
if (errors instanceof JsonApiAdapter.ServerError) {
|
||||||
|
var message = AlertMessage.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: errors.message
|
||||||
|
});
|
||||||
|
this.get('alerts').send('alert', message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -1,44 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
import TaggedArray from '../utils/tagged-array';
|
|
||||||
import ActionButton from 'flarum/components/ui/controls/action-button';
|
|
||||||
|
|
||||||
export default Ember.Component.extend(Ember.Evented, {
|
|
||||||
message: '',
|
|
||||||
type: '',
|
|
||||||
dismissable: true,
|
|
||||||
|
|
||||||
layoutName: 'components/alert-message',
|
|
||||||
classNames: ['alert'],
|
|
||||||
classNameBindings: ['classForType'],
|
|
||||||
|
|
||||||
classForType: function() {
|
|
||||||
return 'alert-'+this.get('type');
|
|
||||||
}.property('type'),
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
var controls = TaggedArray.create();
|
|
||||||
this.trigger('populateControls', controls);
|
|
||||||
this.set('controls', controls);
|
|
||||||
},
|
|
||||||
|
|
||||||
populateControls: function(controls) {
|
|
||||||
if (this.get('dismissable')) {
|
|
||||||
var component = this;
|
|
||||||
var dismiss = ActionButton.create({
|
|
||||||
icon: 'times',
|
|
||||||
className: 'btn btn-icon btn-link',
|
|
||||||
action: function() {
|
|
||||||
component.send('dismiss');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
controls.pushObjectWithTag(dismiss, 'dismiss');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
dismiss: function() {
|
|
||||||
this.sendAction('dismiss', this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
30
ember/app/components/application/back-button.js
Executable file
30
ember/app/components/application/back-button.js
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
/**
|
||||||
|
The back/pin button group in the top-left corner of Flarum's interface.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
classNames: ['back-button'],
|
||||||
|
classNameBindings: ['active'],
|
||||||
|
|
||||||
|
active: Ember.computed.or('target.paneIsShowing', 'target.paneIsPinned'),
|
||||||
|
|
||||||
|
mouseEnter: function() {
|
||||||
|
this.get('target').send('showPane');
|
||||||
|
},
|
||||||
|
|
||||||
|
mouseLeave: function() {
|
||||||
|
this.get('target').send('hidePane');
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
back: function() {
|
||||||
|
this.get('target').send('transitionFromBackButton');
|
||||||
|
this.set('target', null);
|
||||||
|
},
|
||||||
|
|
||||||
|
togglePinned: function() {
|
||||||
|
this.get('target').send('togglePinned');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
7
ember/app/components/application/forum-statistic.js
Normal file
7
ember/app/components/application/forum-statistic.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
layout: precompileTemplate('{{number}} {{label}}')
|
||||||
|
});
|
12
ember/app/components/application/go-to-top.js
Normal file
12
ember/app/components/application/go-to-top.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import ActionButton from 'flarum/components/ui/action-button';
|
||||||
|
|
||||||
|
export default ActionButton.extend({
|
||||||
|
title: 'Go to Top',
|
||||||
|
icon: 'arrow-up',
|
||||||
|
className: 'control-top',
|
||||||
|
action: function() {
|
||||||
|
$('html, body').stop(true).animate({scrollTop: 0});
|
||||||
|
}
|
||||||
|
})
|
7
ember/app/components/application/powered-by.js
Normal file
7
ember/app/components/application/powered-by.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
layout: precompileTemplate('<a href="http://flarum.org" target="_blank">Powered by Flarum</a>')
|
||||||
|
});
|
27
ember/app/components/application/user-dropdown.js
Normal file
27
ember/app/components/application/user-dropdown.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import HasItemLists from 'flarum/mixins/has-item-lists';
|
||||||
|
import DropdownButton from 'flarum/components/ui/dropdown-button';
|
||||||
|
import SeparatorItem from 'flarum/components/ui/separator-item';
|
||||||
|
|
||||||
|
export default DropdownButton.extend(HasItemLists, {
|
||||||
|
layoutName: 'components/application/user-dropdown',
|
||||||
|
itemLists: ['items'],
|
||||||
|
|
||||||
|
buttonClass: 'btn btn-default btn-naked btn-rounded btn-user',
|
||||||
|
menuClass: 'pull-right',
|
||||||
|
label: Ember.computed.alias('user.username'),
|
||||||
|
|
||||||
|
populateItems: function(items) {
|
||||||
|
this.addActionItem(items, 'profile', 'Profile', 'user');
|
||||||
|
this.addActionItem(items, 'settings', 'Settings', 'cog');
|
||||||
|
items.pushObject(SeparatorItem.create());
|
||||||
|
this.addActionItem(items, 'logout', 'Log Out', 'sign-out', null, null, this);
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
logout: function() {
|
||||||
|
this.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
@@ -1,27 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
classNames: ['back-button'],
|
|
||||||
classNameBindings: ['active'],
|
|
||||||
|
|
||||||
active: Ember.computed.or('target.paneIsShowing', 'target.paneIsPinned'),
|
|
||||||
|
|
||||||
mouseEnter: function() {
|
|
||||||
this.get('target').send('showPane');
|
|
||||||
},
|
|
||||||
|
|
||||||
mouseLeave: function() {
|
|
||||||
this.get('target').send('hidePane');
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
back: function() {
|
|
||||||
this.get('target').send('transitionFromBackButton');
|
|
||||||
this.set('target', null);
|
|
||||||
},
|
|
||||||
|
|
||||||
togglePinned: function() {
|
|
||||||
this.get('target').send('togglePinned');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
40
ember/app/components/composer/composer-body.js
Normal file
40
ember/app/components/composer/composer-body.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import HasItemLists from 'flarum/mixins/has-item-lists';
|
||||||
|
|
||||||
|
/**
|
||||||
|
This component is a base class for a composer body. It provides a template
|
||||||
|
with a list of controls, a text editor, and some default behaviour.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend(HasItemLists, {
|
||||||
|
layoutName: 'components/composer/composer-body',
|
||||||
|
|
||||||
|
itemLists: ['controls'],
|
||||||
|
|
||||||
|
submitLabel: '',
|
||||||
|
placeholder: '',
|
||||||
|
content: '',
|
||||||
|
originalContent: '',
|
||||||
|
user: null,
|
||||||
|
submit: null,
|
||||||
|
loading: false,
|
||||||
|
confirmExit: '',
|
||||||
|
|
||||||
|
disabled: Ember.computed.alias('composer.minimized'),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
submit: function(content) {
|
||||||
|
this.get('submit')({
|
||||||
|
content: content
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
willExit: function(abort) {
|
||||||
|
// If the user has typed something, prompt them before exiting
|
||||||
|
// this composer state.
|
||||||
|
if (this.get('content') !== this.get('originalContent') && !confirm(this.get('confirmExit'))) {
|
||||||
|
abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
41
ember/app/components/composer/composer-discussion.js
Normal file
41
ember/app/components/composer/composer-discussion.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import ComposerBody from 'flarum/components/composer/composer-body';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
The composer body for starting a new discussion. Adds a text field as a
|
||||||
|
control so the user can enter the title of their discussion. Also overrides
|
||||||
|
the `submit` and `willExit` actions to account for the title.
|
||||||
|
*/
|
||||||
|
export default ComposerBody.extend({
|
||||||
|
submitLabel: 'Post Discussion',
|
||||||
|
confirmExit: 'You have not posted your discussion. Do you wish to discard it?',
|
||||||
|
titlePlaceholder: 'Discussion Title',
|
||||||
|
title: '',
|
||||||
|
|
||||||
|
populateControls: function(items) {
|
||||||
|
var title = Ember.Component.create({
|
||||||
|
tagName: 'h3',
|
||||||
|
layout: precompileTemplate('{{ui/text-input value=component.title class="form-control" placeholder=component.titlePlaceholder disabled=component.disabled autoGrow=true}}'),
|
||||||
|
component: this
|
||||||
|
});
|
||||||
|
items.pushObjectWithTag(title, 'title');
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
submit: function(content) {
|
||||||
|
this.get('submit')({
|
||||||
|
title: this.get('title'),
|
||||||
|
content: content
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
willExit: function(abort) {
|
||||||
|
if ((this.get('title') || this.get('content')) && !confirm(this.get('confirmExit'))) {
|
||||||
|
abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
26
ember/app/components/composer/composer-edit.js
Normal file
26
ember/app/components/composer/composer-edit.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import ComposerBody from 'flarum/components/composer/composer-body';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
The composer body for editing a post. Sets the initial content to the
|
||||||
|
content of the post that is being edited, and adds a title control to
|
||||||
|
indicate which post is being edited.
|
||||||
|
*/
|
||||||
|
export default ComposerBody.extend({
|
||||||
|
submitLabel: 'Save Changes',
|
||||||
|
content: Ember.computed.oneWay('post.content'),
|
||||||
|
originalContent: Ember.computed.oneWay('post.content'),
|
||||||
|
|
||||||
|
populateControls: function(controls) {
|
||||||
|
var title = Ember.Component.create({
|
||||||
|
tagName: 'h3',
|
||||||
|
layout: precompileTemplate('Editing Post #{{component.post.number}} in <em>{{discussion.title}}</em>'),
|
||||||
|
discussion: this.get('post.discussion'),
|
||||||
|
component: this
|
||||||
|
});
|
||||||
|
controls.pushObjectWithTag(title, 'title');
|
||||||
|
}
|
||||||
|
});
|
22
ember/app/components/composer/composer-reply.js
Normal file
22
ember/app/components/composer/composer-reply.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import ComposerBody from 'flarum/components/composer/composer-body';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
The composer body for posting a reply. Adds a title control to indicate
|
||||||
|
which discussion is being replied to.
|
||||||
|
*/
|
||||||
|
export default ComposerBody.extend({
|
||||||
|
submitLabel: 'Post Reply',
|
||||||
|
|
||||||
|
populateControls: function(items) {
|
||||||
|
var title = Ember.Component.create({
|
||||||
|
tagName: 'h3',
|
||||||
|
layout: precompileTemplate('Replying to <em>{{component.discussion.title}}</em>'),
|
||||||
|
component: this
|
||||||
|
});
|
||||||
|
items.pushObjectWithTag(title, 'title');
|
||||||
|
}
|
||||||
|
});
|
107
ember/app/components/discussion/post-comment.js
Normal file
107
ember/app/components/discussion/post-comment.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import UseComposer from 'flarum/mixins/use-composer';
|
||||||
|
import FadeIn from 'flarum/mixins/fade-in';
|
||||||
|
import HasItemLists from 'flarum/mixins/has-item-lists';
|
||||||
|
import ComposerEdit from 'flarum/components/composer/composer-edit';
|
||||||
|
import PostHeaderUser from 'flarum/components/discussion/post-header/user';
|
||||||
|
import PostHeaderMeta from 'flarum/components/discussion/post-header/meta';
|
||||||
|
import PostHeaderEdited from 'flarum/components/discussion/post-header/edited';
|
||||||
|
import PostHeaderToggle from 'flarum/components/discussion/post-header/toggle';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
Component for a `comment`-typed post. Displays a number of item lists
|
||||||
|
(controls, header, and footer) surrounding the post's HTML content. Allows
|
||||||
|
the post to be edited with the composer, hidden, or restored.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend(FadeIn, HasItemLists, UseComposer, {
|
||||||
|
layoutName: 'components/discussion/post-comment',
|
||||||
|
tagName: 'article',
|
||||||
|
classNames: ['post', 'post-comment'],
|
||||||
|
classNameBindings: [
|
||||||
|
'post.isHidden:deleted',
|
||||||
|
'post.isEdited:edited',
|
||||||
|
'revealContent:reveal-content'
|
||||||
|
],
|
||||||
|
itemLists: ['controls', 'header', 'footer'],
|
||||||
|
|
||||||
|
// The stream-content component instansiates this component and sets the
|
||||||
|
// `content` property to the content of the item in the post-stream object.
|
||||||
|
// This happens to be our post model!
|
||||||
|
post: Ember.computed.alias('content'),
|
||||||
|
|
||||||
|
populateControls: function(items) {
|
||||||
|
if (this.get('post.isHidden')) {
|
||||||
|
this.addActionItem(items, 'restore', 'Restore', 'reply', 'post.canEdit');
|
||||||
|
this.addActionItem(items, 'delete', 'Delete', 'times', 'post.canDelete');
|
||||||
|
} else {
|
||||||
|
this.addActionItem(items, 'edit', 'Edit', 'pencil', 'post.canEdit');
|
||||||
|
this.addActionItem(items, 'hide', 'Delete', 'times', 'post.canEdit');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Since we statically populated controls based on the value of
|
||||||
|
// `post.isHidden`, we'll need to refresh them every time that property
|
||||||
|
// changes.
|
||||||
|
refreshControls: Ember.observer('post.isHidden', function() {
|
||||||
|
this.initItemList('controls');
|
||||||
|
}),
|
||||||
|
|
||||||
|
populateHeader: function(items) {
|
||||||
|
var properties = this.getProperties('post');
|
||||||
|
items.pushObjectWithTag(PostHeaderUser.create(properties), 'user');
|
||||||
|
items.pushObjectWithTag(PostHeaderMeta.create(properties), 'meta');
|
||||||
|
items.pushObjectWithTag(PostHeaderEdited.create(properties), 'edited');
|
||||||
|
items.pushObjectWithTag(PostHeaderToggle.create(properties, {parent: this}), 'toggle');
|
||||||
|
},
|
||||||
|
|
||||||
|
savePost: function(post, data) {
|
||||||
|
post.setProperties(data);
|
||||||
|
return this.saveAndDismissComposer(post);
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// In the template, we render the "controls" dropdown with the contents of
|
||||||
|
// the `renderControls` property. This way, when a post is initially
|
||||||
|
// rendered, it doesn't have to go to the trouble of rendering the
|
||||||
|
// controls right away, which speeds things up. When the dropdown button
|
||||||
|
// is clicked, this will fill in the actual controls.
|
||||||
|
renderControls: function() {
|
||||||
|
this.set('renderControls', this.get('controls'));
|
||||||
|
},
|
||||||
|
|
||||||
|
edit: function() {
|
||||||
|
var post = this.get('post');
|
||||||
|
var component = this;
|
||||||
|
this.showComposer(function() {
|
||||||
|
return ComposerEdit.create({
|
||||||
|
user: post.get('user'),
|
||||||
|
post: post,
|
||||||
|
submit: function(data) { component.savePost(post, data); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
hide: function() {
|
||||||
|
var post = this.get('post');
|
||||||
|
post.setProperties({
|
||||||
|
isHidden: true,
|
||||||
|
deleteTime: new Date,
|
||||||
|
deleteUser: this.get('session.user')
|
||||||
|
});
|
||||||
|
post.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
restore: function() {
|
||||||
|
var post = this.get('post');
|
||||||
|
post.setProperties({
|
||||||
|
isHidden: false,
|
||||||
|
deleteTime: null,
|
||||||
|
deleteUser: null
|
||||||
|
});
|
||||||
|
post.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
38
ember/app/components/discussion/post-header/edited.js
Normal file
38
ember/app/components/discussion/post-header/edited.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import humanTime from 'flarum/utils/human-time';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
Component for the edited pencil icon in a post header. Shows a tooltip on
|
||||||
|
hover which details who edited the post and when.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
tagName: 'span',
|
||||||
|
classNames: ['post-edited'],
|
||||||
|
attributeBindings: ['title'],
|
||||||
|
layout: precompileTemplate('{{fa-icon "pencil"}}'),
|
||||||
|
|
||||||
|
title: Ember.computed('post.editTime', 'post.editUser', function() {
|
||||||
|
return 'Edited by '+this.get('post.editUser.username')+' '+humanTime(this.get('post.editTime'));
|
||||||
|
}),
|
||||||
|
|
||||||
|
// In the context of an item list, this item will be hidden if the post
|
||||||
|
// hasn't been edited, or if it's been hidden.
|
||||||
|
hideItem: Ember.computed('post.isEdited', 'post.isHidden', function() {
|
||||||
|
return !this.get('post.isEdited') || this.get('post.isHidden');
|
||||||
|
}),
|
||||||
|
|
||||||
|
didInsertElement: function() {
|
||||||
|
this.$().tooltip();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Whenever the title changes, we need to tell the tooltip to update to
|
||||||
|
// reflect the new value.
|
||||||
|
updateTooltip: Ember.observer('title', function() {
|
||||||
|
Ember.run.scheduleOnce('afterRender', this, function() {
|
||||||
|
this.$().tooltip('fixTitle');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
36
ember/app/components/discussion/post-header/meta.js
Normal file
36
ember/app/components/discussion/post-header/meta.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var $ = Ember.$;
|
||||||
|
|
||||||
|
/**
|
||||||
|
Component for the meta part of a post header. Displays the time, and when
|
||||||
|
clicked, shows a dropdown containing more information about the post
|
||||||
|
(number, full time, permalink).
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
tagName: 'li',
|
||||||
|
classNames: ['dropdown'],
|
||||||
|
layoutName: 'components/discussion/post-header/time',
|
||||||
|
|
||||||
|
// Construct a permalink by looking up the router in the container, and
|
||||||
|
// using it to generate a link to this post within its discussion.
|
||||||
|
permalink: Ember.computed('post.discusion', 'post.number', function() {
|
||||||
|
var router = this.get('controller').container.lookup('router:main');
|
||||||
|
var path = router.generate('discussion', this.get('post.discussion'), {queryParams: {start: this.get('post.number')}});
|
||||||
|
return window.location.origin+path;
|
||||||
|
}),
|
||||||
|
|
||||||
|
didInsertElement: function() {
|
||||||
|
// When the dropdown menu is shown, select the contents of the permalink
|
||||||
|
// input so that the user can quickly copy the URL.
|
||||||
|
var component = this;
|
||||||
|
this.$('a').click(function() {
|
||||||
|
setTimeout(function() { component.$('.permalink').select(); }, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent clicking on the dropdown menu from closing it.
|
||||||
|
this.$('.dropdown-menu').click(function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
21
ember/app/components/discussion/post-header/toggle.js
Normal file
21
ember/app/components/discussion/post-header/toggle.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
Component for the toggle button in a post header. Toggles the
|
||||||
|
`parent.revealContent` property when clicked. Only displays if the supplied
|
||||||
|
post is not hidden.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
tagName: 'li',
|
||||||
|
layout: precompileTemplate('<a href="#" class="btn btn-default btn-more" {{action "toggle"}}>{{fa-icon "ellipsis-h"}}</a>'),
|
||||||
|
|
||||||
|
hideItem: Ember.computed.not('post.isHidden'),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
toggle: function() {
|
||||||
|
this.toggleProperty('parent.revealContent');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
12
ember/app/components/discussion/post-header/user.js
Normal file
12
ember/app/components/discussion/post-header/user.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
Component for the username/avatar in a post header.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
tagName: 'h3',
|
||||||
|
classNames: ['post-user'],
|
||||||
|
layout: precompileTemplate('{{#link-to "user" post.user}}{{user-avatar post.user}} {{post.user.username}}{{/link-to}}')
|
||||||
|
});
|
293
ember/app/components/discussion/stream-content.js
Normal file
293
ember/app/components/discussion/stream-content.js
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var $ = Ember.$;
|
||||||
|
|
||||||
|
/**
|
||||||
|
Component which renders items in a `post-stream` object. It handles scroll
|
||||||
|
events so that when the user scrolls to the top/bottom of the page, more
|
||||||
|
posts will load. In doing this is also sends an action so that the parent
|
||||||
|
controller's state can be updated. Finally, it can be sent actions to jump
|
||||||
|
to a certain position in the stream and load posts there.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
classNames: ['stream'],
|
||||||
|
|
||||||
|
// The stream object.
|
||||||
|
stream: null,
|
||||||
|
|
||||||
|
// Pause window scroll event listeners. This is set to true while loading
|
||||||
|
// posts, because we don't want a scroll event to trigger another block of
|
||||||
|
// posts to be loaded.
|
||||||
|
paused: false,
|
||||||
|
|
||||||
|
// Whether or not the stream's initial content has loaded.
|
||||||
|
loaded: Ember.computed.bool('stream.loadedCount'),
|
||||||
|
|
||||||
|
// When the stream content is not "active", window scroll event listeners
|
||||||
|
// will be ignored. For the stream content to be active, its initial
|
||||||
|
// content must be loaded and it must not be "paused".
|
||||||
|
active: Ember.computed('loaded', 'paused', function() {
|
||||||
|
return this.get('loaded') && !this.get('paused');
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Whenever the stream object changes (i.e. we have transitioned to a
|
||||||
|
// different discussion), pause events and cancel any pending state updates.
|
||||||
|
refresh: Ember.observer('stream', function() {
|
||||||
|
this.set('paused', true);
|
||||||
|
clearTimeout(this.updateStateTimeout);
|
||||||
|
}),
|
||||||
|
|
||||||
|
didInsertElement: function() {
|
||||||
|
$(window).on('scroll', {view: this}, this.windowWasScrolled);
|
||||||
|
},
|
||||||
|
|
||||||
|
willDestroyElement: function() {
|
||||||
|
$(window).off('scroll', this.windowWasScrolled);
|
||||||
|
},
|
||||||
|
|
||||||
|
windowWasScrolled: function(event) {
|
||||||
|
event.data.view.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Run any checks/updates according to the window's current scroll
|
||||||
|
// position. We check to see if any terminal 'gaps' are in the viewport
|
||||||
|
// and trigger their loading mechanism if they are. We also update the
|
||||||
|
// controller's 'start' query param with the current position. Note: this
|
||||||
|
// observes the 'active' property, so if the stream is 'unpaused', then an
|
||||||
|
// update will be triggered.
|
||||||
|
update: Ember.observer('active', function() {
|
||||||
|
if (!this.get('active')) { return; }
|
||||||
|
|
||||||
|
var $items = this.$().find('.item'),
|
||||||
|
$window = $(window),
|
||||||
|
marginTop = this.getMarginTop(),
|
||||||
|
scrollTop = $window.scrollTop() + marginTop,
|
||||||
|
viewportHeight = $window.height() - marginTop,
|
||||||
|
loadAheadDistance = 300,
|
||||||
|
startNumber,
|
||||||
|
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(true);
|
||||||
|
|
||||||
|
// 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 gapView = Ember.View.views[$this.attr('id')];
|
||||||
|
if ($this.is(':first-child')) {
|
||||||
|
gapView.set('direction', 'up').load();
|
||||||
|
} else if ($this.is(':last-child')) {
|
||||||
|
gapView.set('direction', 'down').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.
|
||||||
|
var view = this;
|
||||||
|
clearTimeout(this.updateStateTimeout);
|
||||||
|
this.updateStateTimeout = setTimeout(function() {
|
||||||
|
view.sendAction('positionChanged', startNumber || 1, endNumber);
|
||||||
|
}, 500);
|
||||||
|
}),
|
||||||
|
|
||||||
|
loadingNumber: function(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.set('paused', true);
|
||||||
|
this.jumpToNumber(number, noAnimation);
|
||||||
|
},
|
||||||
|
|
||||||
|
loadedNumber: function(number, noAnimation) {
|
||||||
|
// The post with this number has been loaded. After we scroll to this
|
||||||
|
// post, we want to resume scroll events.
|
||||||
|
var view = this;
|
||||||
|
Ember.run.scheduleOnce('afterRender', function() {
|
||||||
|
view.jumpToNumber(number, noAnimation).done(function() {
|
||||||
|
view.set('paused', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadingIndex: function(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.set('paused', true);
|
||||||
|
this.jumpToIndex(index, noAnimation);
|
||||||
|
},
|
||||||
|
|
||||||
|
loadedIndex: function(index, noAnimation) {
|
||||||
|
// The post at this index has been loaded. After we scroll to this post,
|
||||||
|
// we want to resume scroll events.
|
||||||
|
var view = this;
|
||||||
|
Ember.run.scheduleOnce('afterRender', function() {
|
||||||
|
view.jumpToIndex(index, noAnimation).done(function() {
|
||||||
|
view.set('paused', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Scroll down to a certain post by number (or the gap which we think the
|
||||||
|
// post is in) and highlight it.
|
||||||
|
jumpToNumber: function(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.)
|
||||||
|
jumpToIndex: function(index, noAnimation) {
|
||||||
|
var $item = this.findNearestToIndex(index);
|
||||||
|
return this.scrollToItem($item, noAnimation);
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToItem: function($item, noAnimation) {
|
||||||
|
var $container = $('html, body').stop(true);
|
||||||
|
if ($item.length) {
|
||||||
|
var marginTop = this.getMarginTop();
|
||||||
|
var scrollTop = $item.is(':first-child') ? 0 : $item.offset().top - marginTop;
|
||||||
|
if (noAnimation) {
|
||||||
|
$container.scrollTop(scrollTop);
|
||||||
|
} else if (scrollTop !== $(document).scrollTop()) {
|
||||||
|
$container.animate({scrollTop: scrollTop});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $container.promise();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Find the DOM element of the item that is nearest to a post with a certain
|
||||||
|
// number. This will either be another post (if the requested post doesn't
|
||||||
|
// exist,) or a gap presumed to contain the requested post.
|
||||||
|
findNearestToNumber: function(number) {
|
||||||
|
var $nearestItem = $();
|
||||||
|
this.$('.item').each(function() {
|
||||||
|
var $this = $(this);
|
||||||
|
if ($this.data('number') > number) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$nearestItem = $this;
|
||||||
|
});
|
||||||
|
return $nearestItem;
|
||||||
|
},
|
||||||
|
|
||||||
|
findNearestToIndex: function(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;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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: function() {
|
||||||
|
return $('#header').outerHeight() + parseInt(this.$().css('margin-top'));
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
goToNumber: function(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 controller = this;
|
||||||
|
this.get('stream').loadNearNumber(number).then(function() {
|
||||||
|
controller.trigger('loadedNumber', number, noAnimation);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
goToIndex: function(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 controller = this;
|
||||||
|
this.get('stream').loadNearIndex(index, backwards).then(function() {
|
||||||
|
controller.trigger('loadedIndex', index, noAnimation);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
goToFirst: function() {
|
||||||
|
this.send('goToIndex', 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
goToLast: function() {
|
||||||
|
this.send('goToIndex', this.get('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.
|
||||||
|
if (! this.get('stream.lastLoaded')) {
|
||||||
|
this.get('stream').one('postsLoaded', function() {
|
||||||
|
Ember.run.scheduleOnce('afterRender', function() {
|
||||||
|
$('html, body').stop(true).scrollTop($('body').height());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadRange: function(start, end, backwards) {
|
||||||
|
this.get('stream').loadRange(start, end, backwards);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
125
ember/app/components/discussion/stream-item.js
Normal file
125
ember/app/components/discussion/stream-item.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var $ = Ember.$;
|
||||||
|
|
||||||
|
/**
|
||||||
|
A stream 'item' represents one item in the post stream - this may be a
|
||||||
|
single post, or it may represent a gap of many posts which have not been
|
||||||
|
loaded.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
classNames: ['item'],
|
||||||
|
classNameBindings: ['gap', 'loading', 'direction'],
|
||||||
|
attributeBindings: [
|
||||||
|
'start:data-start',
|
||||||
|
'end:data-end',
|
||||||
|
'time:data-time',
|
||||||
|
'number:data-number'
|
||||||
|
],
|
||||||
|
|
||||||
|
start: Ember.computed.alias('item.indexStart'),
|
||||||
|
end: Ember.computed.alias('item.indexEnd'),
|
||||||
|
number: Ember.computed.alias('item.content.number'),
|
||||||
|
loading: Ember.computed.alias('item.loading'),
|
||||||
|
direction: Ember.computed.alias('item.direction'),
|
||||||
|
gap: Ember.computed.not('item.content'),
|
||||||
|
|
||||||
|
time: Ember.computed('item.content.time', function() {
|
||||||
|
var time = this.get('item.content.time');
|
||||||
|
return time ? time.toString() : null;
|
||||||
|
}),
|
||||||
|
|
||||||
|
count: Ember.computed('start', 'end', function() {
|
||||||
|
return this.get('end') - this.get('start') + 1;
|
||||||
|
}),
|
||||||
|
|
||||||
|
loadingChanged: Ember.observer('loading', function() {
|
||||||
|
this.rerender();
|
||||||
|
}),
|
||||||
|
|
||||||
|
render: function(buffer) {
|
||||||
|
if (this.get('item.content')) {
|
||||||
|
return this._super(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.push('<span>');
|
||||||
|
if (this.get('loading')) {
|
||||||
|
buffer.push(' ');
|
||||||
|
} else {
|
||||||
|
buffer.push(this.get('count')+' more post'+(this.get('count') !== 1 ? 's' : ''));
|
||||||
|
}
|
||||||
|
buffer.push('</span>');
|
||||||
|
},
|
||||||
|
|
||||||
|
didInsertElement: function() {
|
||||||
|
if (!this.get('gap')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.get('loading')) {
|
||||||
|
var view = this;
|
||||||
|
Ember.run.scheduleOnce('afterRender', function() {
|
||||||
|
view.$().spin('small');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
var self = this;
|
||||||
|
this.$().hover(function(e) {
|
||||||
|
if (! self.get('loading')) {
|
||||||
|
var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
|
||||||
|
self.set('direction', up ? 'up' : 'down');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
load: function(relativeIndex) {
|
||||||
|
// If this item is not a gap, or if we're already loading its posts,
|
||||||
|
// then we don't need to do anything.
|
||||||
|
if (! this.get('gap') || this.get('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 = this.get('direction') === 'up' ? 'nextAll' : 'prevAll';
|
||||||
|
var anchor = this.$()[siblingFunc]('.item:first');
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
this.get('stream').one('postsLoaded', function() {
|
||||||
|
if (anchor.length) {
|
||||||
|
var scrollOffset = anchor.offset().top - $(document).scrollTop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
Ember.run.scheduleOnce('afterRender', function() {
|
||||||
|
$('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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.sendAction(
|
||||||
|
'loadRange',
|
||||||
|
this.get('start') + (relativeIndex || 0),
|
||||||
|
this.get('end'),
|
||||||
|
this.get('direction') === 'up'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
click: function() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
392
ember/app/components/discussion/stream-scrubber.js
Normal file
392
ember/app/components/discussion/stream-scrubber.js
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var $ = Ember.$;
|
||||||
|
|
||||||
|
/**
|
||||||
|
Component which allows the user to scrub along the scrubber-content
|
||||||
|
component with a scrollbar.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
layoutName: 'components/discussion/stream-scrubber',
|
||||||
|
classNames: ['scrubber', 'stream-scrubber'],
|
||||||
|
classNameBindings: ['disabled'],
|
||||||
|
|
||||||
|
// The stream-content component to which this scrubber is linked.
|
||||||
|
streamContent: null,
|
||||||
|
|
||||||
|
// The current index of the stream visible at the top of the viewport, and
|
||||||
|
// the number of items visible within the viewport. These aren't
|
||||||
|
// necessarily integers.
|
||||||
|
index: -1,
|
||||||
|
visible: 1,
|
||||||
|
|
||||||
|
// The description displayed alongside the index in the scrubber. This is
|
||||||
|
// set to the date of the first visible post in the scroll event.
|
||||||
|
description: '',
|
||||||
|
|
||||||
|
stream: Ember.computed.alias('streamContent.stream'),
|
||||||
|
loaded: Ember.computed.alias('streamContent.loaded'),
|
||||||
|
count: Ember.computed.alias('stream.count'),
|
||||||
|
|
||||||
|
// The integer index of the last item that is visible in the viewport. This
|
||||||
|
// is display on the scrubber (i.e. X of 100 posts).
|
||||||
|
visibleIndex: Ember.computed('index', 'visible', function() {
|
||||||
|
return Math.min(this.get('count'), Math.ceil(Math.max(0, this.get('index')) + this.get('visible')));
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 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: Ember.computed('loaded', 'visible', 'count', function() {
|
||||||
|
return !this.get('loaded') || this.get('visible') >= this.get('count');
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Whenever the stream object changes to a new one (i.e. when
|
||||||
|
// transitioning to a different discussion,) reset some properties and
|
||||||
|
// update the scrollbar to a neutral state.
|
||||||
|
refresh: Ember.observer('stream', function() {
|
||||||
|
this.set('index', -1);
|
||||||
|
this.set('visible', 1);
|
||||||
|
this.updateScrollbar();
|
||||||
|
}),
|
||||||
|
|
||||||
|
didInsertElement: function() {
|
||||||
|
var view = this;
|
||||||
|
|
||||||
|
// When the stream-content component begins loading posts at a certain
|
||||||
|
// index, we want our scrubber scrollbar to jump to that position.
|
||||||
|
this.get('streamContent').on('loadingIndex', this, this.loadingIndex);
|
||||||
|
|
||||||
|
// Whenever the window is resized, adjust the height of the scrollbar
|
||||||
|
// so that it fills the height of the sidebar.
|
||||||
|
$(window).on('resize', {view: this}, this.windowWasResized).resize();
|
||||||
|
|
||||||
|
// Define a handler to update the state of the scrollbar to reflect the
|
||||||
|
// current scroll position of the page.
|
||||||
|
$(window).on('scroll', {view: this}, this.windowWasScrolled);
|
||||||
|
|
||||||
|
// When any part of the whole scrollbar is clicked, we want to jump to
|
||||||
|
// that position.
|
||||||
|
this.$('.scrubber-scrollbar')
|
||||||
|
.click(function(e) {
|
||||||
|
if (!view.get('streamContent.active')) { return; }
|
||||||
|
|
||||||
|
// Calculate the index which we want to jump to based on the
|
||||||
|
// click position.
|
||||||
|
// 1. Get the offset of the click from the top of the
|
||||||
|
// scrollbar, as a percentage of the scrollbar's height.
|
||||||
|
var $this = $(this);
|
||||||
|
var offsetPixels = e.clientY - $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 / view.percentPerPost().index;
|
||||||
|
offsetIndex = Math.max(0, Math.min(view.get('count') - 1, offsetIndex));
|
||||||
|
view.get('streamContent').send('goToIndex', Math.floor(offsetIndex));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the mouse is pressed on the scrollbar handle, we capture some
|
||||||
|
// information about its current position. We will store this
|
||||||
|
// information in an object and pass it on to the document's
|
||||||
|
// mousemove/mouseup events later.
|
||||||
|
var dragData = {
|
||||||
|
view: this,
|
||||||
|
mouseStart: 0,
|
||||||
|
indexStart: 0,
|
||||||
|
handle: null
|
||||||
|
};
|
||||||
|
this.$('.scrubber-slider')
|
||||||
|
.css('cursor', 'move')
|
||||||
|
.mousedown(function(e) {
|
||||||
|
dragData.mouseStart = e.clientY;
|
||||||
|
dragData.indexStart = view.get('index');
|
||||||
|
dragData.handle = $(this);
|
||||||
|
view.set('streamContent.paused', true);
|
||||||
|
$('body').css('cursor', 'move');
|
||||||
|
})
|
||||||
|
// Exempt the scrollbar handle from the 'jump to' click event.
|
||||||
|
.click(function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the mouse moves and when it is released, we pass the
|
||||||
|
// information that we captured when the mouse was first pressed onto
|
||||||
|
// some event handlers. These handlers will move the scrollbar/stream-
|
||||||
|
// content as appropriate.
|
||||||
|
$(document)
|
||||||
|
.on('mousemove', dragData, this.mouseWasMoved)
|
||||||
|
.on('mouseup', dragData, this.mouseWasReleased);
|
||||||
|
|
||||||
|
// Finally, we'll just make sure the scrollbar is in the correct
|
||||||
|
// position according to the values of this.index/visible.
|
||||||
|
this.updateScrollbar(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
willDestroyElement: function() {
|
||||||
|
this.get('streamContent').off('loadingIndex', this, this.loadingIndex);
|
||||||
|
|
||||||
|
$(window)
|
||||||
|
.off('resize', this.windowWasResized)
|
||||||
|
.off('scroll', this.windowWasScrolled);
|
||||||
|
|
||||||
|
$(document)
|
||||||
|
.off('mousemove', this.mouseWasMoved)
|
||||||
|
.off('mouseup', this.mouseWasReleased);
|
||||||
|
},
|
||||||
|
|
||||||
|
// When the stream-content component begins loading posts at a certain
|
||||||
|
// index, we want our scrubber scrollbar to jump to that position.
|
||||||
|
loadingIndex: function(index) {
|
||||||
|
this.set('index', index);
|
||||||
|
this.updateScrollbar(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
windowWasResized: function(event) {
|
||||||
|
var view = event.data.view;
|
||||||
|
view.windowWasScrolled(event);
|
||||||
|
|
||||||
|
// Adjust the height of the scrollbar so that it fills the height of
|
||||||
|
// the sidebar and doesn't overlap the footer.
|
||||||
|
var scrollbar = view.$('.scrubber-scrollbar');
|
||||||
|
scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - $('#footer').outerHeight(true));
|
||||||
|
},
|
||||||
|
|
||||||
|
windowWasScrolled: function(event) {
|
||||||
|
var view = event.data.view;
|
||||||
|
if (view.get('streamContent.active')) {
|
||||||
|
view.update();
|
||||||
|
view.updateScrollbar();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mouseWasMoved: function(event) {
|
||||||
|
if (! event.data.handle) { return; }
|
||||||
|
var view = event.data.view;
|
||||||
|
|
||||||
|
// 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.data.mouseStart;
|
||||||
|
var deltaPercent = deltaPixels / view.$('.scrubber-scrollbar').outerHeight() * 100;
|
||||||
|
var deltaIndex = deltaPercent / view.percentPerPost().index;
|
||||||
|
var newIndex = Math.min(event.data.indexStart + deltaIndex, view.get('count') - 1);
|
||||||
|
|
||||||
|
view.set('index', Math.max(0, newIndex));
|
||||||
|
view.updateScrollbar();
|
||||||
|
view.scrollToIndex(newIndex);
|
||||||
|
},
|
||||||
|
|
||||||
|
mouseWasReleased: function(event) {
|
||||||
|
if (!event.data.handle) { return; }
|
||||||
|
event.data.mouseStart = 0;
|
||||||
|
event.data.indexStart = 0;
|
||||||
|
event.data.handle = null;
|
||||||
|
$('body').css('cursor', '');
|
||||||
|
|
||||||
|
var view = event.data.view;
|
||||||
|
|
||||||
|
// 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(view.get('index'));
|
||||||
|
if (!view.get('stream').findNearestToIndex(intIndex).content) {
|
||||||
|
view.get('streamContent').send('goToIndex', intIndex);
|
||||||
|
} else {
|
||||||
|
view.set('streamContent.paused', false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// When the stream-content component resumes being 'active' (for example,
|
||||||
|
// after a bunch of posts have been loaded), then we want to update the
|
||||||
|
// scrubber scrollbar according to the window's current scroll position.
|
||||||
|
resume: Ember.observer('streamContent.active', function() {
|
||||||
|
var scrubber = this;
|
||||||
|
Ember.run.scheduleOnce('afterRender', function() {
|
||||||
|
if (scrubber.get('streamContent.active')) {
|
||||||
|
scrubber.update();
|
||||||
|
scrubber.updateScrollbar(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Update the index/visible/description properties according to the
|
||||||
|
// window's current scroll position.
|
||||||
|
update: function() {
|
||||||
|
if (!this.get('streamContent.active')) { return; }
|
||||||
|
|
||||||
|
var $window = $(window);
|
||||||
|
var marginTop = this.get('streamContent').getMarginTop();
|
||||||
|
var scrollTop = $window.scrollTop() + marginTop;
|
||||||
|
var windowHeight = $window.height() - marginTop;
|
||||||
|
|
||||||
|
// Before looping through all of the posts, we reset the scrollbar
|
||||||
|
// properties to a 'default' state. These values reflect what would be
|
||||||
|
// seen if the browser were scrolled right up to the top of the page,
|
||||||
|
// and the viewport had a height of 0.
|
||||||
|
var $items = this.get('streamContent').$().find('.item');
|
||||||
|
var index = $items.first().data('end') - 1;
|
||||||
|
var visible = 0;
|
||||||
|
var period = '';
|
||||||
|
|
||||||
|
// Now loop through each of the items in the discussion. An 'item' is
|
||||||
|
// either a single post or a 'gap' of one or more posts that haven't
|
||||||
|
// been loaded yet.
|
||||||
|
$items.each(function() {
|
||||||
|
var $this = $(this);
|
||||||
|
var top = $this.offset().top;
|
||||||
|
var height = $this.outerHeight(true);
|
||||||
|
|
||||||
|
// If this item is above the top of the viewport, skip to the next
|
||||||
|
// post. If it's below the bottom of the viewport, break out of the
|
||||||
|
// loop.
|
||||||
|
if (top + height < scrollTop) {
|
||||||
|
visible = (top + height - scrollTop) / height;
|
||||||
|
index = parseFloat($this.data('end')) + 1 - visible;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (top > scrollTop + windowHeight) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the bottom half of this item is visible at the top of the
|
||||||
|
// viewport, then add the visible proportion to the visible
|
||||||
|
// counter, and set the scrollbar index to whatever the visible
|
||||||
|
// proportion represents. For example, if a gap represents indexes
|
||||||
|
// 0-9, and the bottom 50% of the gap is visible in the viewport,
|
||||||
|
// then the scrollbar index will be 5.
|
||||||
|
if (top <= scrollTop && top + height > scrollTop) {
|
||||||
|
visible = (top + height - scrollTop) / height;
|
||||||
|
index = parseFloat($this.data('end')) + 1 - visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the top half of this item is visible at the bottom of the
|
||||||
|
// viewport, then add the visible proportion to the visible
|
||||||
|
// counter.
|
||||||
|
else if (top + height >= scrollTop + windowHeight) {
|
||||||
|
visible += (scrollTop + windowHeight - top) / height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the whole item is visible in the viewport, then increment the
|
||||||
|
// visible counter.
|
||||||
|
else {
|
||||||
|
visible++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this item has a time associated with it, then set the
|
||||||
|
// scrollbar's current period to a formatted version of this time.
|
||||||
|
if ($this.data('time')) {
|
||||||
|
period = $this.data('time');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.set('index', index);
|
||||||
|
this.set('visible', visible);
|
||||||
|
this.set('description', period ? moment(period).format('MMMM YYYY') : '');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update the scrollbar's position to reflect the current values of the
|
||||||
|
// index/visible properties.
|
||||||
|
updateScrollbar: function(animate) {
|
||||||
|
var percentPerPost = this.percentPerPost();
|
||||||
|
var index = this.get('index');
|
||||||
|
var count = this.get('count');
|
||||||
|
var visible = this.get('visible');
|
||||||
|
|
||||||
|
var heights = {};
|
||||||
|
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
|
||||||
|
heights.slider = Math.min(100 - heights.before, percentPerPost.visible * visible);
|
||||||
|
heights.after = 100 - heights.before - heights.slider;
|
||||||
|
|
||||||
|
var $scrubber = this.$();
|
||||||
|
var func = animate ? 'animate' : 'css';
|
||||||
|
for (var part in heights) {
|
||||||
|
var $part = $scrubber.find('.scrubber-'+part);
|
||||||
|
$part.stop(true, true)[func]({height: heights[part]+'%'});
|
||||||
|
|
||||||
|
// jQuery likes to put overflow:hidden, but because the scrollbar
|
||||||
|
// handle has a negative margin-left, we need to override.
|
||||||
|
if (func === 'animate') {
|
||||||
|
$part.css('overflow', 'visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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: function(index) {
|
||||||
|
index = Math.min(index, this.get('count') - 1);
|
||||||
|
|
||||||
|
// Find the item for this index, whether it's a post corresponding to
|
||||||
|
// the index, or a gap which the index is within.
|
||||||
|
var indexFloor = Math.max(0, Math.floor(index));
|
||||||
|
var $nearestItem = this.get('streamContent').findNearestToIndex(indexFloor);
|
||||||
|
|
||||||
|
// Calculate the position of this item so that we can scroll to it. If
|
||||||
|
// the item is a gap, then we will mark it as 'active' to indicate to
|
||||||
|
// the user that it will expand if they release their mouse.
|
||||||
|
// Otherwise, we will add a proportion of the item's height onto the
|
||||||
|
// scroll position.
|
||||||
|
var pos = $nearestItem.offset().top - this.get('streamContent').getMarginTop();
|
||||||
|
if ($nearestItem.is('.gap')) {
|
||||||
|
$nearestItem.addClass('active');
|
||||||
|
} else {
|
||||||
|
if (index >= 0) {
|
||||||
|
pos += $nearestItem.outerHeight(true) * (index - indexFloor);
|
||||||
|
} else {
|
||||||
|
pos += $nearestItem.offset().top * index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the 'active' class from other gaps.
|
||||||
|
this.get('streamContent').$().find('.gap').not($nearestItem).removeClass('active');
|
||||||
|
|
||||||
|
$('html, body').scrollTop(pos);
|
||||||
|
},
|
||||||
|
|
||||||
|
percentPerPost: function() {
|
||||||
|
var count = this.get('count') || 1;
|
||||||
|
var visible = this.get('visible');
|
||||||
|
|
||||||
|
// To stop the slider of the scrollbar from getting too small when there
|
||||||
|
// are many posts, we define a minimum percentage height for the slider
|
||||||
|
// calculated from a 50 pixel limit. From this, we can calculate the
|
||||||
|
// minimum percentage per visible post. If this is greater than the
|
||||||
|
// actual percentage per post, then we need to adjust the 'before'
|
||||||
|
// percentage to account for it.
|
||||||
|
var minPercentVisible = 50 / this.$('.scrubber-scrollbar').outerHeight() * 100;
|
||||||
|
var percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
|
||||||
|
var percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: percentPerPost,
|
||||||
|
visible: percentPerVisiblePost
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
first: function() {
|
||||||
|
this.get('streamContent').send('goToFirst');
|
||||||
|
},
|
||||||
|
|
||||||
|
last: function() {
|
||||||
|
this.get('streamContent').send('goToLast');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@@ -1,57 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
import TaggedArray from '../../utils/tagged-array';
|
|
||||||
import { PositionEnum } from '../../controllers/composer';
|
|
||||||
|
|
||||||
var precompileTemplate = Ember.Handlebars.compile;
|
|
||||||
|
|
||||||
export default Ember.Component.extend(Ember.Evented, {
|
|
||||||
layoutName: 'components/discussions/composer-body',
|
|
||||||
|
|
||||||
submitLabel: 'Post Discussion',
|
|
||||||
titlePlaceholder: 'Discussion Title',
|
|
||||||
placeholder: '',
|
|
||||||
title: '',
|
|
||||||
content: '',
|
|
||||||
submit: null,
|
|
||||||
loading: false,
|
|
||||||
|
|
||||||
disabled: Ember.computed.equal('composer.position', PositionEnum.MINIMIZED),
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
var controls = TaggedArray.create();
|
|
||||||
this.trigger('populateControls', controls);
|
|
||||||
this.set('controls', controls);
|
|
||||||
},
|
|
||||||
|
|
||||||
populateControls: function(controls) {
|
|
||||||
var title = Ember.Component.create({
|
|
||||||
tagName: 'h3',
|
|
||||||
layout: precompileTemplate('{{ui/controls/text-input value=component.title class="form-control" placeholder=component.titlePlaceholder disabled=component.disabled}}'),
|
|
||||||
component: this
|
|
||||||
});
|
|
||||||
controls.pushObjectWithTag(title, 'title');
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
submit: function(content) {
|
|
||||||
this.get('submit')({
|
|
||||||
title: this.get('title'),
|
|
||||||
content: content
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
willExit: function(abort) {
|
|
||||||
// If the user has typed something, prompt them before exiting
|
|
||||||
// this composer state.
|
|
||||||
if ((this.get('title') || this.get('content')) && !confirm('You have not posted your discussion. Do you wish to discard it?')) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
reset: function() {
|
|
||||||
this.set('loading', false);
|
|
||||||
this.set('content', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,48 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
import TaggedArray from '../../utils/tagged-array';
|
|
||||||
|
|
||||||
var precompileTemplate = Ember.Handlebars.compile;
|
|
||||||
|
|
||||||
export default Ember.Component.extend(Ember.Evented, {
|
|
||||||
layoutName: 'components/discussions/composer-body',
|
|
||||||
|
|
||||||
submitLabel: 'Save Changes',
|
|
||||||
placeholder: '',
|
|
||||||
content: Ember.computed.oneWay('post.content'),
|
|
||||||
originalContent: Ember.computed.oneWay('post.content'),
|
|
||||||
submit: null,
|
|
||||||
loading: false,
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
var controls = TaggedArray.create();
|
|
||||||
this.trigger('populateControls', controls);
|
|
||||||
this.set('controls', controls);
|
|
||||||
},
|
|
||||||
|
|
||||||
populateControls: function(controls) {
|
|
||||||
var title = Ember.Component.create({
|
|
||||||
tagName: 'h3',
|
|
||||||
layout: precompileTemplate('Editing Post #{{component.post.number}} in <em>{{discussion.title}}</em>'),
|
|
||||||
discussion: this.get('post.discussion'),
|
|
||||||
component: this
|
|
||||||
});
|
|
||||||
controls.pushObjectWithTag(title, 'title');
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
submit: function(content) {
|
|
||||||
this.get('submit')({
|
|
||||||
content: content
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
willExit: function(abort) {
|
|
||||||
// If the user has typed something, prompt them before exiting
|
|
||||||
// this composer state.
|
|
||||||
if (this.get('content') !== this.get('originalContent') && ! confirm('You have not saved your post. Do you wish to discard your changes?')) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,51 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
import TaggedArray from '../../utils/tagged-array';
|
|
||||||
|
|
||||||
var precompileTemplate = Ember.Handlebars.compile;
|
|
||||||
|
|
||||||
export default Ember.Component.extend(Ember.Evented, {
|
|
||||||
layoutName: 'components/discussions/composer-body',
|
|
||||||
|
|
||||||
submitLabel: 'Post Reply',
|
|
||||||
placeholder: '',
|
|
||||||
content: '',
|
|
||||||
submit: null,
|
|
||||||
loading: false,
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
var controls = TaggedArray.create();
|
|
||||||
this.trigger('populateControls', controls);
|
|
||||||
this.set('controls', controls);
|
|
||||||
},
|
|
||||||
|
|
||||||
populateControls: function(controls) {
|
|
||||||
var title = Ember.Component.create({
|
|
||||||
tagName: 'h3',
|
|
||||||
layout: precompileTemplate('Replying to <em>{{component.discussion.title}}</em>'),
|
|
||||||
component: this
|
|
||||||
});
|
|
||||||
controls.pushObjectWithTag(title, 'title');
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
submit: function(content) {
|
|
||||||
this.get('submit')({
|
|
||||||
content: content
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
willExit: function(abort) {
|
|
||||||
// If the user has typed something, prompt them before exiting
|
|
||||||
// this composer state.
|
|
||||||
if (this.get('content') && ! confirm('You have not posted your reply. Do you wish to discard it?')) {
|
|
||||||
abort();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
reset: function() {
|
|
||||||
this.set('loading', false);
|
|
||||||
this.set('content', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,161 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
import TaggedArray from '../../utils/tagged-array';
|
|
||||||
import ActionButton from '../ui/controls/action-button';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
|
|
||||||
terminalPostType: 'last',
|
|
||||||
countType: 'unread',
|
|
||||||
|
|
||||||
tagName: 'li',
|
|
||||||
attributeBindings: ['discussionId:data-id'],
|
|
||||||
classNames: ['discussion-summary'],
|
|
||||||
classNameBindings: [
|
|
||||||
'discussion.isUnread:unread',
|
|
||||||
'active'
|
|
||||||
],
|
|
||||||
layoutName: 'components/discussions/discussion-listing',
|
|
||||||
|
|
||||||
active: function() {
|
|
||||||
return this.get('childViews').anyBy('active');
|
|
||||||
}.property('childViews.@each.active'),
|
|
||||||
|
|
||||||
displayUnread: function() {
|
|
||||||
return this.get('countType') === 'unread' && this.get('discussion.isUnread');
|
|
||||||
}.property('countType', 'discussion.isUnread'),
|
|
||||||
|
|
||||||
countTitle: function() {
|
|
||||||
return this.get('discussion.isUnread') ? 'Mark as Read' : 'Jump to Last';
|
|
||||||
}.property('discussion.isUnread'),
|
|
||||||
|
|
||||||
displayLastPost: function() {
|
|
||||||
return this.get('terminalPostType') === 'last' && this.get('discussion.repliesCount');
|
|
||||||
}.property('terminalPostType', 'discussion.repliesCount'),
|
|
||||||
|
|
||||||
start: function() {
|
|
||||||
return this.get('discussion.isUnread') ? this.get('discussion.readNumber') + 1 : 1;
|
|
||||||
}.property('discussion.isUnread', 'discussion.readNumber'),
|
|
||||||
|
|
||||||
discussionId: Ember.computed.alias('discussion.id'),
|
|
||||||
|
|
||||||
relevantPosts: function() {
|
|
||||||
if (this.get('controller.show') !== 'posts') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.get('controller.searchQuery')) {
|
|
||||||
return this.get('discussion.relevantPosts');
|
|
||||||
} else if (this.get('controller.sort') === 'newest' || this.get('controller.sort') === 'oldest') {
|
|
||||||
return [this.get('discussion.startPost')];
|
|
||||||
} else {
|
|
||||||
return [this.get('discussion.lastPost')];
|
|
||||||
}
|
|
||||||
}.property('discussion.relevantPosts', 'discussion.startPost', 'discussion.lastPost'),
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
var $this = this.$().css({opacity: 0});
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
$this.animate({opacity: 1}, 'fast');
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// var view = this;
|
|
||||||
// this.$().find('a.info').click(function() {
|
|
||||||
|
|
||||||
// view.set('controller.paneShowing', false);
|
|
||||||
// });
|
|
||||||
|
|
||||||
// https://github.com/nolimits4web/Framework7/blob/master/src/js/swipeout.js
|
|
||||||
// this.$().find('.discussion').on('touchstart mousedown', function(e) {
|
|
||||||
// var isMoved = false;
|
|
||||||
// var isTouched = true;
|
|
||||||
// var isScrolling = undefined;
|
|
||||||
// var touchesStart = {
|
|
||||||
// x: e.type === 'touchstart' ? e.originalEvent.targetTouches[0].pageX : e.pageX,
|
|
||||||
// y: e.type === 'touchstart' ? e.originalEvent.targetTouches[0].pageY : e.pageY,
|
|
||||||
// };
|
|
||||||
// var touchStartTime = (new Date()).getTime();
|
|
||||||
|
|
||||||
// $(this).on('touchmove mousemove', function(e) {
|
|
||||||
// if (! isTouched) return;
|
|
||||||
// $(this).find('a.info').removeClass('pressed');
|
|
||||||
// var touchesNow = {
|
|
||||||
// x: e.type === 'touchmove' ? e.originalEvent.targetTouches[0].pageX : e.pageX,
|
|
||||||
// y: e.type === 'touchmove' ? e.originalEvent.targetTouches[0].pageY : e.pageY,
|
|
||||||
// };
|
|
||||||
// if (typeof isScrolling === 'undefined') {
|
|
||||||
// isScrolling = !!(isScrolling || Math.abs(touchesNow.y - touchesStart.y) > Math.abs(touchesNow.x - touchesStart.x));
|
|
||||||
// }
|
|
||||||
// if (isScrolling) {
|
|
||||||
// isTouched = false;
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// isMoved = true;
|
|
||||||
// e.preventDefault();
|
|
||||||
|
|
||||||
// var diffX = touchesNow.x - touchesStart.x;
|
|
||||||
// var translate = diffX;
|
|
||||||
// var actionsRightWidth = 150;
|
|
||||||
|
|
||||||
// if (translate < -actionsRightWidth) {
|
|
||||||
// translate = -actionsRightWidth - Math.pow(-translate - actionsRightWidth, 0.8);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// $(this).css('left', translate);
|
|
||||||
// });
|
|
||||||
|
|
||||||
// $(this).on('touchend mouseup', function(e) {
|
|
||||||
// $(this).off('touchmove mousemove touchend mouseup');
|
|
||||||
// $(this).find('a.info').removeClass('pressed');
|
|
||||||
// if (!isTouched || !isMoved) {
|
|
||||||
// isTouched = false;
|
|
||||||
// isMoved = false;
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
// isTouched = false;
|
|
||||||
// // isMoved = false;
|
|
||||||
|
|
||||||
// if (isMoved) {
|
|
||||||
// e.preventDefault();
|
|
||||||
// $(this).animate({left: -150});
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// $(this).find('a.info').addClass('pressed').on('click', function(e) {
|
|
||||||
// if (isMoved) {
|
|
||||||
// e.preventDefault();
|
|
||||||
// e.stopImmediatePropagation();
|
|
||||||
// }
|
|
||||||
// $(this).off('click');
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
|
|
||||||
this.set('controls', TaggedArray.create());
|
|
||||||
},
|
|
||||||
|
|
||||||
populateControlsDefault: function(controls) {
|
|
||||||
controls.pushObjectWithTag(ActionButton.create({
|
|
||||||
label: 'Delete',
|
|
||||||
icon: 'times',
|
|
||||||
className: 'delete'
|
|
||||||
}), 'delete');
|
|
||||||
}.on('populateControls'),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
populateControls: function() {
|
|
||||||
if ( ! this.get('controls.length')) {
|
|
||||||
this.trigger('populateControls', this.get('controls'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
markAsRead: function() {
|
|
||||||
if (this.get('discussion.isUnread')) {
|
|
||||||
window.event.stopPropagation();
|
|
||||||
this.sendAction('markAsRead', this.get('discussion'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
@@ -1,149 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
import TaggedArray from '../../utils/tagged-array';
|
|
||||||
import ActionButton from '../ui/controls/action-button';
|
|
||||||
import ComposerEdit from '../discussions/composer-edit';
|
|
||||||
import AlertMessage from '../alert-message';
|
|
||||||
import humanTime from '../../utils/human-time';
|
|
||||||
|
|
||||||
var precompileTemplate = Ember.Handlebars.compile;
|
|
||||||
|
|
||||||
// @todo extend a base post class
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
tagName: 'article',
|
|
||||||
layoutName: 'components/discussions/post-comment',
|
|
||||||
|
|
||||||
editDescription: function() {
|
|
||||||
return 'Edited by '+this.get('post.editUser.username')+' '+humanTime(this.get('post.editTime'));
|
|
||||||
}.property('post.editTime', 'post.editUser'),
|
|
||||||
|
|
||||||
post: Ember.computed.alias('content'),
|
|
||||||
|
|
||||||
classNames: ['post'],
|
|
||||||
classNameBindings: ['post.deleted', 'post.edited'],
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
var $this = this.$();
|
|
||||||
$this.css({opacity: 0});
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
$this.animate({opacity: 1}, 'fast');
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
this.set('controls', TaggedArray.create());
|
|
||||||
this.trigger('populateControls', this.get('controls'));
|
|
||||||
|
|
||||||
this.set('header', TaggedArray.create());
|
|
||||||
this.trigger('populateHeader', this.get('header'));
|
|
||||||
},
|
|
||||||
|
|
||||||
populateControlsDefault: function(controls) {
|
|
||||||
if (this.get('post.deleted')) {
|
|
||||||
this.addControl('restore', 'Restore', 'reply', 'canEdit');
|
|
||||||
this.addControl('delete', 'Delete', 'times', 'canDelete');
|
|
||||||
} else {
|
|
||||||
this.addControl('edit', 'Edit', 'pencil', 'canEdit');
|
|
||||||
this.addControl('hide', 'Delete', 'times', 'canEdit');
|
|
||||||
}
|
|
||||||
}.on('populateControls'),
|
|
||||||
|
|
||||||
populateHeaderDefault: function(header) {
|
|
||||||
header.pushObjectWithTag(Ember.Component.create({
|
|
||||||
tagName: 'h3',
|
|
||||||
classNames: ['user'],
|
|
||||||
layout: precompileTemplate('{{#link-to "user" post.user}}{{user-avatar post.user}} {{post.user.username}}{{/link-to}}'),
|
|
||||||
post: this.get('post')
|
|
||||||
}));
|
|
||||||
|
|
||||||
header.pushObjectWithTag(Ember.Component.create({
|
|
||||||
tagName: 'li',
|
|
||||||
layout: precompileTemplate('{{#link-to "discussion" post.discussion (query-params start=post.number) class="time"}}{{human-time post.time}}{{/link-to}}'),
|
|
||||||
post: this.get('post')
|
|
||||||
}));
|
|
||||||
|
|
||||||
header.pushObjectWithTag(Ember.Component.extend({
|
|
||||||
tagName: 'li',
|
|
||||||
hideItem: Ember.computed.not('parent.post.isEdited'),
|
|
||||||
layout: precompileTemplate('<span class="post-edited" {{bind-attr title=parent.editDescription}}>{{fa-icon "pencil"}}</span>'),
|
|
||||||
parent: this,
|
|
||||||
didInsertElement: function() {
|
|
||||||
this.$('.post-edited').tooltip();
|
|
||||||
},
|
|
||||||
updateTooltip: function() {
|
|
||||||
Ember.run.scheduleOnce('afterRender', this, function() {
|
|
||||||
this.$('.post-edited').tooltip('fixTitle');
|
|
||||||
});
|
|
||||||
}.observes('parent.editDescription')
|
|
||||||
}).create());
|
|
||||||
}.on('populateHeader'),
|
|
||||||
|
|
||||||
addControl: function(tag, label, icon, permissionAttribute) {
|
|
||||||
if (permissionAttribute && !this.get('post').get(permissionAttribute)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var self = this;
|
|
||||||
var action = function() {
|
|
||||||
self.get('controller').send(tag);
|
|
||||||
};
|
|
||||||
|
|
||||||
var item = ActionButton.create({label: label, icon: icon, action: action});
|
|
||||||
this.get('controls').pushObjectWithTag(item, tag);
|
|
||||||
},
|
|
||||||
|
|
||||||
savePost: function(post, data) {
|
|
||||||
var controller = this;
|
|
||||||
var composer = this.get('composer');
|
|
||||||
|
|
||||||
composer.set('content.loading', true);
|
|
||||||
this.get('alerts').send('clearAlerts');
|
|
||||||
|
|
||||||
post.set('content', data.content);
|
|
||||||
|
|
||||||
return post.save().then(function(post) {
|
|
||||||
composer.send('hide');
|
|
||||||
},
|
|
||||||
function(reason) {
|
|
||||||
var errors = reason.errors;
|
|
||||||
for (var i in reason.errors) {
|
|
||||||
var message = AlertMessage.create({
|
|
||||||
type: 'warning',
|
|
||||||
message: reason.errors[i]
|
|
||||||
});
|
|
||||||
controller.get('alerts').send('alert', message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(function() {
|
|
||||||
composer.set('content.loading', false);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
renderControls: function() {
|
|
||||||
this.set('renderControls', this.get('controls'));
|
|
||||||
// if (!this.get('controls.length')) {
|
|
||||||
// this.get('controls').pushObject(Ember.Component.create({tagName: 'li', classNames: ['dropdown-header'], layout: Ember.Handlebars.compile('No actions available')}));
|
|
||||||
// }
|
|
||||||
},
|
|
||||||
|
|
||||||
edit: function() {
|
|
||||||
var component = this;
|
|
||||||
var post = this.get('post');
|
|
||||||
var composer = this.get('composer');
|
|
||||||
|
|
||||||
// If the composer is already set up for this post, then we
|
|
||||||
// don't need to change its content - we can just show it.
|
|
||||||
if (!(composer.get('content') instanceof ComposerEdit) || composer.get('content.post') !== post) {
|
|
||||||
composer.switchContent(ComposerEdit.create({
|
|
||||||
user: post.get('user'),
|
|
||||||
post: post,
|
|
||||||
submit: function(data) {
|
|
||||||
component.savePost(post, data);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
composer.send('show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,286 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
var $ = Ember.$;
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
|
|
||||||
classNames: ['stream'],
|
|
||||||
|
|
||||||
// The stream object.
|
|
||||||
stream: null,
|
|
||||||
|
|
||||||
// Pause window scroll event listeners. This is set to true while loading
|
|
||||||
// posts, because we don't want a scroll event to trigger another block of
|
|
||||||
// posts to be loaded.
|
|
||||||
paused: false,
|
|
||||||
|
|
||||||
// Whether or not the stream's initial content has loaded.
|
|
||||||
loaded: Ember.computed.bool('stream.loadedCount'),
|
|
||||||
|
|
||||||
// When the stream content is not "active", window scroll event listeners
|
|
||||||
// will be ignored. For the stream content to be active, its initial
|
|
||||||
// content must be loaded and it must not be "paused".
|
|
||||||
active: function() {
|
|
||||||
return this.get('loaded') && ! this.get('paused');
|
|
||||||
}.property('loaded', 'paused'),
|
|
||||||
|
|
||||||
refresh: function() {
|
|
||||||
this.set('paused', true);
|
|
||||||
clearTimeout(this.updateStateTimeout);
|
|
||||||
}.observes('stream'),
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
$(window).on('scroll', {view: this}, this.windowWasScrolled);
|
|
||||||
},
|
|
||||||
|
|
||||||
willDestroyElement: function() {
|
|
||||||
$(window).off('scroll', this.windowWasScrolled);
|
|
||||||
},
|
|
||||||
|
|
||||||
windowWasScrolled: function(event) {
|
|
||||||
event.data.view.update();
|
|
||||||
},
|
|
||||||
|
|
||||||
// Run any checks/updates according to the window's current scroll
|
|
||||||
// position. We check to see if any terminal 'gaps' are in the viewport
|
|
||||||
// and trigger their loading mechanism if they are. We also update the
|
|
||||||
// controller's 'start' query param with the current position. Note: this
|
|
||||||
// observes the 'active' property, so if the stream is 'unpaused', then an
|
|
||||||
// update will be triggered.
|
|
||||||
update: function() {
|
|
||||||
if (! this.get('active')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var $items = this.$().find('.item'),
|
|
||||||
$window = $(window),
|
|
||||||
marginTop = this.getMarginTop(),
|
|
||||||
scrollTop = $window.scrollTop() + marginTop,
|
|
||||||
viewportHeight = $window.height() - marginTop,
|
|
||||||
loadAheadDistance = 300,
|
|
||||||
currentNumber;
|
|
||||||
|
|
||||||
// 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),
|
|
||||||
top = $this.offset().top,
|
|
||||||
height = $this.outerHeight(true);
|
|
||||||
|
|
||||||
// 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 gapView = Ember.View.views[$this.attr('id')];
|
|
||||||
if ($this.is(':first-child')) {
|
|
||||||
gapView.set('direction', 'up').load();
|
|
||||||
} else if ($this.is(':last-child')) {
|
|
||||||
gapView.set('direction', 'down').load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 && ! currentNumber) {
|
|
||||||
currentNumber = $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.
|
|
||||||
var view = this;
|
|
||||||
clearTimeout(this.updateStateTimeout);
|
|
||||||
this.updateStateTimeout = setTimeout(function() {
|
|
||||||
view.sendAction('updateStart', currentNumber || 1);
|
|
||||||
}, 250);
|
|
||||||
}.observes('active'),
|
|
||||||
|
|
||||||
loadingNumber: function(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.set('paused', true);
|
|
||||||
this.jumpToNumber(number, noAnimation);
|
|
||||||
},
|
|
||||||
|
|
||||||
loadedNumber: function(number, noAnimation) {
|
|
||||||
// The post with this number has been loaded. After we scroll to this
|
|
||||||
// post, we want to resume scroll events.
|
|
||||||
var view = this;
|
|
||||||
Ember.run.scheduleOnce('afterRender', function() {
|
|
||||||
view.jumpToNumber(number, noAnimation).done(function() {
|
|
||||||
view.set('paused', false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
loadingIndex: function(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.set('paused', true);
|
|
||||||
this.jumpToIndex(index, noAnimation);
|
|
||||||
},
|
|
||||||
|
|
||||||
loadedIndex: function(index, noAnimation) {
|
|
||||||
// The post at this index has been loaded. After we scroll to this post,
|
|
||||||
// we want to resume scroll events.
|
|
||||||
var view = this;
|
|
||||||
Ember.run.scheduleOnce('afterRender', function() {
|
|
||||||
view.jumpToIndex(index, noAnimation).done(function() {
|
|
||||||
view.set('paused', false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Scroll down to a certain post by number (or the gap which we think the
|
|
||||||
// post is in) and highlight it.
|
|
||||||
jumpToNumber: function(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 (number > 1) {
|
|
||||||
$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.)
|
|
||||||
jumpToIndex: function(index, noAnimation) {
|
|
||||||
var $item = this.findNearestToIndex(index);
|
|
||||||
return this.scrollToItem($item, noAnimation);
|
|
||||||
},
|
|
||||||
|
|
||||||
scrollToItem: function($item, noAnimation) {
|
|
||||||
var $container = $('html, body').stop(true);
|
|
||||||
if ($item.length) {
|
|
||||||
var marginTop = this.getMarginTop();
|
|
||||||
var scrollTop = $item.is(':first-child') ? 0 : $item.offset().top - marginTop;
|
|
||||||
if (noAnimation) {
|
|
||||||
$container.scrollTop(scrollTop);
|
|
||||||
} else if (scrollTop !== $(document).scrollTop()) {
|
|
||||||
$container.animate({scrollTop: scrollTop});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $container.promise();
|
|
||||||
},
|
|
||||||
|
|
||||||
// Find the DOM element of the item that is nearest to a post with a certain
|
|
||||||
// number. This will either be another post (if the requested post doesn't
|
|
||||||
// exist,) or a gap presumed to contain the requested post.
|
|
||||||
findNearestToNumber: function(number) {
|
|
||||||
var $nearestItem = $();
|
|
||||||
this.$('.item').each(function() {
|
|
||||||
var $this = $(this);
|
|
||||||
if ($this.data('number') > number) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$nearestItem = $this;
|
|
||||||
});
|
|
||||||
return $nearestItem;
|
|
||||||
},
|
|
||||||
|
|
||||||
findNearestToIndex: function(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;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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: function() {
|
|
||||||
return $('#header').outerHeight() + parseInt(this.$().css('margin-top'));
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
goToNumber: function(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 controller = this;
|
|
||||||
this.get('stream').loadNearNumber(number).then(function() {
|
|
||||||
controller.trigger('loadedNumber', number, noAnimation);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
goToIndex: function(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 controller = this;
|
|
||||||
this.get('stream').loadNearIndex(index, backwards).then(function() {
|
|
||||||
controller.trigger('loadedIndex', index, noAnimation);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
goToFirst: function() {
|
|
||||||
this.send('goToIndex', 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
goToLast: function() {
|
|
||||||
this.send('goToIndex', this.get('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.
|
|
||||||
if (! this.get('stream.lastLoaded')) {
|
|
||||||
this.get('stream').one('postsLoaded', function() {
|
|
||||||
Ember.run.scheduleOnce('afterRender', function() {
|
|
||||||
$('html, body').stop(true).scrollTop($('body').height());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
loadRange: function(start, end, backwards) {
|
|
||||||
this.get('stream').loadRange(start, end, backwards);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,123 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
var $ = Ember.$;
|
|
||||||
|
|
||||||
// A discussion 'item' represents one item in the post stream. In other words, a
|
|
||||||
// single item may represent a single post, or it may represent a gap of many
|
|
||||||
// posts which have not been loaded.
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
classNames: ['item'],
|
|
||||||
classNameBindings: ['gap', 'loading', 'direction'],
|
|
||||||
attributeBindings: [
|
|
||||||
'start:data-start',
|
|
||||||
'end:data-end',
|
|
||||||
'time:data-time',
|
|
||||||
'number:data-number'
|
|
||||||
],
|
|
||||||
|
|
||||||
start: Ember.computed.alias('item.indexStart'),
|
|
||||||
end: Ember.computed.alias('item.indexEnd'),
|
|
||||||
time: function() {
|
|
||||||
var time = this.get('item.content.time');
|
|
||||||
return time ? time.toString() : null;
|
|
||||||
}.property('item.content.time'),
|
|
||||||
number: Ember.computed.alias('item.content.number'),
|
|
||||||
loading: Ember.computed.alias('item.loading'),
|
|
||||||
direction: Ember.computed.alias('item.direction'),
|
|
||||||
gap: Ember.computed.not('item.content'),
|
|
||||||
|
|
||||||
count: function() {
|
|
||||||
return this.get('end') - this.get('start') + 1;
|
|
||||||
}.property('start', 'end'),
|
|
||||||
|
|
||||||
loadingChanged: function() {
|
|
||||||
this.rerender();
|
|
||||||
}.observes('loading'),
|
|
||||||
|
|
||||||
render: function(buffer) {
|
|
||||||
if (this.get('item.content')) {
|
|
||||||
return this._super(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.push('<span>');
|
|
||||||
if (this.get('loading')) {
|
|
||||||
buffer.push(' ');
|
|
||||||
} else {
|
|
||||||
buffer.push(this.get('count')+' more post'+(this.get('count') !== 1 ? 's' : ''));
|
|
||||||
}
|
|
||||||
buffer.push('</span>');
|
|
||||||
},
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
if (! this.get('gap')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.get('loading')) {
|
|
||||||
var view = this;
|
|
||||||
Ember.run.scheduleOnce('afterRender', function() {
|
|
||||||
view.$().spin('small');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
var self = this;
|
|
||||||
this.$().hover(function(e) {
|
|
||||||
if (! self.get('loading')) {
|
|
||||||
var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
|
|
||||||
self.set('direction', up ? 'up' : 'down');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
load: function(relativeIndex) {
|
|
||||||
// If this item is not a gap, or if we're already loading its posts,
|
|
||||||
// then we don't need to do anything.
|
|
||||||
if (! this.get('gap') || this.get('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 = this.get('direction') === 'up' ? 'nextAll' : 'prevAll';
|
|
||||||
var anchor = this.$()[siblingFunc]('.item:first');
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
this.get('stream').one('postsLoaded', function() {
|
|
||||||
if (anchor.length) {
|
|
||||||
var scrollOffset = anchor.offset().top - $(document).scrollTop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
Ember.run.scheduleOnce('afterRender', function() {
|
|
||||||
$('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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.sendAction(
|
|
||||||
'loadRange',
|
|
||||||
this.get('start') + (relativeIndex || 0),
|
|
||||||
this.get('end'),
|
|
||||||
this.get('direction') === 'up'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
click: function() {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,395 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
var $ = Ember.$;
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
layoutName: 'components/discussions/stream-scrubber',
|
|
||||||
classNames: ['scrubber', 'stream-scrubber'],
|
|
||||||
classNameBindings: ['disabled'],
|
|
||||||
|
|
||||||
// The stream-content component to which this scrubber is linked.
|
|
||||||
streamContent: null,
|
|
||||||
stream: Ember.computed.alias('streamContent.stream'),
|
|
||||||
loaded: Ember.computed.alias('streamContent.loaded'),
|
|
||||||
count: Ember.computed.alias('stream.count'),
|
|
||||||
|
|
||||||
// The current index of the stream visible at the top of the viewport, and
|
|
||||||
// the number of items visible within the viewport. These aren't
|
|
||||||
// necessarily integers.
|
|
||||||
index: -1,
|
|
||||||
visible: 1,
|
|
||||||
|
|
||||||
// The integer index of the last item that is visible in the viewport. This
|
|
||||||
// is display on the scrubber (i.e. X of 100 posts).
|
|
||||||
visibleIndex: function() {
|
|
||||||
return Math.min(this.get('count'), Math.ceil(Math.max(0, this.get('index')) + this.get('visible')));
|
|
||||||
}.property('index', 'visible'),
|
|
||||||
|
|
||||||
// The description displayed alongside the index in the scrubber. This is
|
|
||||||
// set to the date of the first visible post in the scroll event.
|
|
||||||
description: '',
|
|
||||||
|
|
||||||
// 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: function() {
|
|
||||||
return ! this.get('loaded') || this.get('visible') >= this.get('count');
|
|
||||||
}.property('loaded', 'visible', 'count'),
|
|
||||||
|
|
||||||
// Whenever the stream object changes to a new one (i.e. when
|
|
||||||
// transitioning to a different discussion,) reset some properties and
|
|
||||||
// update the scrollbar to a neutral state.
|
|
||||||
refresh: function() {
|
|
||||||
this.set('index', -1);
|
|
||||||
this.set('visible', 1);
|
|
||||||
this.updateScrollbar();
|
|
||||||
}.observes('stream'),
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
var view = this;
|
|
||||||
|
|
||||||
// When the stream-content component begins loading posts at a certain
|
|
||||||
// index, we want our scrubber scrollbar to jump to that position.
|
|
||||||
this.get('streamContent').on('loadingIndex', this, this.loadingIndex);
|
|
||||||
|
|
||||||
// Whenever the window is resized, adjust the height of the scrollbar
|
|
||||||
// so that it fills the height of the sidebar.
|
|
||||||
$(window).on('resize', {view: this}, this.windowWasResized).resize();
|
|
||||||
|
|
||||||
// Define a handler to update the state of the scrollbar to reflect the
|
|
||||||
// current scroll position of the page.
|
|
||||||
$(window).on('scroll', {view: this}, this.windowWasScrolled);
|
|
||||||
|
|
||||||
// When any part of the whole scrollbar is clicked, we want to jump to
|
|
||||||
// that position.
|
|
||||||
this.$('.scrubber-scrollbar')
|
|
||||||
.click(function(e) {
|
|
||||||
if (! view.get('streamContent.active')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the index which we want to jump to based on the
|
|
||||||
// click position.
|
|
||||||
// 1. Get the offset of the click from the top of the
|
|
||||||
// scrollbar, as a percentage of the scrollbar's height.
|
|
||||||
var $this = $(this),
|
|
||||||
offsetPixels = e.clientY - $this.offset().top + $('body').scrollTop(),
|
|
||||||
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 / view.percentPerPost().index;
|
|
||||||
offsetIndex = Math.max(0, Math.min(view.get('count') - 1, offsetIndex));
|
|
||||||
view.get('streamContent').send('goToIndex', Math.floor(offsetIndex));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
// When the mouse is pressed on the scrollbar handle, we capture some
|
|
||||||
// information about its current position. We will store this
|
|
||||||
// information in an object and pass it on to the document's
|
|
||||||
// mousemove/mouseup events later.
|
|
||||||
var dragData = {
|
|
||||||
view: this,
|
|
||||||
mouseStart: 0,
|
|
||||||
indexStart: 0,
|
|
||||||
handle: null
|
|
||||||
};
|
|
||||||
this.$('.scrubber-slider')
|
|
||||||
.css('cursor', 'move')
|
|
||||||
.mousedown(function(e) {
|
|
||||||
dragData.mouseStart = e.clientY;
|
|
||||||
dragData.indexStart = view.get('index');
|
|
||||||
dragData.handle = $(this);
|
|
||||||
view.set('streamContent.paused', true);
|
|
||||||
$('body').css('cursor', 'move');
|
|
||||||
})
|
|
||||||
// Exempt the scrollbar handle from the 'jump to' click event.
|
|
||||||
.click(function(e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// When the mouse moves and when it is released, we pass the
|
|
||||||
// information that we captured when the mouse was first pressed onto
|
|
||||||
// some event handlers. These handlers will move the scrollbar/stream-
|
|
||||||
// content as appropriate.
|
|
||||||
$(document)
|
|
||||||
.on('mousemove', dragData, this.mouseWasMoved)
|
|
||||||
.on('mouseup', dragData, this.mouseWasReleased);
|
|
||||||
|
|
||||||
// Finally, we'll just make sure the scrollbar is in the correct
|
|
||||||
// position according to the values of this.index/visible.
|
|
||||||
this.updateScrollbar(true);
|
|
||||||
},
|
|
||||||
|
|
||||||
willDestroyElement: function() {
|
|
||||||
this.get('streamContent').off('loadingIndex', this, this.loadingIndex);
|
|
||||||
|
|
||||||
$(window)
|
|
||||||
.off('resize', this.windowWasResized)
|
|
||||||
.off('scroll', this.windowWasScrolled);
|
|
||||||
|
|
||||||
$(document)
|
|
||||||
.off('mousemove', this.mouseWasMoved)
|
|
||||||
.off('mouseup', this.mouseWasReleased);
|
|
||||||
},
|
|
||||||
|
|
||||||
// When the stream-content component begins loading posts at a certain
|
|
||||||
// index, we want our scrubber scrollbar to jump to that position.
|
|
||||||
loadingIndex: function(index) {
|
|
||||||
this.set('index', index);
|
|
||||||
this.updateScrollbar(true);
|
|
||||||
},
|
|
||||||
|
|
||||||
windowWasResized: function(event) {
|
|
||||||
var view = event.data.view;
|
|
||||||
view.windowWasScrolled(event);
|
|
||||||
|
|
||||||
// Adjust the height of the scrollbar so that it fills the height of
|
|
||||||
// the sidebar and doesn't overlap the footer.
|
|
||||||
var scrollbar = view.$('.scrubber-scrollbar');
|
|
||||||
scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - $('#footer').outerHeight(true));
|
|
||||||
},
|
|
||||||
|
|
||||||
windowWasScrolled: function(event) {
|
|
||||||
var view = event.data.view;
|
|
||||||
if (view.get('streamContent.active')) {
|
|
||||||
view.update();
|
|
||||||
view.updateScrollbar();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mouseWasMoved: function(event) {
|
|
||||||
if (! event.data.handle) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var view = event.data.view;
|
|
||||||
|
|
||||||
// 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.data.mouseStart,
|
|
||||||
deltaPercent = deltaPixels / view.$('.scrubber-scrollbar').outerHeight() * 100,
|
|
||||||
deltaIndex = deltaPercent / view.percentPerPost().index,
|
|
||||||
newIndex = Math.min(event.data.indexStart + deltaIndex, view.get('count') - 1);
|
|
||||||
|
|
||||||
view.set('index', Math.max(0, newIndex));
|
|
||||||
view.updateScrollbar();
|
|
||||||
view.scrollToIndex(newIndex);
|
|
||||||
},
|
|
||||||
|
|
||||||
mouseWasReleased: function(event) {
|
|
||||||
if (! event.data.handle) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.data.mouseStart = 0;
|
|
||||||
event.data.indexStart = 0;
|
|
||||||
event.data.handle = null;
|
|
||||||
$('body').css('cursor', '');
|
|
||||||
|
|
||||||
var view = event.data.view;
|
|
||||||
|
|
||||||
// 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(view.get('index'));
|
|
||||||
if (! view.get('stream').findNearestToIndex(intIndex).content) {
|
|
||||||
view.get('streamContent').send('goToIndex', intIndex);
|
|
||||||
} else {
|
|
||||||
view.set('streamContent.paused', false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// When the stream-content component resumes being 'active' (for example,
|
|
||||||
// after a bunch of posts have been loaded), then we want to update the
|
|
||||||
// scrubber scrollbar according to the window's current scroll position.
|
|
||||||
resume: function() {
|
|
||||||
var scrubber = this;
|
|
||||||
Ember.run.scheduleOnce('afterRender', function() {
|
|
||||||
if (scrubber.get('streamContent.active')) {
|
|
||||||
scrubber.update();
|
|
||||||
scrubber.updateScrollbar(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}.observes('streamContent.active'),
|
|
||||||
|
|
||||||
// Update the index/visible/description properties according to the
|
|
||||||
// window's current scroll position.
|
|
||||||
update: function() {
|
|
||||||
if (! this.get('streamContent.active')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var $window = $(window),
|
|
||||||
marginTop = this.get('streamContent').getMarginTop(),
|
|
||||||
scrollTop = $window.scrollTop() + marginTop,
|
|
||||||
windowHeight = $window.height() - marginTop;
|
|
||||||
|
|
||||||
// Before looping through all of the posts, we reset the scrollbar
|
|
||||||
// properties to a 'default' state. These values reflect what would be
|
|
||||||
// seen if the browser were scrolled right up to the top of the page,
|
|
||||||
// and the viewport had a height of 0.
|
|
||||||
var $items = this.get('streamContent').$().find('.item');
|
|
||||||
var index = $items.first().data('end') - 1;
|
|
||||||
var visible = 0;
|
|
||||||
var period = '';
|
|
||||||
|
|
||||||
// Now loop through each of the items in the discussion. An 'item' is
|
|
||||||
// either a single post or a 'gap' of one or more posts that haven't
|
|
||||||
// been loaded yet.
|
|
||||||
$items.each(function() {
|
|
||||||
var $this = $(this),
|
|
||||||
top = $this.offset().top,
|
|
||||||
height = $this.outerHeight(true);
|
|
||||||
|
|
||||||
// If this item is above the top of the viewport, skip to the next
|
|
||||||
// post. If it's below the bottom of the viewport, break out of the
|
|
||||||
// loop.
|
|
||||||
if (top + height < scrollTop) {
|
|
||||||
visible = (top + height - scrollTop) / height;
|
|
||||||
index = parseFloat($this.data('end')) + 1 - visible;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (top > scrollTop + windowHeight) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the bottom half of this item is visible at the top of the
|
|
||||||
// viewport, then add the visible proportion to the visible
|
|
||||||
// counter, and set the scrollbar index to whatever the visible
|
|
||||||
// proportion represents. For example, if a gap represents indexes
|
|
||||||
// 0-9, and the bottom 50% of the gap is visible in the viewport,
|
|
||||||
// then the scrollbar index will be 5.
|
|
||||||
if (top <= scrollTop && top + height > scrollTop) {
|
|
||||||
visible = (top + height - scrollTop) / height;
|
|
||||||
index = parseFloat($this.data('end')) + 1 - visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the top half of this item is visible at the bottom of the
|
|
||||||
// viewport, then add the visible proportion to the visible
|
|
||||||
// counter.
|
|
||||||
else if (top + height >= scrollTop + windowHeight) {
|
|
||||||
visible += (scrollTop + windowHeight - top) / height;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the whole item is visible in the viewport, then increment the
|
|
||||||
// visible counter.
|
|
||||||
else {
|
|
||||||
visible++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this item has a time associated with it, then set the
|
|
||||||
// scrollbar's current period to a formatted version of this time.
|
|
||||||
if ($this.data('time')) {
|
|
||||||
period = $this.data('time');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.set('index', index);
|
|
||||||
this.set('visible', visible);
|
|
||||||
this.set('description', period ? moment(period).format('MMMM YYYY') : '');
|
|
||||||
},
|
|
||||||
|
|
||||||
// Update the scrollbar's position to reflect the current values of the
|
|
||||||
// index/visible properties.
|
|
||||||
updateScrollbar: function(animate) {
|
|
||||||
var percentPerPost = this.percentPerPost(),
|
|
||||||
index = this.get('index'),
|
|
||||||
count = this.get('count'),
|
|
||||||
visible = this.get('visible');
|
|
||||||
|
|
||||||
var heights = {};
|
|
||||||
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
|
|
||||||
heights.slider = Math.min(100 - heights.before, percentPerPost.visible * visible);
|
|
||||||
heights.after = 100 - heights.before - heights.slider;
|
|
||||||
|
|
||||||
var $scrubber = this.$();
|
|
||||||
var func = animate ? 'animate' : 'css';
|
|
||||||
for (var part in heights) {
|
|
||||||
var $part = $scrubber.find('.scrubber-'+part);
|
|
||||||
$part.stop(true, true)[func]({height: heights[part]+'%'});
|
|
||||||
|
|
||||||
// jQuery likes to put overflow:hidden, but because the scrollbar
|
|
||||||
// handle has a negative margin-left, we need to override.
|
|
||||||
if (func === 'animate') {
|
|
||||||
$part.css('overflow', 'visible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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: function(index) {
|
|
||||||
index = Math.min(index, this.get('count') - 1);
|
|
||||||
|
|
||||||
// Find the item for this index, whether it's a post corresponding to
|
|
||||||
// the index, or a gap which the index is within.
|
|
||||||
var indexFloor = Math.max(0, Math.floor(index)),
|
|
||||||
$nearestItem = this.get('streamContent').findNearestToIndex(indexFloor);
|
|
||||||
|
|
||||||
// Calculate the position of this item so that we can scroll to it. If
|
|
||||||
// the item is a gap, then we will mark it as 'active' to indicate to
|
|
||||||
// the user that it will expand if they release their mouse.
|
|
||||||
// Otherwise, we will add a proportion of the item's height onto the
|
|
||||||
// scroll position.
|
|
||||||
var pos = $nearestItem.offset().top - this.get('streamContent').getMarginTop();
|
|
||||||
if ($nearestItem.is('.gap')) {
|
|
||||||
$nearestItem.addClass('active');
|
|
||||||
} else {
|
|
||||||
if (index >= 0) {
|
|
||||||
pos += $nearestItem.outerHeight(true) * (index - indexFloor);
|
|
||||||
} else {
|
|
||||||
pos += $nearestItem.offset().top * index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the 'active' class from other gaps.
|
|
||||||
this.get('streamContent').$().find('.gap').not($nearestItem).removeClass('active');
|
|
||||||
|
|
||||||
$('html, body').scrollTop(pos);
|
|
||||||
},
|
|
||||||
|
|
||||||
percentPerPost: function() {
|
|
||||||
var count = this.get('count') || 1,
|
|
||||||
visible = this.get('visible');
|
|
||||||
|
|
||||||
// To stop the slider of the scrollbar from getting too small when there
|
|
||||||
// are many posts, we define a minimum percentage height for the slider
|
|
||||||
// calculated from a 50 pixel limit. From this, we can calculate the
|
|
||||||
// minimum percentage per visible post. If this is greater than the
|
|
||||||
// actual percentage per post, then we need to adjust the 'before'
|
|
||||||
// percentage to account for it.
|
|
||||||
var minPercentVisible = 50 / this.$('.scrubber-scrollbar').outerHeight() * 100;
|
|
||||||
var percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
|
|
||||||
var percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
|
|
||||||
|
|
||||||
return {
|
|
||||||
index: percentPerPost,
|
|
||||||
visible: percentPerVisiblePost
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
first: function() {
|
|
||||||
this.get('streamContent').send('goToFirst');
|
|
||||||
},
|
|
||||||
|
|
||||||
last: function() {
|
|
||||||
this.get('streamContent').send('goToLast');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
87
ember/app/components/index/discussion-listing.js
Executable file
87
ember/app/components/index/discussion-listing.js
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import HasItemLists from 'flarum/mixins/has-item-lists';
|
||||||
|
import FadeIn from 'flarum/mixins/fade-in';
|
||||||
|
|
||||||
|
/**
|
||||||
|
Component for a discussion listing on the discussions index. It has `info`
|
||||||
|
and `controls` item lists for a bit of flexibility.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend(FadeIn, HasItemLists, {
|
||||||
|
layoutName: 'components/index/discussion-listing',
|
||||||
|
tagName: 'li',
|
||||||
|
attributeBindings: ['discussionId:data-id'],
|
||||||
|
classNames: ['discussion-summary'],
|
||||||
|
classNameBindings: [
|
||||||
|
'discussion.isUnread:unread',
|
||||||
|
'active'
|
||||||
|
],
|
||||||
|
itemLists: ['info', 'controls'],
|
||||||
|
|
||||||
|
terminalPostType: 'last',
|
||||||
|
countType: 'unread',
|
||||||
|
|
||||||
|
discussionId: Ember.computed.alias('discussion.id'),
|
||||||
|
|
||||||
|
active: Ember.computed('childViews.@each.active', function() {
|
||||||
|
return this.get('childViews').anyBy('active');
|
||||||
|
}),
|
||||||
|
|
||||||
|
displayUnread: Ember.computed('countType', 'discussion.isUnread', function() {
|
||||||
|
return this.get('countType') === 'unread' && this.get('discussion.isUnread');
|
||||||
|
}),
|
||||||
|
|
||||||
|
countTitle: Ember.computed('discussion.isUnread', function() {
|
||||||
|
return this.get('discussion.isUnread') ? 'Mark as Read' : '';
|
||||||
|
}),
|
||||||
|
|
||||||
|
displayLastPost: Ember.computed('terminalPostType', 'discussion.repliesCount', function() {
|
||||||
|
return this.get('terminalPostType') === 'last' && this.get('discussion.repliesCount');
|
||||||
|
}),
|
||||||
|
|
||||||
|
start: Ember.computed('discussion.lastPostNumber', 'discussion.readNumber', function() {
|
||||||
|
return Math.min(this.get('discussion.lastPostNumber'), (this.get('discussion.readNumber') || 0) + 1);
|
||||||
|
}),
|
||||||
|
|
||||||
|
relevantPosts: Ember.computed('discussion.relevantPosts', 'discussion.startPost', 'discussion.lastPost', function() {
|
||||||
|
if (this.get('controller.show') !== 'posts') { return []; }
|
||||||
|
if (this.get('controller.searchQuery')) {
|
||||||
|
return this.get('discussion.relevantPosts');
|
||||||
|
} else if (this.get('controller.sort') === 'newest' || this.get('controller.sort') === 'oldest') {
|
||||||
|
return [this.get('discussion.startPost')];
|
||||||
|
} else {
|
||||||
|
return [this.get('discussion.lastPost')];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
populateControls: function(items) {
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
populateInfo: function(items) {
|
||||||
|
items.pushObjectWithTag(Ember.Component.extend({
|
||||||
|
classNames: ['terminal-post'],
|
||||||
|
layoutName: 'components/index/discussion-info/terminal-post',
|
||||||
|
discussion: Ember.computed.alias('parent.discussion'),
|
||||||
|
displayLastPost: Ember.computed.alias('parent.displayLastPost'),
|
||||||
|
}).create({parent: this}), 'terminalPost');
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// In the template, we render the "controls" dropdown with the contents of
|
||||||
|
// the `renderControls` property. This way, when a post is initially
|
||||||
|
// rendered, it doesn't have to go to the trouble of rendering the
|
||||||
|
// controls right away, which speeds things up. When the dropdown button
|
||||||
|
// is clicked, this will fill in the actual controls.
|
||||||
|
renderControls: function() {
|
||||||
|
this.set('renderControls', this.get('controls'));
|
||||||
|
},
|
||||||
|
|
||||||
|
markAsRead: function() {
|
||||||
|
if (this.get('discussion.isUnread')) {
|
||||||
|
discussion.set('readNumber', discussion.get('lastPostNumber'));
|
||||||
|
discussion.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
19
ember/app/components/index/welcome-hero.js
Normal file
19
ember/app/components/index/welcome-hero.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
/**
|
||||||
|
Component for the "welcome to this forum" hero on the discussions index.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
layoutName: 'components/index/welcome-hero',
|
||||||
|
tagName: 'header',
|
||||||
|
classNames: ['hero', 'welcome-hero'],
|
||||||
|
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
close: function() {
|
||||||
|
this.$().slideUp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
29
ember/app/components/ui/action-button.js
Normal file
29
ember/app/components/ui/action-button.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
Button which sends an action when clicked.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
tagName: 'a',
|
||||||
|
attributeBindings: ['href', 'title'],
|
||||||
|
classNameBindings: ['className'],
|
||||||
|
href: '#',
|
||||||
|
layout: precompileTemplate('{{#if icon}}{{fa-icon icon class="fa-fw icon-glyph"}} {{/if}}<span>{{label}}</span>'),
|
||||||
|
|
||||||
|
label: '',
|
||||||
|
icon: '',
|
||||||
|
className: '',
|
||||||
|
action: null,
|
||||||
|
|
||||||
|
click: function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var action = this.get('action');
|
||||||
|
if (typeof action === 'string') {
|
||||||
|
this.sendAction('action');
|
||||||
|
} else if (typeof action === 'function') {
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
53
ember/app/components/ui/alert-message.js
Executable file
53
ember/app/components/ui/alert-message.js
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import HasItemLists from 'flarum/mixins/has-item-lists';
|
||||||
|
import ActionButton from 'flarum/components/ui/action-button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
An alert message. Has a message, a `controls` item list, and a dismiss
|
||||||
|
button.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend(HasItemLists, {
|
||||||
|
layoutName: 'components/ui/alert-message',
|
||||||
|
classNames: ['alert'],
|
||||||
|
classNameBindings: ['classForType'],
|
||||||
|
itemLists: ['controls'],
|
||||||
|
|
||||||
|
message: '',
|
||||||
|
type: '',
|
||||||
|
dismissable: true,
|
||||||
|
buttons: [],
|
||||||
|
|
||||||
|
classForType: Ember.computed('type', function() {
|
||||||
|
return 'alert-'+this.get('type');
|
||||||
|
}),
|
||||||
|
|
||||||
|
populateControls: function(controls) {
|
||||||
|
var component = this;
|
||||||
|
|
||||||
|
this.get('buttons').forEach(function(button) {
|
||||||
|
controls.pushObject(ActionButton.create({
|
||||||
|
label: button.label,
|
||||||
|
action: function() {
|
||||||
|
component.send('dismiss');
|
||||||
|
button.action();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.get('dismissable')) {
|
||||||
|
var dismiss = ActionButton.create({
|
||||||
|
icon: 'times',
|
||||||
|
className: 'btn btn-icon btn-link',
|
||||||
|
action: function() { component.send('dismiss'); }
|
||||||
|
});
|
||||||
|
controls.pushObjectWithTag(dismiss, 'dismiss');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
dismiss: function() {
|
||||||
|
this.sendAction('dismiss', this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@@ -1,28 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
label: '',
|
|
||||||
icon: '',
|
|
||||||
className: '',
|
|
||||||
action: null,
|
|
||||||
divider: false,
|
|
||||||
active: false,
|
|
||||||
|
|
||||||
classNames: [],
|
|
||||||
|
|
||||||
tagName: 'a',
|
|
||||||
attributeBindings: ['href', 'title'],
|
|
||||||
classNameBindings: ['className'],
|
|
||||||
href: '#',
|
|
||||||
layout: Ember.Handlebars.compile('{{#if icon}}{{fa-icon icon class="fa-fw icon-glyph"}} {{/if}}<span>{{label}}</span>'),
|
|
||||||
|
|
||||||
click: function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
var action = this.get('action');
|
|
||||||
if (typeof action === 'string') {
|
|
||||||
this.sendAction('action');
|
|
||||||
} else if (typeof action === 'function') {
|
|
||||||
action();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,27 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
items: null, // TaggedArray
|
|
||||||
layoutName: 'components/ui/controls/dropdown-button',
|
|
||||||
classNames: ['dropdown', 'btn-group'],
|
|
||||||
classNameBindings: ['itemCountClass', 'class'],
|
|
||||||
|
|
||||||
label: 'Controls',
|
|
||||||
icon: 'ellipsis-v',
|
|
||||||
buttonClass: 'btn btn-default',
|
|
||||||
menuClass: '',
|
|
||||||
|
|
||||||
dropdownMenuClass: function() {
|
|
||||||
return 'dropdown-menu '+this.get('menuClass');
|
|
||||||
}.property('menuClass'),
|
|
||||||
|
|
||||||
itemCountClass: function() {
|
|
||||||
return 'item-count-'+this.get('items.length');
|
|
||||||
}.property('items.length'),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
buttonClick: function() {
|
|
||||||
this.sendAction('buttonClick');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,28 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
items: [],
|
|
||||||
layoutName: 'components/ui/controls/dropdown-select',
|
|
||||||
classNames: ['dropdown', 'dropdown-select', 'btn-group'],
|
|
||||||
classNameBindings: ['itemCountClass', 'class'],
|
|
||||||
|
|
||||||
buttonClass: 'btn btn-default',
|
|
||||||
menuClass: '',
|
|
||||||
icon: 'ellipsis-v',
|
|
||||||
|
|
||||||
mainButtonClass: function() {
|
|
||||||
return 'btn '+this.get('buttonClass');
|
|
||||||
}.property('buttonClass'),
|
|
||||||
|
|
||||||
dropdownMenuClass: function() {
|
|
||||||
return 'dropdown-menu '+this.get('menuClass');
|
|
||||||
}.property('menuClass'),
|
|
||||||
|
|
||||||
itemCountClass: function() {
|
|
||||||
return 'item-count-'+this.get('items.length');
|
|
||||||
}.property('items.length'),
|
|
||||||
|
|
||||||
activeItem: function() {
|
|
||||||
return this.get('menu.childViews').findBy('active');
|
|
||||||
}.property('menu.childViews.@each.active')
|
|
||||||
});
|
|
@@ -1,15 +0,0 @@
|
|||||||
import DropdownButton from './dropdown-button';
|
|
||||||
|
|
||||||
export default DropdownButton.extend({
|
|
||||||
layoutName: 'components/ui/controls/dropdown-split',
|
|
||||||
classNames: ['dropdown', 'dropdown-split', 'btn-group'],
|
|
||||||
menuClass: 'pull-right',
|
|
||||||
|
|
||||||
mainButtonClass: function() {
|
|
||||||
return 'btn '+this.get('buttonClass');
|
|
||||||
}.property('buttonClass'),
|
|
||||||
|
|
||||||
firstItem: function() {
|
|
||||||
return this.get('items').objectAt(0);
|
|
||||||
}.property('items.[]')
|
|
||||||
});
|
|
@@ -1,22 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
import ComponentItem from '../items/component-item';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
tagName: 'ul',
|
|
||||||
layoutName: 'components/ui/controls/item-list',
|
|
||||||
|
|
||||||
listItems: function() {
|
|
||||||
if (!Ember.isArray(this.get('items'))) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
var listItems = [];
|
|
||||||
this.get('items').forEach(function(item) {
|
|
||||||
if (item.get('tagName') !== 'li') {
|
|
||||||
item = ComponentItem.extend({component: item});
|
|
||||||
}
|
|
||||||
listItems.push(item);
|
|
||||||
});
|
|
||||||
return listItems;
|
|
||||||
}.property('items.[]')
|
|
||||||
});
|
|
@@ -1,14 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
classNames: ['loading-indicator'],
|
|
||||||
|
|
||||||
layout: Ember.Handlebars.compile(' '),
|
|
||||||
size: 'small',
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
var size = this.get('size');
|
|
||||||
Ember.$.fn.spin.presets[size].zIndex = 'auto';
|
|
||||||
this.$().spin(size);
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,40 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
classNames: ['search-input'],
|
|
||||||
classNameBindings: ['active', 'value:clearable'],
|
|
||||||
|
|
||||||
layoutName: 'components/ui/controls/search-input',
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
var self = this;
|
|
||||||
this.$().find('input').on('keydown', function(e) {
|
|
||||||
if (e.which === 27) {
|
|
||||||
self.clear();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.$().find('.clear').on('mousedown', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
}).on('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
self.clear();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clear: function() {
|
|
||||||
this.set('value', '');
|
|
||||||
this.send('search');
|
|
||||||
this.$().find('input').focus();
|
|
||||||
},
|
|
||||||
|
|
||||||
willDestroyElement: function() {
|
|
||||||
this.$().find('input').off('keydown');
|
|
||||||
this.$().find('.clear').off('mousedown click');
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
search: function() {
|
|
||||||
this.get('action')(this.get('value'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,9 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
tagName: 'span',
|
|
||||||
classNames: ['select-input'],
|
|
||||||
optionValuePath: 'content',
|
|
||||||
optionLabelPath: 'content',
|
|
||||||
layout: Ember.Handlebars.compile('{{view "select" content=view.content optionValuePath=view.optionValuePath optionLabelPath=view.optionLabelPath value=view.value class="form-control"}} {{fa-icon "sort"}}')
|
|
||||||
});
|
|
@@ -1,39 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
import TaggedArray from '../../../utils/tagged-array';
|
|
||||||
import ActionButton from './action-button';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
disabled: false,
|
|
||||||
|
|
||||||
classNames: ['text-editor'],
|
|
||||||
|
|
||||||
didInsertElement: function() {
|
|
||||||
var controlItems = TaggedArray.create();
|
|
||||||
this.trigger('populateControls', controlItems);
|
|
||||||
this.set('controlItems', controlItems);
|
|
||||||
|
|
||||||
var component = this;
|
|
||||||
this.$('textarea').bind('keydown', 'meta+return', function() {
|
|
||||||
component.send('submit');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
populateControls: function(controls) {
|
|
||||||
var component = this;
|
|
||||||
var submit = ActionButton.create({
|
|
||||||
label: this.get('submitLabel'),
|
|
||||||
className: 'btn btn-primary',
|
|
||||||
action: function() {
|
|
||||||
component.send('submit');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
controls.pushObjectWithTag(submit, 'submit');
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
submit: function() {
|
|
||||||
this.sendAction('submit', this.get('value'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,18 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.TextField.extend({
|
|
||||||
didInsertElement: function() {
|
|
||||||
var component = this;
|
|
||||||
this.$().on('input', function() {
|
|
||||||
var empty = !$(this).val();
|
|
||||||
if (empty) {
|
|
||||||
$(this).val(component.get('placeholder'));
|
|
||||||
}
|
|
||||||
$(this).css('width', 0);
|
|
||||||
$(this).width($(this)[0].scrollWidth);
|
|
||||||
if (empty) {
|
|
||||||
$(this).val('');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
30
ember/app/components/ui/dropdown-button.js
Normal file
30
ember/app/components/ui/dropdown-button.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
/**
|
||||||
|
Button which has an attached dropdown menu containing an item list.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
layoutName: 'components/ui/dropdown-button',
|
||||||
|
classNames: ['dropdown', 'btn-group'],
|
||||||
|
classNameBindings: ['itemCountClass', 'class'],
|
||||||
|
|
||||||
|
label: 'Controls',
|
||||||
|
icon: 'ellipsis-v',
|
||||||
|
buttonClass: 'btn btn-default',
|
||||||
|
menuClass: '',
|
||||||
|
items: null,
|
||||||
|
|
||||||
|
dropdownMenuClass: Ember.computed('menuClass', function() {
|
||||||
|
return 'dropdown-menu '+this.get('menuClass');
|
||||||
|
}),
|
||||||
|
|
||||||
|
itemCountClass: Ember.computed('items.length', function() {
|
||||||
|
return 'item-count-'+this.get('items.length');
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
buttonClick: function() {
|
||||||
|
this.sendAction('buttonClick');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
32
ember/app/components/ui/dropdown-select.js
Normal file
32
ember/app/components/ui/dropdown-select.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
/**
|
||||||
|
Button which has an attached dropdown menu containing an item list. The
|
||||||
|
currently-active item's label is displayed as the label of the button.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
layoutName: 'components/ui/dropdown-select',
|
||||||
|
classNames: ['dropdown', 'dropdown-select', 'btn-group'],
|
||||||
|
classNameBindings: ['itemCountClass', 'class'],
|
||||||
|
|
||||||
|
buttonClass: 'btn btn-default',
|
||||||
|
menuClass: '',
|
||||||
|
icon: 'ellipsis-v',
|
||||||
|
items: [],
|
||||||
|
|
||||||
|
mainButtonClass: Ember.computed('buttonClass', function() {
|
||||||
|
return 'btn '+this.get('buttonClass');
|
||||||
|
}),
|
||||||
|
|
||||||
|
dropdownMenuClass: Ember.computed('menuClass', function() {
|
||||||
|
return 'dropdown-menu '+this.get('menuClass');
|
||||||
|
}),
|
||||||
|
|
||||||
|
itemCountClass: Ember.computed('items.length', function() {
|
||||||
|
return 'item-count-'+this.get('items.length');
|
||||||
|
}),
|
||||||
|
|
||||||
|
activeItem: Ember.computed('menu.childViews.@each.active', function() {
|
||||||
|
return this.get('menu.childViews').findBy('active');
|
||||||
|
})
|
||||||
|
});
|
22
ember/app/components/ui/dropdown-split.js
Normal file
22
ember/app/components/ui/dropdown-split.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import DropdownButton from 'flarum/components/ui/dropdown-button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
Given a list of items, this component displays a split button: the left side
|
||||||
|
is the first item in the list, while the right side is a dropdown-toggle
|
||||||
|
which shows a dropdown menu containing all of the items.
|
||||||
|
*/
|
||||||
|
export default DropdownButton.extend({
|
||||||
|
layoutName: 'components/ui/dropdown-split',
|
||||||
|
classNames: ['dropdown', 'dropdown-split', 'btn-group'],
|
||||||
|
menuClass: 'pull-right',
|
||||||
|
|
||||||
|
mainButtonClass: Ember.computed('buttonClass', function() {
|
||||||
|
return 'btn '+this.get('buttonClass');
|
||||||
|
}),
|
||||||
|
|
||||||
|
firstItem: Ember.computed('items.[]', function() {
|
||||||
|
return this.get('items').objectAt(0);
|
||||||
|
})
|
||||||
|
});
|
21
ember/app/components/ui/item-list.js
Normal file
21
ember/app/components/ui/item-list.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
/**
|
||||||
|
Output a list of components within a <ul>, making sure each one is contained
|
||||||
|
in an <li> element.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
layoutName: 'components/ui/item-list',
|
||||||
|
tagName: 'ul',
|
||||||
|
|
||||||
|
listItems: Ember.computed('items.[]', function() {
|
||||||
|
var items = this.get('items');
|
||||||
|
if (!Ember.isArray(items)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
items.forEach(function(item) {
|
||||||
|
item.set('isListItem', item.get('tagName') === 'li');
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
})
|
||||||
|
});
|
@@ -1,6 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
tagName: 'li',
|
|
||||||
layoutName: 'components/ui/items/component-item'
|
|
||||||
});
|
|
@@ -1,37 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
icon: '',
|
|
||||||
label: '',
|
|
||||||
action: null,
|
|
||||||
badge: '',
|
|
||||||
|
|
||||||
tagName: 'li',
|
|
||||||
classNameBindings: ['active'],
|
|
||||||
active: function() {
|
|
||||||
return !! this.get('childViews').anyBy('active');
|
|
||||||
}.property('childViews.@each.active'),
|
|
||||||
|
|
||||||
// init: function() {
|
|
||||||
// var params = this.params;
|
|
||||||
// if (params[params.length - 1].queryParams) {
|
|
||||||
// this.queryParamsObject = {values: params.pop().queryParams};
|
|
||||||
// }
|
|
||||||
|
|
||||||
// this._super();
|
|
||||||
// },
|
|
||||||
|
|
||||||
layout: function() {
|
|
||||||
return Ember.Handlebars.compile('{{#link-to '+this.get('linkTo')+'}}'+this.get('iconTemplate')+' {{label}} <span class="count">{{badge}}</span>{{/link-to}}');
|
|
||||||
}.property('linkTo', 'iconTemplate'),
|
|
||||||
|
|
||||||
iconTemplate: function() {
|
|
||||||
return '{{fa-icon icon}}';
|
|
||||||
}.property(),
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
main: function() {
|
|
||||||
this.get('action')();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
@@ -1,6 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
tagName: 'li',
|
|
||||||
classNames: ['divider']
|
|
||||||
});
|
|
19
ember/app/components/ui/loading-indicator.js
Normal file
19
ember/app/components/ui/loading-indicator.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
Loading spinner.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
classNames: ['loading-indicator'],
|
||||||
|
|
||||||
|
layout: precompileTemplate(' '),
|
||||||
|
size: 'small',
|
||||||
|
|
||||||
|
didInsertElement: function() {
|
||||||
|
var size = this.get('size');
|
||||||
|
Ember.$.fn.spin.presets[size].zIndex = 'auto';
|
||||||
|
this.$().spin(size);
|
||||||
|
}
|
||||||
|
});
|
22
ember/app/components/ui/nav-item.js
Normal file
22
ember/app/components/ui/nav-item.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
A list item which contains a navigation link. The list item's `active`
|
||||||
|
property reflects whether or not the link is active.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
layout: precompileTemplate('{{#link-to routeName}}{{fa-icon icon}} {{label}} <span class="count">{{badge}}</span>{{/link-to}}'),
|
||||||
|
tagName: 'li',
|
||||||
|
classNameBindings: ['active'],
|
||||||
|
|
||||||
|
icon: '',
|
||||||
|
label: '',
|
||||||
|
badge: '',
|
||||||
|
routeName: '',
|
||||||
|
|
||||||
|
active: Ember.computed('childViews.@each.active', function() {
|
||||||
|
return !!this.get('childViews').anyBy('active');
|
||||||
|
})
|
||||||
|
});
|
36
ember/app/components/ui/search-input.js
Normal file
36
ember/app/components/ui/search-input.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
/**
|
||||||
|
A basic search input. Comes with the ability to be cleared by pressing
|
||||||
|
escape or with a button. Sends an action when enter is pressed.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
layoutName: 'components/ui/search-input',
|
||||||
|
classNames: ['search-input'],
|
||||||
|
classNameBindings: ['active', 'value:clearable'],
|
||||||
|
|
||||||
|
didInsertElement: function() {
|
||||||
|
this.$('input').on('keydown', 'esc', function(e) {
|
||||||
|
self.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
this.$('.clear').on('mousedown click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}).on('click', function(e) {
|
||||||
|
self.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: function() {
|
||||||
|
this.set('value', '');
|
||||||
|
this.send('search');
|
||||||
|
this.$().find('input').focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
search: function() {
|
||||||
|
this.get('action')(this.get('value'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
16
ember/app/components/ui/select-input.js
Normal file
16
ember/app/components/ui/select-input.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
var precompileTemplate = Ember.Handlebars.compile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
A basic select input. Wraps Ember's select component with a span/icon so
|
||||||
|
that we can style it more fancily.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
layout: precompileTemplate('{{view "select" content=view.content optionValuePath=view.optionValuePath optionLabelPath=view.optionLabelPath value=view.value class="form-control"}} {{fa-icon "sort"}}'),
|
||||||
|
tagName: 'span',
|
||||||
|
classNames: ['select-input'],
|
||||||
|
|
||||||
|
optionValuePath: 'content',
|
||||||
|
optionLabelPath: 'content'
|
||||||
|
});
|
9
ember/app/components/ui/separator-item.js
Normal file
9
ember/app/components/ui/separator-item.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
/**
|
||||||
|
A simple separator list item for use in menus.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend({
|
||||||
|
tagName: 'li',
|
||||||
|
classNames: ['divider']
|
||||||
|
});
|
33
ember/app/components/ui/text-editor.js
Normal file
33
ember/app/components/ui/text-editor.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import HasItemLists from 'flarum/mixins/has-item-lists';
|
||||||
|
import ActionButton from 'flarum/components/ui/action-button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
A text editor. Contains a textarea and an item list of `controls`, including
|
||||||
|
a submit button.
|
||||||
|
*/
|
||||||
|
export default Ember.Component.extend(HasItemLists, {
|
||||||
|
classNames: ['text-editor'],
|
||||||
|
itemLists: ['controls'],
|
||||||
|
|
||||||
|
value: '',
|
||||||
|
disabled: false,
|
||||||
|
|
||||||
|
didInsertElement: function() {
|
||||||
|
var component = this;
|
||||||
|
this.$('textarea').bind('keydown', 'meta+return', function() {
|
||||||
|
component.send('submit');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
populateControls: function(items) {
|
||||||
|
this.addActionItem(items, 'submit', this.get('submitLabel')).set('className', 'btn btn-primary');
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
submit: function() {
|
||||||
|
this.sendAction('submit', this.get('value'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
26
ember/app/components/ui/text-input.js
Normal file
26
ember/app/components/ui/text-input.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
/**
|
||||||
|
An extension of Ember's text field with an option to set up an auto-growing
|
||||||
|
text input.
|
||||||
|
*/
|
||||||
|
export default Ember.TextField.extend({
|
||||||
|
autoGrow: false,
|
||||||
|
|
||||||
|
didInsertElement: function() {
|
||||||
|
if (this.get('autoGrow')) {
|
||||||
|
var component = this;
|
||||||
|
this.$().on('input', function() {
|
||||||
|
var empty = !$(this).val();
|
||||||
|
if (empty) {
|
||||||
|
$(this).val(component.get('placeholder'));
|
||||||
|
}
|
||||||
|
$(this).css('width', 0);
|
||||||
|
$(this).width($(this)[0].scrollWidth);
|
||||||
|
if (empty) {
|
||||||
|
$(this).val('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@@ -1,14 +0,0 @@
|
|||||||
import Ember from 'ember';
|
|
||||||
|
|
||||||
export default Ember.Component.extend({
|
|
||||||
|
|
||||||
tagName: 'header',
|
|
||||||
classNames: ['hero', 'welcome-hero'],
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
close: function() {
|
|
||||||
this.$().slideUp();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
@@ -2,22 +2,22 @@ import Ember from 'ember';
|
|||||||
|
|
||||||
export default Ember.Controller.extend({
|
export default Ember.Controller.extend({
|
||||||
|
|
||||||
// The title of the forum.
|
// The title of the forum.
|
||||||
// TODO: Preload this value in the index.html payload from Laravel config.
|
// TODO: Preload this value in the index.html payload from Laravel config.
|
||||||
forumTitle: 'Flarum Demo Forum',
|
forumTitle: 'Flarum Demo Forum',
|
||||||
|
|
||||||
// The title of the current page. This should be set as appropriate in
|
// The title of the current page. This should be set as appropriate in
|
||||||
// controllers/views.
|
// controllers/views.
|
||||||
pageTitle: '',
|
pageTitle: '',
|
||||||
|
|
||||||
backButtonTarget: null,
|
backButtonTarget: null,
|
||||||
|
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
searchActive: false,
|
searchActive: false,
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
search: function(query) {
|
search: function(query) {
|
||||||
this.transitionToRoute('index', {queryParams: {searchQuery: query, sort: query ? 'relevance' : 'recent'}});
|
this.transitionToRoute('index', {queryParams: {searchQuery: query, sort: query ? 'relevance' : 'recent'}});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -1,99 +1,99 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
export var PositionEnum = {
|
export var PositionEnum = {
|
||||||
HIDDEN: 'hidden',
|
HIDDEN: 'hidden',
|
||||||
NORMAL: 'normal',
|
NORMAL: 'normal',
|
||||||
MINIMIZED: 'minimized',
|
MINIMIZED: 'minimized',
|
||||||
FULLSCREEN: 'fullscreen'
|
FULLSCREEN: 'fullscreen'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Ember.Controller.extend(Ember.Evented, {
|
export default Ember.Controller.extend(Ember.Evented, {
|
||||||
content: null,
|
content: null,
|
||||||
position: PositionEnum.HIDDEN,
|
position: PositionEnum.HIDDEN,
|
||||||
|
|
||||||
visible: Ember.computed.or('normal', 'minimized', 'fullscreen'),
|
visible: Ember.computed.or('normal', 'minimized', 'fullscreen'),
|
||||||
normal: Ember.computed.equal('position', PositionEnum.NORMAL),
|
normal: Ember.computed.equal('position', PositionEnum.NORMAL),
|
||||||
minimized: Ember.computed.equal('position', PositionEnum.MINIMIZED),
|
minimized: Ember.computed.equal('position', PositionEnum.MINIMIZED),
|
||||||
fullscreen: Ember.computed.equal('position', PositionEnum.FULLSCREEN),
|
fullscreen: Ember.computed.equal('position', PositionEnum.FULLSCREEN),
|
||||||
|
|
||||||
// Switch out the composer's content for a new component. The old
|
// Switch out the composer's content for a new component. The old
|
||||||
// component will be given the opportunity to abort the switch. Note:
|
// component will be given the opportunity to abort the switch. Note:
|
||||||
// there appears to be a bug in Ember where the content binding won't
|
// there appears to be a bug in Ember where the content binding won't
|
||||||
// update in the view if we switch the value out immediately. As a
|
// update in the view if we switch the value out immediately. As a
|
||||||
// workaround, we set it to null, and then set it to its new value in the
|
// workaround, we set it to null, and then set it to its new value in the
|
||||||
// next run loop iteration.
|
// next run loop iteration.
|
||||||
switchContent: function(newContent) {
|
switchContent: function(newContent) {
|
||||||
var composer = this;
|
var composer = this;
|
||||||
this.confirmExit().then(function() {
|
this.confirmExit().then(function() {
|
||||||
composer.set('content', null);
|
composer.set('content', null);
|
||||||
Ember.run.next(function() {
|
Ember.run.next(function() {
|
||||||
newContent.set('composer', composer);
|
newContent.composer = composer;
|
||||||
composer.set('content', newContent);
|
composer.set('content', newContent);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ask the content component if it's OK to close it, and give it the
|
||||||
|
// opportunity to abort. The content component must respond to the
|
||||||
|
// `willExit(abort)` action, and call `abort()` if we should not proceed.
|
||||||
|
confirmExit: function() {
|
||||||
|
var composer = this;
|
||||||
|
var promise = new Ember.RSVP.Promise(function(resolve, reject) {
|
||||||
|
var content = composer.get('content');
|
||||||
|
if (content) {
|
||||||
|
content.send('willExit', reject);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
show: function() {
|
||||||
|
var composer = this;
|
||||||
|
|
||||||
|
// We do this in the next run loop because we need to wait for new
|
||||||
|
// content to be switched in. See `switchContent` above.
|
||||||
|
Ember.run.next(function() {
|
||||||
|
composer.set('position', PositionEnum.NORMAL);
|
||||||
|
composer.trigger('focus');
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Ask the content component if it's OK to close it, and give it the
|
hide: function() {
|
||||||
// opportunity to abort. The content component must respond to the
|
this.set('position', PositionEnum.HIDDEN);
|
||||||
// `willExit(abort)` action, and call `abort()` if we should not proceed.
|
|
||||||
confirmExit: function() {
|
|
||||||
var composer = this;
|
|
||||||
var promise = new Ember.RSVP.Promise(function(resolve, reject) {
|
|
||||||
var content = composer.get('content');
|
|
||||||
if (content) {
|
|
||||||
content.send('willExit', reject);
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
return promise;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
clearContent: function() {
|
||||||
show: function() {
|
this.set('content', null);
|
||||||
var composer = this;
|
},
|
||||||
|
|
||||||
// We do this in the next run loop because we need to wait for new
|
close: function() {
|
||||||
// content to be switched in. See `switchContent` above.
|
var composer = this;
|
||||||
Ember.run.next(function() {
|
this.confirmExit().then(function() {
|
||||||
composer.set('position', PositionEnum.NORMAL);
|
composer.send('hide');
|
||||||
composer.trigger('focus');
|
});
|
||||||
});
|
},
|
||||||
},
|
|
||||||
|
|
||||||
hide: function() {
|
minimize: function() {
|
||||||
this.set('position', PositionEnum.HIDDEN);
|
if (this.get('position') !== PositionEnum.HIDDEN) {
|
||||||
},
|
this.set('position', PositionEnum.MINIMIZED);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearContent: function() {
|
fullscreen: function() {
|
||||||
this.set('content', null);
|
if (this.get('position') !== PositionEnum.HIDDEN) {
|
||||||
},
|
this.set('position', PositionEnum.FULLSCREEN);
|
||||||
|
this.trigger('focus');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
close: function() {
|
exitFullscreen: function() {
|
||||||
var composer = this;
|
if (this.get('position') === PositionEnum.FULLSCREEN) {
|
||||||
this.confirmExit().then(function() {
|
this.set('position', PositionEnum.NORMAL);
|
||||||
composer.send('hide');
|
this.trigger('focus');
|
||||||
});
|
}
|
||||||
},
|
|
||||||
|
|
||||||
minimize: function() {
|
|
||||||
if (this.get('position') !== PositionEnum.HIDDEN) {
|
|
||||||
this.set('position', PositionEnum.MINIMIZED);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
fullscreen: function() {
|
|
||||||
if (this.get('position') !== PositionEnum.HIDDEN) {
|
|
||||||
this.set('position', PositionEnum.FULLSCREEN);
|
|
||||||
this.trigger('focus');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
exitFullscreen: function() {
|
|
||||||
if (this.get('position') === PositionEnum.FULLSCREEN) {
|
|
||||||
this.set('position', PositionEnum.NORMAL);
|
|
||||||
this.trigger('focus');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -1,116 +1,103 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
import ComposerReply from '../components/discussions/composer-reply';
|
import ComposerReply from 'flarum/components/composer/composer-reply';
|
||||||
import ActionButton from '../components/ui/controls/action-button';
|
import ActionButton from 'flarum/components/ui/action-button';
|
||||||
import AlertMessage from '../components/alert-message';
|
import AlertMessage from 'flarum/components/ui/alert-message';
|
||||||
|
import UseComposerMixin from 'flarum/mixins/use-composer';
|
||||||
|
|
||||||
export default Ember.Controller.extend(Ember.Evented, {
|
export default Ember.Controller.extend(Ember.Evented, UseComposerMixin, {
|
||||||
needs: ['application', 'alerts', 'composer'],
|
needs: ['application'],
|
||||||
|
composer: Ember.inject.controller('composer'),
|
||||||
queryParams: ['start'],
|
alerts: Ember.inject.controller('alerts'),
|
||||||
start: '1',
|
|
||||||
searchQuery: '',
|
|
||||||
|
|
||||||
loaded: false,
|
queryParams: ['start'],
|
||||||
stream: null,
|
start: '1',
|
||||||
|
searchQuery: '',
|
||||||
|
|
||||||
// Save a reply. This may be called by a composer-reply component that was
|
loaded: false,
|
||||||
// set up on a different discussion, so we require a discussion model to
|
stream: null,
|
||||||
// be explicitly passed rather than using the controller's implicit one.
|
|
||||||
// @todo break this down into bite-sized functions so that extensions can
|
|
||||||
// easily override where they please.
|
|
||||||
saveReply: function(discussion, data) {
|
|
||||||
var controller = this;
|
|
||||||
var composer = this.get('controllers.composer');
|
|
||||||
var stream = this.get('stream');
|
|
||||||
|
|
||||||
composer.set('content.loading', true);
|
// Save a reply. This may be called by a composer-reply component that was
|
||||||
controller.get('controllers.alerts').send('clearAlerts');
|
// set up on a different discussion, so we require a discussion model to
|
||||||
|
// be explicitly passed rather than using the controller's implicit one.
|
||||||
|
// @todo break this down into bite-sized functions so that extensions can
|
||||||
|
// easily override where they please.
|
||||||
|
saveReply: function(discussion, data) {
|
||||||
|
var post = this.store.createRecord('post', {
|
||||||
|
content: data.content,
|
||||||
|
discussion: discussion
|
||||||
|
});
|
||||||
|
|
||||||
var post = this.store.createRecord('post', {
|
var controller = this;
|
||||||
content: data.content,
|
var stream = this.get('stream');
|
||||||
discussion: discussion
|
return this.saveAndDismissComposer(post).then(function(post) {
|
||||||
});
|
discussion.setProperties({
|
||||||
|
lastTime: post.get('time'),
|
||||||
|
lastUser: post.get('user'),
|
||||||
|
lastPost: post,
|
||||||
|
lastPostNumber: post.get('number'),
|
||||||
|
commentsCount: discussion.get('commentsCount') + 1,
|
||||||
|
readTime: post.get('time'),
|
||||||
|
readNumber: post.get('number')
|
||||||
|
});
|
||||||
|
|
||||||
return post.save().then(function(post) {
|
// If we're currently viewing the discussion which this reply was
|
||||||
composer.send('hide');
|
// made in, then we can add the post to the end of the post
|
||||||
|
// stream.
|
||||||
discussion.setProperties({
|
if (discussion == controller.get('model') && stream) {
|
||||||
lastTime: post.get('time'),
|
stream.addPostToEnd(post);
|
||||||
lastUser: post.get('user'),
|
} else {
|
||||||
lastPost: post,
|
// Otherwise, we'll create an alert message to inform the user
|
||||||
lastPostNumber: post.get('number'),
|
// that their reply has been posted, containing a button which
|
||||||
commentsCount: discussion.get('commentsCount') + 1,
|
// will transition to their new post when clicked.
|
||||||
readTime: post.get('time'),
|
var message = AlertMessage.create({
|
||||||
readNumber: post.get('number')
|
type: 'success',
|
||||||
});
|
message: 'Your reply was posted.',
|
||||||
|
buttons: [{
|
||||||
// If we're currently viewing the discussion which this reply was
|
label: 'View',
|
||||||
// made in, then we can add the post to the end of the post
|
action: function() {
|
||||||
// stream.
|
controller.transitionToRoute('discussion', post.get('discussion'), {queryParams: {start: post.get('number')}});
|
||||||
if (discussion == controller.get('model') && stream) {
|
|
||||||
stream.addPostToEnd(post);
|
|
||||||
} else {
|
|
||||||
// Otherwise, we'll create an alert message to inform the user
|
|
||||||
// that their reply has been posted, containing a button which
|
|
||||||
// will transition to their new post when clicked.
|
|
||||||
var message = AlertMessage.create({
|
|
||||||
type: 'success',
|
|
||||||
message: 'Your reply was posted.'
|
|
||||||
});
|
|
||||||
message.on('populateControls', function(controls) {
|
|
||||||
controls.pushObjectWithTag(ActionButton.create({
|
|
||||||
label: 'View',
|
|
||||||
action: function() {
|
|
||||||
controller.transitionToRoute('discussion', post.get('discussion'), {queryParams: {start: post.get('number')}});
|
|
||||||
message.send('dismiss');
|
|
||||||
}
|
|
||||||
}), 'view');
|
|
||||||
});
|
|
||||||
controller.get('controllers.alerts').send('alert', message);
|
|
||||||
}
|
}
|
||||||
},
|
}]
|
||||||
function(reason) {
|
|
||||||
var errors = reason.errors;
|
|
||||||
for (var i in reason.errors) {
|
|
||||||
var message = AlertMessage.create({
|
|
||||||
type: 'warning',
|
|
||||||
message: reason.errors[i]
|
|
||||||
});
|
|
||||||
controller.get('controllers.alerts').send('alert', message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(function() {
|
|
||||||
composer.set('content.loading', false);
|
|
||||||
});
|
});
|
||||||
|
controller.get('alerts').send('alert', message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Whenever we transition to a different discussion or the logged-in user
|
||||||
|
// changes, we'll need the composer content to refresh next time the reply
|
||||||
|
// button is clicked.
|
||||||
|
clearComposerContent: Ember.observer('model', 'session.user', function() {
|
||||||
|
this.set('composerContent', undefined);
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
reply: function() {
|
||||||
|
var discussion = this.get('model');
|
||||||
|
var controller = this;
|
||||||
|
this.showComposer(function() {
|
||||||
|
return ComposerReply.create({
|
||||||
|
user: controller.get('session.user'),
|
||||||
|
discussion: discussion,
|
||||||
|
submit: function(data) {
|
||||||
|
controller.saveReply(discussion, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
// This action is called when the start position of the discussion
|
||||||
reply: function() {
|
// currently being viewed changes (i.e. when the user scrolls up/down
|
||||||
var controller = this;
|
// the post stream.)
|
||||||
var discussion = this.get('model');
|
positionChanged: function(startNumber, endNumber) {
|
||||||
var composer = this.get('controllers.composer');
|
this.set('start', startNumber);
|
||||||
|
|
||||||
// If the composer is already set up for this discussion, then we
|
var discussion = this.get('model');
|
||||||
// don't need to change its content - we can just show it.
|
if (endNumber > discussion.get('readNumber')) {
|
||||||
if (!(composer.get('content') instanceof ComposerReply) || composer.get('content.discussion') !== discussion) {
|
discussion.set('readNumber', endNumber);
|
||||||
composer.switchContent(ComposerReply.create({
|
discussion.save();
|
||||||
user: controller.get('session.user'),
|
}
|
||||||
discussion: discussion,
|
|
||||||
submit: function(data) {
|
|
||||||
controller.saveReply(discussion, data);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
composer.send('show');
|
|
||||||
},
|
|
||||||
|
|
||||||
// This action is called when the start position of the discussion
|
|
||||||
// currently being viewed changes (i.e. when the user scrolls up/down
|
|
||||||
// the post stream.)
|
|
||||||
updateStart: function(start) {
|
|
||||||
this.set('start', start);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
@@ -1,76 +1,59 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
import DiscussionResult from '../models/discussion-result';
|
import DiscussionResult from 'flarum/models/discussion-result';
|
||||||
import PostResult from '../models/post-result';
|
import PostResult from 'flarum/models/post-result';
|
||||||
import PaneableMixin from '../mixins/paneable';
|
import Paneable from 'flarum/mixins/paneable';
|
||||||
import ComposerDiscussion from '../components/discussions/composer-discussion';
|
import ComposerDiscussion from 'flarum/components/composer/composer-discussion';
|
||||||
import AlertMessage from '../components/alert-message';
|
import AlertMessage from 'flarum/components/ui/alert-message';
|
||||||
|
import UseComposer from 'flarum/mixins/use-composer';
|
||||||
|
|
||||||
export default Ember.Controller.extend(Ember.Evented, PaneableMixin, {
|
export default Ember.Controller.extend(UseComposer, Paneable, {
|
||||||
needs: ['application', 'composer', 'alerts', 'index/index', 'discussion'],
|
needs: ['application', 'index/index', 'discussion'],
|
||||||
|
composer: Ember.inject.controller('composer'),
|
||||||
|
alerts: Ember.inject.controller('alerts'),
|
||||||
|
|
||||||
index: Ember.computed.alias('controllers.index/index'),
|
index: Ember.computed.alias('controllers.index/index'),
|
||||||
|
|
||||||
paneDisabled: Ember.computed.not('index.model.length'),
|
paneDisabled: Ember.computed.not('index.model.length'),
|
||||||
|
|
||||||
saveDiscussion: function(data) {
|
saveDiscussion: function(data) {
|
||||||
var controller = this;
|
var discussion = this.store.createRecord('discussion', {
|
||||||
var composer = this.get('controllers.composer');
|
title: data.title,
|
||||||
var stream = this.get('stream');
|
content: data.content
|
||||||
|
});
|
||||||
|
|
||||||
composer.set('content.loading', true);
|
var controller = this;
|
||||||
controller.get('controllers.alerts').send('clearAlerts');
|
return this.saveAndDismissComposer(discussion).then(function(discussion) {
|
||||||
|
controller.get('index').set('model', null).send('refresh');
|
||||||
|
controller.transitionToRoute('discussion', discussion);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
var discussion = this.store.createRecord('discussion', {
|
actions: {
|
||||||
title: data.title,
|
transitionFromBackButton: function() {
|
||||||
content: data.content
|
this.transitionToRoute('index');
|
||||||
});
|
|
||||||
|
|
||||||
return discussion.save().then(function(discussion) {
|
|
||||||
composer.send('hide');
|
|
||||||
controller.get('index').set('model', null).send('refresh');
|
|
||||||
controller.transitionToRoute('discussion', discussion);
|
|
||||||
},
|
|
||||||
function(reason) {
|
|
||||||
var errors = reason.errors;
|
|
||||||
for (var i in reason.errors) {
|
|
||||||
var message = AlertMessage.create({
|
|
||||||
type: 'warning',
|
|
||||||
message: reason.errors[i]
|
|
||||||
});
|
|
||||||
controller.get('controllers.alerts').send('alert', message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.finally(function() {
|
|
||||||
composer.set('content.loading', false);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
loadMore: function() {
|
||||||
transitionFromBackButton: function() {
|
this.get('index').send('loadMore');
|
||||||
this.transitionToRoute('index');
|
},
|
||||||
},
|
|
||||||
|
|
||||||
loadMore: function() {
|
markAllAsRead: function() {
|
||||||
this.get('index').send('loadMore');
|
var user = this.get('session.user');
|
||||||
},
|
user.set('readTime', new Date);
|
||||||
|
user.save();
|
||||||
|
},
|
||||||
|
|
||||||
newDiscussion: function() {
|
newDiscussion: function() {
|
||||||
var controller = this;
|
var controller = this;
|
||||||
var composer = this.get('controllers.composer');
|
this.showComposer(function() {
|
||||||
|
return ComposerDiscussion.create({
|
||||||
// If the composer is already set up for starting a discussion, then we
|
user: controller.get('session.user'),
|
||||||
// don't need to change its content - we can just show it.
|
submit: function(data) {
|
||||||
if (!(composer.get('content') instanceof ComposerDiscussion)) {
|
controller.saveDiscussion(data);
|
||||||
composer.switchContent(ComposerDiscussion.create({
|
}
|
||||||
user: controller.get('session.user'),
|
});
|
||||||
submit: function(data) {
|
});
|
||||||
controller.saveDiscussion(data);
|
}
|
||||||
}
|
}
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
composer.send('show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
import DiscussionResult from '../../models/discussion-result';
|
import DiscussionResult from 'flarum/models/discussion-result';
|
||||||
import PostResult from '../../models/post-result';
|
import PostResult from 'flarum/models/post-result';
|
||||||
|
|
||||||
export default Ember.Controller.extend({
|
export default Ember.Controller.extend({
|
||||||
needs: ['application'],
|
needs: ['application'],
|
||||||
@@ -22,17 +22,15 @@ export default Ember.Controller.extend({
|
|||||||
{key: 'oldest', label: 'Oldest', sort: 'created'},
|
{key: 'oldest', label: 'Oldest', sort: 'created'},
|
||||||
],
|
],
|
||||||
|
|
||||||
terminalPostType: function() {
|
terminalPostType: Ember.computed('sort', function() {
|
||||||
return ['newest', 'oldest'].indexOf(this.get('sort')) !== -1 ? 'start' : 'last';
|
return ['newest', 'oldest'].indexOf(this.get('sort')) !== -1 ? 'start' : 'last';
|
||||||
}.property('sort'),
|
}),
|
||||||
|
|
||||||
countType: function() {
|
countType: Ember.computed('sort', function() {
|
||||||
return this.get('sort') === 'replies' ? 'replies' : 'unread';
|
return this.get('sort') === 'replies' ? 'replies' : 'unread';
|
||||||
}.property('sort'),
|
}),
|
||||||
|
|
||||||
moreResults: function() {
|
moreResults: Ember.computed.bool('meta.moreUrl'),
|
||||||
return !!this.get('meta.moreUrl');
|
|
||||||
}.property('meta.moreUrl'),
|
|
||||||
|
|
||||||
getResults: function(start) {
|
getResults: function(start) {
|
||||||
var searchQuery = this.get('searchQuery');
|
var searchQuery = this.get('searchQuery');
|
||||||
@@ -75,9 +73,12 @@ export default Ember.Controller.extend({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
searchQueryDidChange: function() {
|
searchQueryDidChange: Ember.observer('searchQuery', function() {
|
||||||
this.get('controllers.application').set('searchQuery', this.get('searchQuery'));
|
var searchQuery = this.get('searchQuery');
|
||||||
this.get('controllers.application').set('searchActive', !! this.get('searchQuery'));
|
this.get('controllers.application').setProperties({
|
||||||
|
searchQuery: searchQuery,
|
||||||
|
searchActive: !!searchQuery
|
||||||
|
});
|
||||||
|
|
||||||
var sortOptions = this.get('sortOptions');
|
var sortOptions = this.get('sortOptions');
|
||||||
|
|
||||||
@@ -86,13 +87,13 @@ export default Ember.Controller.extend({
|
|||||||
} else if (!this.get('searchQuery') && sortOptions[0].sort === 'relevance') {
|
} else if (!this.get('searchQuery') && sortOptions[0].sort === 'relevance') {
|
||||||
sortOptions.shiftObject();
|
sortOptions.shiftObject();
|
||||||
}
|
}
|
||||||
}.observes('searchQuery'),
|
}),
|
||||||
|
|
||||||
paramsDidChange: function() {
|
paramsDidChange: Ember.observer('sort', 'show', 'searchQuery', function() {
|
||||||
if (this.get('model')) {
|
if (this.get('model')) {
|
||||||
this.send('refresh');
|
this.send('refresh');
|
||||||
}
|
}
|
||||||
}.observes('sort', 'show', 'searchQuery'),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
loadMore: function() {
|
loadMore: function() {
|
||||||
|
@@ -1,36 +1,34 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
import AuthenticationControllerMixin from 'simple-auth/mixins/authentication-controller-mixin';
|
import AuthenticationControllerMixin from 'simple-auth/mixins/authentication-controller-mixin';
|
||||||
import ModalControllerMixin from '../mixins/modal-controller';
|
import ModalController from 'flarum/mixins/modal-controller';
|
||||||
|
|
||||||
export default Ember.Controller.extend(ModalControllerMixin, AuthenticationControllerMixin, {
|
|
||||||
|
|
||||||
|
export default Ember.Controller.extend(ModalController, AuthenticationControllerMixin, {
|
||||||
authenticator: 'authenticator:flarum',
|
authenticator: 'authenticator:flarum',
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
authenticate: function() {
|
authenticate: function() {
|
||||||
var data = this.getProperties('identification', 'password');
|
var data = this.getProperties('identification', 'password');
|
||||||
var controller = this;
|
var controller = this;
|
||||||
this.set('error', null);
|
this.set('error', null);
|
||||||
this.set('loading', true);
|
this.set('loading', true);
|
||||||
return this._super(data).then(function() {
|
return this._super(data).then(function() {
|
||||||
controller.send("sessionChanged");
|
controller.send("sessionChanged");
|
||||||
controller.send("closeModal");
|
controller.send("closeModal");
|
||||||
}, function(errors) {
|
}, function(errors) {
|
||||||
switch(errors[0].code) {
|
switch(errors[0].code) {
|
||||||
case 'invalidLogin':
|
case 'invalidLogin':
|
||||||
controller.set('error', 'Your login details are incorrect.');
|
controller.set('error', 'Your login details are incorrect.');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
controller.set('error', 'Something went wrong. (Error code: '+errors[0].code+')');
|
controller.set('error', 'Something went wrong. (Error code: '+errors[0].code+')');
|
||||||
}
|
}
|
||||||
controller.trigger('refocus');
|
controller.trigger('refocus');
|
||||||
}).finally(function() {
|
}).finally(function() {
|
||||||
controller.set('loading', false);
|
controller.set('loading', false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -1,33 +1,29 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
import AuthenticationControllerMixin from 'simple-auth/mixins/authentication-controller-mixin';
|
import ModalController from 'flarum/mixins/modal-controller';
|
||||||
import ModalControllerMixin from '../mixins/modal-controller';
|
|
||||||
|
|
||||||
export default Ember.Controller.extend(ModalControllerMixin, AuthenticationControllerMixin, {
|
export default Ember.Controller.extend(ModalController, {
|
||||||
authenticator: 'authenticator:flarum',
|
actions: {
|
||||||
|
submit: function() {
|
||||||
|
var data = this.getProperties('username', 'email', 'password');
|
||||||
|
var controller = this;
|
||||||
|
this.set('error', null);
|
||||||
|
this.set('loading', true);
|
||||||
|
|
||||||
actions: {
|
var user = this.store.createRecord('user', data);
|
||||||
submit: function() {
|
|
||||||
var data = this.getProperties('username', 'email', 'password');
|
|
||||||
var controller = this;
|
|
||||||
this.set('error', null);
|
|
||||||
this.set('loading', true);
|
|
||||||
|
|
||||||
var user = this.store.createRecord('user', data);
|
return user.save().then(function() {
|
||||||
|
controller.get('session').authenticate('authenticator:flarum', {
|
||||||
return user.save().then(function() {
|
identification: data.email,
|
||||||
controller.get('session').authenticate('authenticator:flarum', {
|
password: data.password
|
||||||
identification: data.email,
|
}).then(function() {
|
||||||
password: data.password
|
controller.send('closeModal');
|
||||||
}).then(function() {
|
controller.send('sessionChanged');
|
||||||
controller.send('closeModal');
|
controller.set('loading', false);
|
||||||
controller.send('sessionChanged');
|
});
|
||||||
controller.set('loading', false);
|
}, function(reason) {
|
||||||
});
|
controller.set('loading', false);
|
||||||
}, function(reason) {
|
});
|
||||||
controller.set('loading', false);
|
}
|
||||||
});
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
export default Ember.Handlebars.makeBoundHelper(function(number) {
|
export default Ember.Handlebars.makeBoundHelper(function(number) {
|
||||||
return new Ember.Handlebars.SafeString(''+number);
|
return new Ember.Handlebars.SafeString(''+number);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
export default Ember.Handlebars.makeBoundHelper(function(icon, options) {
|
export default Ember.Handlebars.makeBoundHelper(function(icon, options) {
|
||||||
return new Ember.Handlebars.SafeString('<i class="fa fa-fw fa-'+icon+' '+(options.hash.class || '')+'"></i>');
|
return new Ember.Handlebars.SafeString('<i class="fa fa-fw fa-'+icon+' '+(options.hash.class || '')+'"></i>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
9
ember/app/helpers/full-time.js
Normal file
9
ember/app/helpers/full-time.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
export default Ember.Handlebars.makeBoundHelper(function(time) {
|
||||||
|
var m = moment(time);
|
||||||
|
var datetime = m.format();
|
||||||
|
var full = m.format('LLLL');
|
||||||
|
|
||||||
|
return new Ember.Handlebars.SafeString('<time pubdate datetime="'+datetime+'">'+full+'</time>');
|
||||||
|
});
|
@@ -1,18 +1,17 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
export default Ember.Handlebars.makeBoundHelper(function(text, phrase) {
|
export default Ember.Handlebars.makeBoundHelper(function(text, phrase) {
|
||||||
if (phrase) {
|
if (phrase) {
|
||||||
var words = phrase.split(' ');
|
var words = phrase.split(' ');
|
||||||
var replacement = function(matched) {
|
var replacement = function(matched) {
|
||||||
return '<span class="highlight-keyword">'+matched+'</span>';
|
return '<span class="highlight-keyword">'+matched+'</span>';
|
||||||
};
|
};
|
||||||
words.forEach(function(word) {
|
words.forEach(function(word) {
|
||||||
text = text.replace(
|
text = text.replace(
|
||||||
new RegExp("\\b"+word+"\\b", 'gi'),
|
new RegExp("\\b"+word+"\\b", 'gi'),
|
||||||
replacement
|
replacement
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new Ember.Handlebars.SafeString(text);
|
return new Ember.Handlebars.SafeString(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -3,12 +3,11 @@ import Ember from 'ember';
|
|||||||
import humanTime from '../utils/human-time';
|
import humanTime from '../utils/human-time';
|
||||||
|
|
||||||
export default Ember.Handlebars.makeBoundHelper(function(time) {
|
export default Ember.Handlebars.makeBoundHelper(function(time) {
|
||||||
var m = moment(time);
|
var m = moment(time);
|
||||||
var datetime = m.format();
|
var datetime = m.format();
|
||||||
var full = m.format('LLLL');
|
var full = m.format('LLLL');
|
||||||
|
|
||||||
var ago = humanTime(m);
|
var ago = humanTime(m);
|
||||||
|
|
||||||
return new Ember.Handlebars.SafeString('<time pubdate datetime="'+datetime+'" title="'+full+'" data-humantime>'+ago+'</time>');
|
return new Ember.Handlebars.SafeString('<time pubdate datetime="'+datetime+'" title="'+full+'" data-humantime>'+ago+'</time>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -30,12 +30,12 @@ export default Ember.Handlebars.makeBoundHelper(function(user, options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var number;
|
// var number;
|
||||||
if (number = user.get('avatarNumber')) {
|
// if (number = user.get('avatarNumber')) {
|
||||||
number = number + '';
|
// number = number + '';
|
||||||
var filename = number.length >= 3 ? number : new Array(3 - number.length + 1).join('0') + number;
|
// var filename = number.length >= 3 ? number : new Array(3 - number.length + 1).join('0') + number;
|
||||||
return new Ember.Handlebars.SafeString('<img src="/packages/flarum/core/avatars/'+filename+'.jpg" class="avatar '+options.hash.class+'">');
|
// return new Ember.Handlebars.SafeString('<img src="/packages/flarum/core/avatars/'+filename+'.jpg" class="avatar '+options.hash.class+'">');
|
||||||
}
|
// }
|
||||||
|
|
||||||
var username = user.get('username');
|
var username = user.get('username');
|
||||||
if (!username) {
|
if (!username) {
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import FlarumAuthorizer from '../authorizers/flarum';
|
import FlarumAuthorizer from 'flarum/authorizers/flarum';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'authentication',
|
name: 'authentication',
|
||||||
@@ -6,4 +6,4 @@ export default {
|
|||||||
initialize: function(container) {
|
initialize: function(container) {
|
||||||
container.register('authorizer:flarum', FlarumAuthorizer);
|
container.register('authorizer:flarum', FlarumAuthorizer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
import humanTime from '../utils/human-time';
|
import humanTime from 'flarum/utils/human-time';
|
||||||
|
|
||||||
var $ = Ember.$;
|
var $ = Ember.$;
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export default {
|
|||||||
.removeData('livestamp');
|
.removeData('livestamp');
|
||||||
|
|
||||||
timestamp = moment(timestamp);
|
timestamp = moment(timestamp);
|
||||||
if (timestamp.diff(moment()) < 60 * 60) {
|
if (timestamp.diff(moment(new Date())) < 60 * 60) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
|
if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
|
||||||
@@ -144,4 +144,4 @@ export default {
|
|||||||
})(jQuery, moment);
|
})(jQuery, moment);
|
||||||
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
export default {
|
export default {
|
||||||
name: 'inject-components',
|
name: 'inject-components',
|
||||||
initialize: function(container, application) {
|
initialize: function(container, application) {
|
||||||
|
application.inject('adapter', 'alerts', 'controller:alerts')
|
||||||
application.inject('component', 'alerts', 'controller:alerts')
|
application.inject('component', 'alerts', 'controller:alerts')
|
||||||
application.inject('component', 'composer', 'controller:composer')
|
application.inject('component', 'composer', 'controller:composer')
|
||||||
|
application.inject('model', 'session', 'simple-auth-session:main')
|
||||||
|
application.inject('component', 'session', 'simple-auth-session:main')
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
14
ember/app/mixins/fade-in.js
Normal file
14
ember/app/mixins/fade-in.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
export default Ember.Mixin.create({
|
||||||
|
fadeIn: Ember.on('didInsertElement', function() {
|
||||||
|
var $this = this.$();
|
||||||
|
var targetOpacity = $this.css('opacity');
|
||||||
|
$this.css('opacity', 0);
|
||||||
|
setTimeout(function() {
|
||||||
|
$this.animate({opacity: targetOpacity}, 'fast', function() {
|
||||||
|
$this.css('opacity', '');
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
})
|
||||||
|
});
|
45
ember/app/mixins/has-item-lists.js
Normal file
45
ember/app/mixins/has-item-lists.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
import TaggedArray from 'flarum/utils/tagged-array';
|
||||||
|
import ActionButton from 'flarum/components/ui/action-button';
|
||||||
|
|
||||||
|
export default Ember.Mixin.create({
|
||||||
|
itemLists: [],
|
||||||
|
|
||||||
|
initItemLists: Ember.on('didInsertElement', function() {
|
||||||
|
var self = this;
|
||||||
|
this.get('itemLists').forEach(function(name) {
|
||||||
|
self.initItemList(name);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
initItemList: function(name) {
|
||||||
|
this.set(name, this.populateItemList(name));
|
||||||
|
},
|
||||||
|
|
||||||
|
populateItemList: function(name) {
|
||||||
|
var items = TaggedArray.create();
|
||||||
|
this.trigger('populate'+name.charAt(0).toUpperCase()+name.slice(1), items);
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
addActionItem: function(items, tag, label, icon, conditionProperty, actionName, actionTarget) {
|
||||||
|
if (conditionProperty && !this.get(conditionProperty)) { return; }
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
actionTarget = actionTarget || self.get('controller');
|
||||||
|
var item = ActionButton.extend({
|
||||||
|
label: label,
|
||||||
|
icon: icon,
|
||||||
|
action: function() {
|
||||||
|
actionTarget.send(actionName || tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var itemInstance = item.create();
|
||||||
|
|
||||||
|
items.pushObjectWithTag(itemInstance, tag);
|
||||||
|
|
||||||
|
return itemInstance;
|
||||||
|
}
|
||||||
|
});
|
@@ -1,9 +1,9 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
export default Ember.Mixin.create(Ember.Evented, {
|
export default Ember.Mixin.create(Ember.Evented, {
|
||||||
actions: {
|
actions: {
|
||||||
focus: function() {
|
focus: function() {
|
||||||
this.trigger('focus');
|
this.trigger('focus');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -1,16 +1,15 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
export default Ember.Mixin.create({
|
export default Ember.Mixin.create({
|
||||||
focusEventOn: function() {
|
focusEventOn: Ember.on('didInsertElement', function() {
|
||||||
this.get('controller').on('focus', this, this.focus);
|
this.get('controller').on('focus', this, this.focus);
|
||||||
}.on('didInsertElement'),
|
}),
|
||||||
|
|
||||||
focusEventOff: function() {
|
focusEventOff: Ember.on('willDestroyElement', function() {
|
||||||
this.get('controller').off('focus', this, this.focus);
|
this.get('controller').off('focus', this, this.focus);
|
||||||
}.on('willDestroyElement'),
|
}),
|
||||||
|
|
||||||
focus: function() {
|
focus: Ember.on('didInsertElement', function() {
|
||||||
this.$('input:first:visible:enabled').focus();
|
this.$('input:first:visible:enabled').focus();
|
||||||
console.log('focus first')
|
})
|
||||||
}.on('didInsertElement')
|
|
||||||
});
|
});
|
||||||
|
@@ -1,59 +1,57 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
// This mixin defines a "paneable" controller - this is, one that has a
|
/**
|
||||||
// portion of its interface that can be turned into a pane which slides out
|
This mixin defines a "paneable" controller - this is, one that has a portion
|
||||||
// from the side of the screen. This is useful, for instance, when you have
|
of its interface that can be turned into a pane which slides out from the
|
||||||
// nested routes (index > discussion) and want to have the parent
|
side of the screen. This is useful, for instance, when you have nested
|
||||||
// route's interface transform into a side pane when entering the child route.
|
routes (index > discussion) and want to have the parent route's interface
|
||||||
|
transform into a side pane when entering the child route.
|
||||||
|
*/
|
||||||
export default Ember.Mixin.create({
|
export default Ember.Mixin.create({
|
||||||
needs: ['application'],
|
needs: ['application'],
|
||||||
|
|
||||||
// Whether or not the "paneable" interface element is paned.
|
// Whether or not the "paneable" interface element is paned.
|
||||||
paned: false,
|
paned: false,
|
||||||
|
|
||||||
// Whether or not the pane should be visible on screen.
|
// Whether or not the pane should be visible on screen.
|
||||||
paneShowing: false,
|
paneShowing: false,
|
||||||
paneHideTimeout: null,
|
paneHideTimeout: null,
|
||||||
|
|
||||||
// Whether or not the pane is always visible on screen, even when the
|
// Whether or not the pane is always visible on screen, even when the
|
||||||
// mouse is taken away.
|
// mouse is taken away.
|
||||||
panePinned: localStorage.getItem('panePinned'),
|
panePinned: localStorage.getItem('panePinned'),
|
||||||
|
|
||||||
// Disable the paneable behaviour completely, regardless of if it is
|
// Disable the paneable behaviour completely, regardless of if it is
|
||||||
// paned, showing, or pinned.
|
// paned, showing, or pinned.
|
||||||
paneDisabled: false,
|
paneDisabled: false,
|
||||||
|
|
||||||
paneIsShowing: function() {
|
paneEnabled: Ember.computed.not('paneDisabled'),
|
||||||
return this.get('paned') && this.get('paneShowing') && !this.get('paneDisabled');
|
paneIsShowing: Ember.computed.and('paned', 'paneShowing', 'paneEnabled'),
|
||||||
}.property('paned', 'paneShowing', 'paneDisabled'),
|
paneIsPinned: Ember.computed.and('paned', 'panePinned', 'paneEnabled'),
|
||||||
|
|
||||||
paneIsPinned: function() {
|
// Tell the application controller when we pin/unpin the pane so that
|
||||||
return this.get('paned') && this.get('panePinned') && !this.get('paneDisabled');
|
// other parts of the interface can respond appropriately.
|
||||||
}.property('paned', 'panePinned', 'paneDisabled'),
|
paneIsPinnedChanged: Ember.observer('paneIsPinned', function() {
|
||||||
|
this.set('controllers.application.panePinned', this.get('paneIsPinned'));
|
||||||
|
}),
|
||||||
|
|
||||||
// Tell the application controller when we pin/unpin the pane so that
|
actions: {
|
||||||
// other parts of the interface can respond appropriately.
|
showPane: function() {
|
||||||
paneIsPinnedChanged: function() {
|
if (this.get('paned')) {
|
||||||
this.set('controllers.application.panePinned', this.get('paneIsPinned'));
|
clearTimeout(this.get('paneHideTimeout'));
|
||||||
}.observes('paneIsPinned'),
|
this.set('paneShowing', true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
actions: {
|
hidePane: function(delay) {
|
||||||
showPane: function() {
|
var controller = this;
|
||||||
if (this.get('paned')) {
|
controller.set('paneHideTimeout', setTimeout(function() {
|
||||||
clearTimeout(this.get('paneHideTimeout'));
|
controller.set('paneShowing', false);
|
||||||
this.set('paneShowing', true);
|
}, delay || 250));
|
||||||
}
|
},
|
||||||
},
|
|
||||||
|
|
||||||
hidePane: function(delay) {
|
togglePinned: function() {
|
||||||
var controller = this;
|
localStorage.setItem('panePinned', this.toggleProperty('panePinned') || '');
|
||||||
controller.set('paneHideTimeout', setTimeout(function() {
|
}
|
||||||
controller.set('paneShowing', false);
|
}
|
||||||
}, delay || 250));
|
|
||||||
},
|
|
||||||
|
|
||||||
togglePinned: function() {
|
|
||||||
localStorage.setItem('panePinned', this.toggleProperty('panePinned') || '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
37
ember/app/mixins/use-composer.js
Normal file
37
ember/app/mixins/use-composer.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Ember from 'ember';
|
||||||
|
|
||||||
|
export default Ember.Mixin.create({
|
||||||
|
showComposer: function(buildComposerContent) {
|
||||||
|
var composer = this.get('composer');
|
||||||
|
if (this.get('composerContent') !== composer.get('content')) {
|
||||||
|
this.set('composerContent', buildComposerContent());
|
||||||
|
composer.switchContent(this.get('composerContent'));
|
||||||
|
}
|
||||||
|
composer.send('show');
|
||||||
|
},
|
||||||
|
|
||||||
|
saveAndDismissComposer: function(model) {
|
||||||
|
var composer = this.get('composer');
|
||||||
|
composer.set('content.loading', true);
|
||||||
|
this.get('alerts').send('clearAlerts');
|
||||||
|
|
||||||
|
return model.save().then(function(model) {
|
||||||
|
composer.send('hide');
|
||||||
|
return model;
|
||||||
|
}, function(reason) {
|
||||||
|
controller.showErrorsAsAlertMessages(reason.errors);
|
||||||
|
}).finally(function() {
|
||||||
|
composer.set('content.loading', false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showErrorsAsAlertMessages: function(errors) {
|
||||||
|
for (var i in errors) {
|
||||||
|
var message = AlertMessage.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: errors[i]
|
||||||
|
});
|
||||||
|
this.get('alerts').send('alert', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
@@ -1,12 +1,7 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
var DiscussionResult = Ember.ObjectProxy.extend({
|
export default Ember.ObjectProxy.extend({
|
||||||
|
relevantPosts: null,
|
||||||
relevantPosts: null,
|
startPost: null,
|
||||||
|
lastPost: null
|
||||||
startPost: null,
|
|
||||||
lastPost: null
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default DiscussionResult;
|
|
||||||
|
@@ -1,48 +1,49 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
import DS from 'ember-data';
|
import DS from 'ember-data';
|
||||||
|
|
||||||
var Discussion = DS.Model.extend({
|
export default DS.Model.extend({
|
||||||
|
title: DS.attr('string'),
|
||||||
|
slug: Ember.computed('title', function() {
|
||||||
|
return this.get('title').toLowerCase().replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-');
|
||||||
|
}),
|
||||||
|
|
||||||
title: DS.attr('string'),
|
startTime: DS.attr('date'),
|
||||||
content: DS.attr('string'), // only used to save a new discussion
|
startUser: DS.belongsTo('user'),
|
||||||
|
startPost: DS.belongsTo('post'),
|
||||||
|
|
||||||
slug: function() {
|
lastTime: DS.attr('date'),
|
||||||
return this.get('title').toLowerCase().replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-');
|
lastUser: DS.belongsTo('user'),
|
||||||
}.property('title'),
|
lastPost: DS.belongsTo('post'),
|
||||||
|
lastPostNumber: DS.attr('number'),
|
||||||
canReply: DS.attr('boolean'),
|
|
||||||
canEdit: DS.attr('boolean'),
|
|
||||||
canDelete: DS.attr('boolean'),
|
|
||||||
|
|
||||||
startTime: DS.attr('date'),
|
canReply: DS.attr('boolean'),
|
||||||
startUser: DS.belongsTo('user'),
|
canEdit: DS.attr('boolean'),
|
||||||
startPost: DS.belongsTo('post'),
|
canDelete: DS.attr('boolean'),
|
||||||
|
|
||||||
lastTime: DS.attr('date'),
|
commentsCount: DS.attr('number'),
|
||||||
lastUser: DS.belongsTo('user'),
|
repliesCount: Ember.computed('commentsCount', function() {
|
||||||
lastPost: DS.belongsTo('post'),
|
return Math.max(0, this.get('commentsCount') - 1);
|
||||||
lastPostNumber: DS.attr('number'),
|
}),
|
||||||
|
|
||||||
relevantPosts: DS.hasMany('post'),
|
// The API returns the `posts` relationship as a list of IDs. To hydrate a
|
||||||
|
// post-stream object, we're only interested in obtaining a list of IDs, so
|
||||||
|
// we make it a string and then split it by comma. Instead, we'll put a
|
||||||
|
// relationship on `loadedPosts`.
|
||||||
|
posts: DS.attr('string'),
|
||||||
|
postIds: Ember.computed('posts', function() {
|
||||||
|
var posts = this.get('posts') || '';
|
||||||
|
return posts.split(',');
|
||||||
|
}),
|
||||||
|
loadedPosts: DS.hasMany('post'),
|
||||||
|
relevantPosts: DS.hasMany('post'),
|
||||||
|
|
||||||
commentsCount: DS.attr('number'),
|
readTime: DS.attr('date'),
|
||||||
repliesCount: function() {
|
readNumber: DS.attr('number'),
|
||||||
return Math.max(0, this.get('commentsCount') - 1);
|
unreadCount: Ember.computed('lastPostNumber', 'readNumber', 'session.user.readTime', function() {
|
||||||
}.property('commentsCount'),
|
return this.get('session.user.readTime') < this.get('lastTime') ? Math.max(0, this.get('lastPostNumber') - (this.get('readNumber') || 0)) : 0;
|
||||||
|
}),
|
||||||
|
isUnread: Ember.computed.bool('unreadCount'),
|
||||||
|
|
||||||
posts: DS.attr('string'),
|
// Only used to save a new discussion
|
||||||
postIds: function() {
|
content: DS.attr('string')
|
||||||
var posts = this.get('posts') || '';
|
|
||||||
return posts.split(',');
|
|
||||||
}.property('posts'),
|
|
||||||
loadedPosts: DS.hasMany('post'),
|
|
||||||
|
|
||||||
readTime: DS.attr('date'),
|
|
||||||
readNumber: DS.attr('number'),
|
|
||||||
unreadCount: function() {
|
|
||||||
return this.get('lastPostNumber') - this.get('readNumber');
|
|
||||||
}.property('lastPostNumber', 'readNumber'),
|
|
||||||
isUnread: Ember.computed.bool('unreadCount')
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Discussion;
|
|
||||||
|
@@ -1,9 +1,6 @@
|
|||||||
import DS from 'ember-data';
|
import DS from 'ember-data';
|
||||||
|
|
||||||
export default DS.Model.extend({
|
export default DS.Model.extend({
|
||||||
|
name: DS.attr('string'),
|
||||||
name: DS.attr('string'),
|
users: DS.hasMany('group'),
|
||||||
|
|
||||||
users: DS.hasMany('group'),
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -1,202 +1,203 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
// The post stream is an object which represents the posts in a discussion as
|
/**
|
||||||
// they are displayed on the discussion page, from top to bottom. ...
|
The post stream is an object which represents the posts in a discussion as
|
||||||
|
they are displayed on the discussion page, from top to bottom. ...
|
||||||
|
*/
|
||||||
export default Ember.ArrayProxy.extend(Ember.Evented, {
|
export default Ember.ArrayProxy.extend(Ember.Evented, {
|
||||||
|
|
||||||
// An array of all of the post IDs, in chronological order, in the discussion.
|
// An array of all of the post IDs, in chronological order, in the discussion.
|
||||||
ids: null,
|
ids: null,
|
||||||
|
|
||||||
content: null,
|
content: null,
|
||||||
|
|
||||||
store: null,
|
store: null,
|
||||||
discussion: null,
|
discussion: null,
|
||||||
|
|
||||||
postLoadCount: 20,
|
postLoadCount: 20,
|
||||||
|
|
||||||
count: Ember.computed.alias('ids.length'),
|
count: Ember.computed.alias('ids.length'),
|
||||||
|
|
||||||
loadedCount: function() {
|
loadedCount: Ember.computed('content.@each', function() {
|
||||||
return this.get('content').filterBy('content').length;
|
return this.get('content').filterBy('content').length;
|
||||||
}.property('content.@each'),
|
}),
|
||||||
|
|
||||||
firstLoaded: function() {
|
firstLoaded: Ember.computed('content.@each', function() {
|
||||||
var first = this.objectAt(0);
|
var first = this.objectAt(0);
|
||||||
return first && first.content;
|
return first && first.content;
|
||||||
}.property('content.@each'),
|
}),
|
||||||
|
|
||||||
lastLoaded: function() {
|
lastLoaded: Ember.computed('content.@each', function() {
|
||||||
var last = this.objectAt(this.get('length') - 1);
|
var last = this.objectAt(this.get('length') - 1);
|
||||||
return last && last.content;
|
return last && last.content;
|
||||||
}.property('content.@each'),
|
}),
|
||||||
|
|
||||||
init: function() {
|
init: function() {
|
||||||
this._super();
|
this._super();
|
||||||
this.set('ids', Ember.A());
|
this.set('ids', Ember.A());
|
||||||
this.clear();
|
this.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
setup: function(ids) {
|
setup: function(ids) {
|
||||||
// Set our ids to the array provided and reset the content of the
|
// Set our ids to the array provided and reset the content of the
|
||||||
// stream to a big gap that covers the amount of posts we now have.
|
// stream to a big gap that covers the amount of posts we now have.
|
||||||
this.set('ids', ids);
|
this.set('ids', ids);
|
||||||
this.clear();
|
this.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clear the contents of the post stream, resetting it to one big gap.
|
// Clear the contents of the post stream, resetting it to one big gap.
|
||||||
clear: function() {
|
clear: function() {
|
||||||
var content = Ember.A();
|
var content = Ember.A();
|
||||||
content.clear().pushObject(this.makeItem(0, this.get('count') - 1).set('loading', true));
|
content.clear().pushObject(this.makeItem(0, this.get('count') - 1).set('loading', true));
|
||||||
this.set('content', content);
|
this.set('content', content);
|
||||||
},
|
},
|
||||||
|
|
||||||
loadRange: function(start, end, backwards) {
|
loadRange: function(start, end, backwards) {
|
||||||
var limit = this.get('postLoadCount');
|
var limit = this.get('postLoadCount');
|
||||||
|
|
||||||
// Find the appropriate gap objects in the post stream. When we find
|
// Find the appropriate gap objects in the post stream. When we find
|
||||||
// one, we will turn on its loading flag.
|
// one, we will turn on its loading flag.
|
||||||
this.get('content').forEach(function(item) {
|
this.get('content').forEach(function(item) {
|
||||||
if (! item.content && ((item.indexStart >= start && item.indexStart <= end) || (item.indexEnd >= start && item.indexEnd <= end))) {
|
if (!item.content && ((item.indexStart >= start && item.indexStart <= end) || (item.indexEnd >= start && item.indexEnd <= end))) {
|
||||||
item.set('loading', true);
|
item.set('loading', true);
|
||||||
item.set('direction', backwards ? 'up' : 'down');
|
item.set('direction', backwards ? 'up' : 'down');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get a list of post numbers that we'll want to retrieve. If there are
|
// 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
|
// more post IDs than the number of posts we want to load, then take a
|
||||||
// slice of the array in the appropriate direction.
|
// slice of the array in the appropriate direction.
|
||||||
var ids = this.get('ids').slice(start, end + 1);
|
var ids = this.get('ids').slice(start, end + 1);
|
||||||
ids = backwards ? ids.slice(-limit) : ids.slice(0, limit);
|
ids = backwards ? ids.slice(-limit) : ids.slice(0, limit);
|
||||||
|
|
||||||
return this.loadPosts(ids);
|
return this.loadPosts(ids);
|
||||||
},
|
},
|
||||||
|
|
||||||
loadPosts: function(ids) {
|
loadPosts: function(ids) {
|
||||||
if (! ids.length) {
|
if (!ids.length) {
|
||||||
return Ember.RSVP.resolve();
|
return Ember.RSVP.resolve();
|
||||||
}
|
|
||||||
|
|
||||||
var stream = this;
|
|
||||||
return this.store.find('post', {ids: ids}).then(function(posts) {
|
|
||||||
stream.addPosts(posts);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
loadNearNumber: function(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.get('content.number') == number) {
|
|
||||||
return Ember.RSVP.resolve([item.get('content')]);
|
|
||||||
} else if (! item.content) {
|
|
||||||
item.set('direction', 'down').set('loading', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var stream = this;
|
|
||||||
return this.store.find('post', {
|
|
||||||
discussions: this.get('discussion.id'),
|
|
||||||
near: number,
|
|
||||||
count: this.get('postLoadCount')
|
|
||||||
}).then(function(posts) {
|
|
||||||
stream.addPosts(posts);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
loadNearIndex: function(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.content) {
|
|
||||||
return Ember.RSVP.resolve([item.get('content')]);
|
|
||||||
}
|
|
||||||
return this.loadRange(Math.max(item.indexStart, index - this.get('postLoadCount') / 2), item.indexEnd, backwards);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ember.RSVP.reject();
|
|
||||||
},
|
|
||||||
|
|
||||||
addPosts: function(posts) {
|
|
||||||
this.trigger('postsLoaded', posts);
|
|
||||||
|
|
||||||
var stream = this;
|
|
||||||
var content = this.get('content');
|
|
||||||
content.beginPropertyChanges();
|
|
||||||
posts.forEach(function(post) {
|
|
||||||
stream.addPost(post);
|
|
||||||
});
|
|
||||||
content.endPropertyChanges();
|
|
||||||
|
|
||||||
this.trigger('postsAdded');
|
|
||||||
},
|
|
||||||
|
|
||||||
addPost: function(post) {
|
|
||||||
var index = this.get('ids').indexOf(post.get('id'));
|
|
||||||
var content = this.get('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.indexStart <= index && item.indexEnd >= index) {
|
|
||||||
var newItems = [];
|
|
||||||
if (item.indexStart < index) {
|
|
||||||
newItems.push(makeItem(item.indexStart, index - 1));
|
|
||||||
}
|
|
||||||
newItems.push(makeItem(index, index, post));
|
|
||||||
if (item.indexEnd > index) {
|
|
||||||
newItems.push(makeItem(index + 1, item.indexEnd));
|
|
||||||
}
|
|
||||||
content.replace(i, 1, newItems);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
addPostToEnd: function(post) {
|
|
||||||
this.get('ids').pushObject(post.get('id'));
|
|
||||||
var index = this.get('count') - 1;
|
|
||||||
this.get('content').pushObject(this.makeItem(index, index, post));
|
|
||||||
},
|
|
||||||
|
|
||||||
makeItem: function(indexStart, indexEnd, post) {
|
|
||||||
var item = Ember.Object.create({
|
|
||||||
indexStart: indexStart,
|
|
||||||
indexEnd: indexEnd
|
|
||||||
});
|
|
||||||
if (post) {
|
|
||||||
item.setProperties({
|
|
||||||
content: post,
|
|
||||||
component: 'discussions/post-'+post.get('type')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
},
|
|
||||||
|
|
||||||
findNearestTo: function(index, property) {
|
|
||||||
var nearestItem;
|
|
||||||
this.get('content').some(function(item) {
|
|
||||||
if (item.get(property) > index) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
nearestItem = item;
|
|
||||||
});
|
|
||||||
return nearestItem;
|
|
||||||
},
|
|
||||||
|
|
||||||
findNearestToNumber: function(number) {
|
|
||||||
return this.findNearestTo(number, 'content.number');
|
|
||||||
},
|
|
||||||
|
|
||||||
findNearestToIndex: function(index) {
|
|
||||||
return this.findNearestTo(index, 'indexStart');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var stream = this;
|
||||||
|
return this.store.find('post', {ids: ids}).then(function(posts) {
|
||||||
|
stream.addPosts(posts);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadNearNumber: function(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.get('content.number') == number) {
|
||||||
|
return Ember.RSVP.resolve([item.get('content')]);
|
||||||
|
} else if (! item.content) {
|
||||||
|
item.set('direction', 'down').set('loading', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = this;
|
||||||
|
return this.store.find('post', {
|
||||||
|
discussions: this.get('discussion.id'),
|
||||||
|
near: number,
|
||||||
|
count: this.get('postLoadCount')
|
||||||
|
}).then(function(posts) {
|
||||||
|
stream.addPosts(posts);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
loadNearIndex: function(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.content) {
|
||||||
|
return Ember.RSVP.resolve([item.get('content')]);
|
||||||
|
}
|
||||||
|
return this.loadRange(Math.max(item.indexStart, index - this.get('postLoadCount') / 2), item.indexEnd, backwards);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ember.RSVP.reject();
|
||||||
|
},
|
||||||
|
|
||||||
|
addPosts: function(posts) {
|
||||||
|
this.trigger('postsLoaded', posts);
|
||||||
|
|
||||||
|
var stream = this;
|
||||||
|
var content = this.get('content');
|
||||||
|
content.beginPropertyChanges();
|
||||||
|
posts.forEach(function(post) {
|
||||||
|
stream.addPost(post);
|
||||||
|
});
|
||||||
|
content.endPropertyChanges();
|
||||||
|
|
||||||
|
this.trigger('postsAdded');
|
||||||
|
},
|
||||||
|
|
||||||
|
addPost: function(post) {
|
||||||
|
var index = this.get('ids').indexOf(post.get('id'));
|
||||||
|
var content = this.get('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.indexStart <= index && item.indexEnd >= index) {
|
||||||
|
var newItems = [];
|
||||||
|
if (item.indexStart < index) {
|
||||||
|
newItems.push(makeItem(item.indexStart, index - 1));
|
||||||
|
}
|
||||||
|
newItems.push(makeItem(index, index, post));
|
||||||
|
if (item.indexEnd > index) {
|
||||||
|
newItems.push(makeItem(index + 1, item.indexEnd));
|
||||||
|
}
|
||||||
|
content.replace(i, 1, newItems);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addPostToEnd: function(post) {
|
||||||
|
this.get('ids').pushObject(post.get('id'));
|
||||||
|
var index = this.get('count') - 1;
|
||||||
|
this.get('content').pushObject(this.makeItem(index, index, post));
|
||||||
|
},
|
||||||
|
|
||||||
|
makeItem: function(indexStart, indexEnd, post) {
|
||||||
|
var item = Ember.Object.create({
|
||||||
|
indexStart: indexStart,
|
||||||
|
indexEnd: indexEnd
|
||||||
|
});
|
||||||
|
if (post) {
|
||||||
|
item.setProperties({
|
||||||
|
content: post,
|
||||||
|
component: 'discussion/post-'+post.get('type')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
|
||||||
|
findNearestTo: function(index, property) {
|
||||||
|
var nearestItem;
|
||||||
|
this.get('content').some(function(item) {
|
||||||
|
if (item.get(property) > index) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
nearestItem = item;
|
||||||
|
});
|
||||||
|
return nearestItem;
|
||||||
|
},
|
||||||
|
|
||||||
|
findNearestToNumber: function(number) {
|
||||||
|
return this.findNearestTo(number, 'content.number');
|
||||||
|
},
|
||||||
|
|
||||||
|
findNearestToIndex: function(index) {
|
||||||
|
return this.findNearestTo(index, 'indexStart');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
@@ -2,25 +2,23 @@ import Ember from 'ember';
|
|||||||
import DS from 'ember-data';
|
import DS from 'ember-data';
|
||||||
|
|
||||||
export default DS.Model.extend({
|
export default DS.Model.extend({
|
||||||
|
discussion: DS.belongsTo('discussion', {inverse: 'loadedPosts'}),
|
||||||
|
number: DS.attr('number'),
|
||||||
|
|
||||||
discussion: DS.belongsTo('discussion', {inverse: 'loadedPosts'}),
|
time: DS.attr('date'),
|
||||||
number: DS.attr('number'),
|
user: DS.belongsTo('user'),
|
||||||
|
type: DS.attr('string'),
|
||||||
|
content: DS.attr('string'),
|
||||||
|
contentHtml: DS.attr('string'),
|
||||||
|
|
||||||
time: DS.attr('date'),
|
editTime: DS.attr('date'),
|
||||||
user: DS.belongsTo('user'),
|
editUser: DS.belongsTo('user'),
|
||||||
type: DS.attr('string'),
|
isEdited: Ember.computed.notEmpty('editTime'),
|
||||||
content: DS.attr('string'),
|
|
||||||
contentHtml: DS.attr('string'),
|
|
||||||
|
|
||||||
editTime: DS.attr('date'),
|
isHidden: DS.attr('boolean'),
|
||||||
editUser: DS.belongsTo('user'),
|
deleteTime: DS.attr('date'),
|
||||||
isEdited: Ember.computed.notEmpty('editTime'),
|
deleteUser: DS.belongsTo('user'),
|
||||||
|
|
||||||
deleteTime: DS.attr('date'),
|
|
||||||
deleteUser: DS.belongsTo('user'),
|
|
||||||
isDeleted: Ember.computed.notEmpty('deleteTime'),
|
|
||||||
|
|
||||||
canEdit: DS.attr('boolean'),
|
|
||||||
canDelete: DS.attr('boolean')
|
|
||||||
|
|
||||||
|
canEdit: DS.attr('boolean'),
|
||||||
|
canDelete: DS.attr('boolean')
|
||||||
});
|
});
|
||||||
|
@@ -1,23 +1,20 @@
|
|||||||
import DS from 'ember-data';
|
import DS from 'ember-data';
|
||||||
|
|
||||||
export default DS.Model.extend({
|
export default DS.Model.extend({
|
||||||
|
username: DS.attr('string'),
|
||||||
|
email: DS.attr('string'),
|
||||||
|
password: DS.attr('string'),
|
||||||
|
avatarUrl: DS.attr('string'),
|
||||||
|
|
||||||
username: DS.attr('string'),
|
groups: DS.hasMany('group'),
|
||||||
avatarUrl: DS.attr('string'),
|
|
||||||
joinTime: DS.attr('date'),
|
|
||||||
lastSeenTime: DS.attr('date'),
|
|
||||||
discussionsCount: DS.attr('number'),
|
|
||||||
postsCount: DS.attr('number'),
|
|
||||||
|
|
||||||
canEdit: DS.attr('boolean'),
|
joinTime: DS.attr('date'),
|
||||||
canDelete: DS.attr('boolean'),
|
lastSeenTime: DS.attr('date'),
|
||||||
|
readTime: DS.attr('date'),
|
||||||
|
|
||||||
groups: DS.hasMany('group'),
|
discussionsCount: DS.attr('number'),
|
||||||
|
postsCount: DS.attr('number'),
|
||||||
|
|
||||||
email: DS.attr('string'),
|
canEdit: DS.attr('boolean'),
|
||||||
password: DS.attr('string'),
|
canDelete: DS.attr('boolean')
|
||||||
|
|
||||||
avatarNumber: function() {
|
|
||||||
return Math.random() > 0.3 ? Math.floor(Math.random() * 19) + 1 : null;
|
|
||||||
}.property()
|
|
||||||
});
|
});
|
||||||
|
@@ -7,17 +7,17 @@ var Router = Ember.Router.extend({
|
|||||||
|
|
||||||
Router.map(function() {
|
Router.map(function() {
|
||||||
|
|
||||||
this.resource('index', {path: '/'}, function() {
|
this.resource('index', {path: '/'}, function() {
|
||||||
this.resource('discussion', {path: '/:id/:slug'}, function() {
|
this.resource('discussion', {path: '/:id/:slug'}, function() {
|
||||||
this.route('near', {path: '/:near'});
|
this.route('near', {path: '/:near'});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
this.resource('user', {path: '/u/:username'}, function() {
|
this.resource('user', {path: '/u/:username'}, function() {
|
||||||
this.route('activity');
|
this.route('activity');
|
||||||
this.route('posts');
|
this.route('posts');
|
||||||
this.resource('preferences');
|
this.resource('preferences');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -2,39 +2,38 @@ import Ember from 'ember';
|
|||||||
import ApplicationRouteMixin from 'simple-auth/mixins/application-route-mixin';
|
import ApplicationRouteMixin from 'simple-auth/mixins/application-route-mixin';
|
||||||
|
|
||||||
export default Ember.Route.extend(ApplicationRouteMixin, {
|
export default Ember.Route.extend(ApplicationRouteMixin, {
|
||||||
|
actions: {
|
||||||
|
login: function() {
|
||||||
|
this.controllerFor('login').set('error', null);
|
||||||
|
this.send('showModal', 'login');
|
||||||
|
},
|
||||||
|
|
||||||
actions: {
|
signup: function() {
|
||||||
login: function() {
|
this.controllerFor('signup').set('error', null);
|
||||||
this.controllerFor('login').set('error', null);
|
this.send('showModal', 'signup');
|
||||||
this.send('showModal', 'login');
|
},
|
||||||
},
|
|
||||||
|
|
||||||
signup: function() {
|
showModal: function(name) {
|
||||||
this.controllerFor('signup').set('error', null);
|
this.render(name, {
|
||||||
this.send('showModal', 'signup');
|
into: 'application',
|
||||||
},
|
outlet: 'modal'
|
||||||
|
});
|
||||||
|
this.controllerFor('application').set('modalController', this.controllerFor(name));
|
||||||
|
},
|
||||||
|
|
||||||
showModal: function(name) {
|
closeModal: function() {
|
||||||
this.render(name, {
|
this.controllerFor('application').set('modalController', null);
|
||||||
into: 'application',
|
},
|
||||||
outlet: 'modal'
|
|
||||||
});
|
|
||||||
this.controllerFor('application').set('modalController', this.controllerFor(name));
|
|
||||||
},
|
|
||||||
|
|
||||||
closeModal: function() {
|
destroyModal: function() {
|
||||||
this.controllerFor('application').set('modalController', null);
|
this.disconnectOutlet({
|
||||||
},
|
outlet: 'modal',
|
||||||
|
parentView: 'application'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
destroyModal: function() {
|
sessionChanged: function() {
|
||||||
this.disconnectOutlet({
|
this.refresh();
|
||||||
outlet: 'modal',
|
}
|
||||||
parentView: 'application'
|
}
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
sessionChanged: function() {
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
@@ -1,121 +1,121 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
import PostStream from '../models/post-stream';
|
import PostStream from 'flarum/models/post-stream';
|
||||||
|
|
||||||
export default Ember.Route.extend({
|
export default Ember.Route.extend({
|
||||||
queryParams: {
|
queryParams: {
|
||||||
start: {replace: true}
|
start: {replace: true}
|
||||||
},
|
},
|
||||||
|
|
||||||
model: function(params) {
|
model: function(params) {
|
||||||
return this.store.findQueryOne('discussion', params.id, {
|
return this.store.findQueryOne('discussion', params.id, {
|
||||||
include: 'posts',
|
include: 'posts',
|
||||||
near: params.start
|
near: params.start
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
resetController: function(controller) {
|
resetController: function(controller) {
|
||||||
// Whenever we exit the discussion view, or transition to a different
|
// Whenever we exit the discussion view, or transition to a different
|
||||||
// discussion, we want to reset the query params so that they don't stick.
|
// discussion, we want to reset the query params so that they don't stick.
|
||||||
controller.set('start', '1');
|
controller.set('start', '1');
|
||||||
controller.set('searchQuery', '');
|
controller.set('searchQuery', '');
|
||||||
controller.set('loaded', false);
|
controller.set('loaded', false);
|
||||||
controller.set('stream', null);
|
controller.set('stream', null);
|
||||||
},
|
},
|
||||||
|
|
||||||
setupController: function(controller, discussion) {
|
setupController: function(controller, discussion) {
|
||||||
controller.set('model', discussion);
|
controller.set('model', discussion);
|
||||||
this.controllerFor('index/index').set('lastDiscussion', discussion);
|
this.controllerFor('index/index').set('lastDiscussion', discussion);
|
||||||
|
|
||||||
// Set up the post stream object. It needs to know about the discussion
|
// Set up the post stream object. It needs to know about the discussion
|
||||||
// it's representing the posts for, and we also need to inject the Ember
|
// it's representing the posts for, and we also need to inject the Ember
|
||||||
// Data store.
|
// Data store.
|
||||||
var stream = PostStream.create({
|
var stream = PostStream.create({
|
||||||
discussion: discussion,
|
discussion: discussion,
|
||||||
store: this.store
|
store: this.store
|
||||||
|
});
|
||||||
|
controller.set('stream', stream);
|
||||||
|
|
||||||
|
// Next, we need to make sure we have a list of the discussion's post
|
||||||
|
// IDs. If we don't already have this information, we'll need to
|
||||||
|
// reload the discussion model.
|
||||||
|
var promise = discussion.get('posts') ? Ember.RSVP.resolve(discussion) : this.model({
|
||||||
|
id: discussion.get('id'),
|
||||||
|
start: controller.get('start')
|
||||||
|
});
|
||||||
|
|
||||||
|
// When we know we have the post IDs, we can set up the post stream with
|
||||||
|
// them. Then we will tell the view that we have finished loading so that
|
||||||
|
// it can scroll down to the appropriate post.
|
||||||
|
promise.then(function(discussion) {
|
||||||
|
var postIds = discussion.get('postIds');
|
||||||
|
stream.setup(postIds);
|
||||||
|
|
||||||
|
// A page of posts will have been returned as linked data by this
|
||||||
|
// request, and automatically loaded into the store. In turn, we
|
||||||
|
// want to load them into the stream. However, since there is no
|
||||||
|
// way to access them directly, we need to retrieve them based on
|
||||||
|
// the requested start number. This code finds the post for that
|
||||||
|
// number, gets its index, slices an array of surrounding post
|
||||||
|
// IDs, and finally adds these posts to the stream.
|
||||||
|
var posts = discussion.get('loadedPosts');
|
||||||
|
var startPost = posts.findBy('number', parseInt(controller.get('start')));
|
||||||
|
if (startPost) {
|
||||||
|
var startIndex = postIds.indexOf(startPost.get('id'));
|
||||||
|
var count = stream.get('postLoadCount');
|
||||||
|
startIndex = Math.max(0, startIndex - count / 2);
|
||||||
|
var loadIds = postIds.slice(startIndex, startIndex + count);
|
||||||
|
stream.addPosts(posts.filter(function(item) {
|
||||||
|
return loadIds.indexOf(item.get('id')) !== -1;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the list of post IDs for this discussion (without
|
||||||
|
// dirtying the record), so that next time we load the discussion,
|
||||||
|
// the discussion details and post IDs will be refreshed.
|
||||||
|
controller.store.push('discussion', {id: discussion.get('id'), posts: ''});
|
||||||
|
|
||||||
|
// It's possible for this promise to have resolved but the user
|
||||||
|
// has clicked away to a different discussion. So only if we're
|
||||||
|
// still on the original one, we will tell the view that we're
|
||||||
|
// done loading.
|
||||||
|
if (controller.get('model') === discussion) {
|
||||||
|
controller.set('loaded', true);
|
||||||
|
Ember.run.scheduleOnce('afterRender', function() {
|
||||||
|
controller.trigger('loaded');
|
||||||
});
|
});
|
||||||
controller.set('stream', stream);
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Next, we need to make sure we have a list of the discussion's post
|
actions: {
|
||||||
// IDs. If we don't already have this information, we'll need to
|
queryParamsDidChange: function(params) {
|
||||||
// reload the discussion model.
|
// If the ?start param has changed, we want to tell the view to
|
||||||
var promise = discussion.get('posts') ? Ember.RSVP.resolve(discussion) : this.model({
|
// tell the streamContent component to jump to this start point.
|
||||||
id: discussion.get('id'),
|
// We postpone running this code until the next run loop because
|
||||||
start: controller.get('start')
|
// when transitioning directly from one discussion to another,
|
||||||
});
|
// queryParamsDidChange is fired before the controller is reset.
|
||||||
|
// Thus, controller.loaded would still be true and the
|
||||||
|
// startWasChanged event would be triggered inappropriately.
|
||||||
|
var newStart = parseInt(params.start) || 1;
|
||||||
|
var controller = this.controllerFor('discussion');
|
||||||
|
var oldStart = parseInt(controller.get('start'));
|
||||||
|
Ember.run.next(function() {
|
||||||
|
if (controller.get('loaded') && newStart !== oldStart) {
|
||||||
|
controller.trigger('startWasChanged', newStart);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// When we know we have the post IDs, we can set up the post stream with
|
didTransition: function() {
|
||||||
// them. Then we will tell the view that we have finished loading so that
|
// When we transition into a new discussion, we want to hide the
|
||||||
// it can scroll down to the appropriate post.
|
// discussions list pane. This means that when the user selects a
|
||||||
promise.then(function(discussion) {
|
// different discussion within the pane, the pane will slide away.
|
||||||
var postIds = discussion.get('postIds');
|
// We also minimize the composer.
|
||||||
stream.setup(postIds);
|
this.controllerFor('index')
|
||||||
|
.set('paned', true)
|
||||||
// A page of posts will have been returned as linked data by this
|
.set('paneShowing', false);
|
||||||
// request, and automatically loaded into the store. In turn, we
|
this.controllerFor('composer').send('minimize');
|
||||||
// want to load them into the stream. However, since there is no
|
}
|
||||||
// way to access them directly, we need to retrieve them based on
|
}
|
||||||
// the requested start number. This code finds the post for that
|
|
||||||
// number, gets its index, slices an array of surrounding post
|
|
||||||
// IDs, and finally adds these posts to the stream.
|
|
||||||
var posts = discussion.get('loadedPosts');
|
|
||||||
var startPost = posts.findBy('number', parseInt(controller.get('start')));
|
|
||||||
if (startPost) {
|
|
||||||
var startIndex = postIds.indexOf(startPost.get('id'));
|
|
||||||
var count = stream.get('postLoadCount');
|
|
||||||
startIndex = Math.max(0, startIndex - count / 2);
|
|
||||||
var loadIds = postIds.slice(startIndex, startIndex + count);
|
|
||||||
stream.addPosts(posts.filter(function(item) {
|
|
||||||
return loadIds.indexOf(item.get('id')) !== -1;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the list of post IDs for this discussion (without
|
|
||||||
// dirtying the record), so that next time we load the discussion,
|
|
||||||
// the discussion details and post IDs will be refreshed.
|
|
||||||
controller.store.push('discussion', {id: discussion.get('id'), posts: ''});
|
|
||||||
|
|
||||||
// It's possible for this promise to have resolved but the user
|
|
||||||
// has clicked away to a different discussion. So only if we're
|
|
||||||
// still on the original one, we will tell the view that we're
|
|
||||||
// done loading.
|
|
||||||
if (controller.get('model') === discussion) {
|
|
||||||
controller.set('loaded', true);
|
|
||||||
Ember.run.scheduleOnce('afterRender', function() {
|
|
||||||
controller.trigger('loaded');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
queryParamsDidChange: function(params) {
|
|
||||||
// If the ?start param has changed, we want to tell the view to
|
|
||||||
// tell the streamContent component to jump to this start point.
|
|
||||||
// We postpone running this code until the next run loop because
|
|
||||||
// when transitioning directly from one discussion to another,
|
|
||||||
// queryParamsDidChange is fired before the controller is reset.
|
|
||||||
// Thus, controller.loaded would still be true and the
|
|
||||||
// startWasChanged event would be triggered inappropriately.
|
|
||||||
var newStart = parseInt(params.start) || 1;
|
|
||||||
var controller = this.controllerFor('discussion');
|
|
||||||
var oldStart = parseInt(controller.get('start'));
|
|
||||||
Ember.run.next(function() {
|
|
||||||
if (controller.get('loaded') && newStart !== oldStart) {
|
|
||||||
controller.trigger('startWasChanged', newStart);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
didTransition: function() {
|
|
||||||
// When we transition into a new discussion, we want to hide the
|
|
||||||
// discussions list pane. This means that when the user selects a
|
|
||||||
// different discussion within the pane, the pane will slide away.
|
|
||||||
// We also minimize the composer.
|
|
||||||
this.controllerFor('index')
|
|
||||||
.set('paned', true)
|
|
||||||
.set('paneShowing', false);
|
|
||||||
this.controllerFor('composer').send('minimize');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
@@ -1,47 +1,51 @@
|
|||||||
import Ember from 'ember';
|
import Ember from 'ember';
|
||||||
|
|
||||||
import AddCssClassToBodyMixin from '../../mixins/add-css-class-to-body';
|
import AddCssClassToBody from 'flarum/mixins/add-css-class-to-body';
|
||||||
|
|
||||||
export default Ember.Route.extend(AddCssClassToBodyMixin, {
|
export default Ember.Route.extend(AddCssClassToBody, {
|
||||||
cachedModel: null,
|
cachedModel: null,
|
||||||
|
|
||||||
model: function() {
|
model: function() {
|
||||||
if (!this.get('cachedModel')) {
|
if (!this.get('cachedModel')) {
|
||||||
this.set('cachedModel', Ember.ArrayProxy.create());
|
this.set('cachedModel', Ember.ArrayProxy.create());
|
||||||
}
|
}
|
||||||
return Ember.RSVP.resolve(this.get('cachedModel'));
|
return Ember.RSVP.resolve(this.get('cachedModel'));
|
||||||
},
|
},
|
||||||
|
|
||||||
setupController: function(controller, model) {
|
setupController: function(controller, model) {
|
||||||
controller.set('model', model);
|
controller.set('model', model);
|
||||||
|
|
||||||
if (!model.get('length')) {
|
if (!model.get('length')) {
|
||||||
controller.set('resultsLoading', true);
|
controller.set('resultsLoading', true);
|
||||||
controller.getResults().then(function(results) {
|
controller.getResults().then(function(results) {
|
||||||
controller
|
controller
|
||||||
.set('resultsLoading', false)
|
.set('resultsLoading', false)
|
||||||
.set('meta', results.get('meta'))
|
.set('meta', results.get('meta'))
|
||||||
.set('model.content', results);
|
.set('model.content', results);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deactivate: function() {
|
deactivate: function() {
|
||||||
this._super();
|
this._super();
|
||||||
this.controllerFor('application').set('backButtonTarget', this.controllerFor('index'));
|
this.controllerFor('application').set('backButtonTarget', this.controllerFor('index'));
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
|
||||||
refresh: function() {
|
|
||||||
this.set('cachedModel', null);
|
|
||||||
this.refresh();
|
|
||||||
},
|
|
||||||
|
|
||||||
didTransition: function() {
|
actions: {
|
||||||
// @todo only if it's not a new discussion
|
refresh: function() {
|
||||||
this.controllerFor('composer').send('minimize');
|
this.set('cachedModel', null);
|
||||||
this.controllerFor('index').set('paned', false);
|
this.refresh();
|
||||||
this.controllerFor('index').set('paneShowing', false);
|
},
|
||||||
}
|
|
||||||
}
|
didTransition: function() {
|
||||||
|
var application = this.controllerFor('application');
|
||||||
|
if (application.get('backButtonTarget') === this.controllerFor('index')) {
|
||||||
|
application.set('backButtonTarget', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controllerFor('composer').send('minimize');
|
||||||
|
this.controllerFor('index').set('paned', false);
|
||||||
|
this.controllerFor('index').set('paneShowing', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
4
ember/app/styles/bootstrap/bootstrap.less
vendored
4
ember/app/styles/bootstrap/bootstrap.less
vendored
@@ -12,7 +12,7 @@
|
|||||||
@import "@{bootstrap-base}type.less";
|
@import "@{bootstrap-base}type.less";
|
||||||
@import "@{bootstrap-base}code.less";
|
@import "@{bootstrap-base}code.less";
|
||||||
@import "@{bootstrap-base}grid.less";
|
@import "@{bootstrap-base}grid.less";
|
||||||
@import "@{bootstrap-base}tables.less";
|
// @import "@{bootstrap-base}tables.less";
|
||||||
@import "@{bootstrap-base}forms.less";
|
@import "@{bootstrap-base}forms.less";
|
||||||
@import "@{bootstrap-base}buttons.less";
|
@import "@{bootstrap-base}buttons.less";
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
// Components w/ JavaScript
|
// Components w/ JavaScript
|
||||||
@import "@{bootstrap-base}modals.less";
|
@import "@{bootstrap-base}modals.less";
|
||||||
@import "@{bootstrap-base}tooltip.less";
|
@import "@{bootstrap-base}tooltip.less";
|
||||||
@import "@{bootstrap-base}popovers.less";
|
// @import "@{bootstrap-base}popovers.less";
|
||||||
// @import "@{bootstrap-base}carousel.less";
|
// @import "@{bootstrap-base}carousel.less";
|
||||||
|
|
||||||
// Utility classes
|
// Utility classes
|
||||||
|
@@ -8,30 +8,30 @@
|
|||||||
|
|
||||||
.define-body-variables(@fl-dark-body);
|
.define-body-variables(@fl-dark-body);
|
||||||
.define-body-variables(false) {
|
.define-body-variables(false) {
|
||||||
@fl-body-bg: #fff;
|
@fl-body-bg: #fff;
|
||||||
@fl-body-color: #444;
|
@fl-body-color: #444;
|
||||||
@fl-body-muted-color: hsv(hue(@fl-secondary-color), max(0, saturation(@fl-secondary-color) - 20%), 65%); // todo
|
@fl-body-muted-color: hsv(hue(@fl-secondary-color), max(0, saturation(@fl-secondary-color) - 20%), 65%); // todo
|
||||||
@fl-body-muted-more-color: #bbb;
|
@fl-body-muted-more-color: #bbb;
|
||||||
@fl-body-heading-color: @fl-body-color;
|
@fl-body-heading-color: @fl-body-color;
|
||||||
|
|
||||||
@fl-body-control-bg: @fl-secondary-color;
|
@fl-body-control-bg: @fl-secondary-color;
|
||||||
@fl-body-control-color: @fl-body-muted-color;
|
@fl-body-control-color: @fl-body-muted-color;
|
||||||
|
|
||||||
@fl-body-control-primary-bg: @fl-primary-color;
|
@fl-body-control-primary-bg: @fl-primary-color;
|
||||||
@fl-body-control-primary-color: #fff;
|
@fl-body-control-primary-color: #fff;
|
||||||
}
|
}
|
||||||
.define-body-variables(true) {
|
.define-body-variables(true) {
|
||||||
@fl-body-bg: #333;
|
@fl-body-bg: #333;
|
||||||
@fl-body-color: #ccc;
|
@fl-body-color: #ccc;
|
||||||
@fl-body-muted-color: hsv(hue(@fl-secondary-color), max(0, saturation(@fl-secondary-color) - 20%), 65%); // todo
|
@fl-body-muted-color: hsv(hue(@fl-secondary-color), max(0, saturation(@fl-secondary-color) - 20%), 65%); // todo
|
||||||
@fl-body-muted-more-color: #bbb;
|
@fl-body-muted-more-color: #bbb;
|
||||||
@fl-body-heading-color: @fl-body-color;
|
@fl-body-heading-color: @fl-body-color;
|
||||||
|
|
||||||
@fl-body-control-bg: @fl-secondary-color;
|
@fl-body-control-bg: @fl-secondary-color;
|
||||||
@fl-body-control-color: @fl-body-muted-color;
|
@fl-body-control-color: @fl-body-muted-color;
|
||||||
|
|
||||||
@fl-body-control-primary-bg: @fl-primary-color;
|
@fl-body-control-primary-bg: @fl-primary-color;
|
||||||
@fl-body-control-primary-color: #fff;
|
@fl-body-control-primary-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------
|
// ---------------------------------
|
||||||
@@ -39,22 +39,22 @@
|
|||||||
|
|
||||||
.define-hdr-variables(@fl-dark-hdr);
|
.define-hdr-variables(@fl-dark-hdr);
|
||||||
.define-hdr-variables(false) {
|
.define-hdr-variables(false) {
|
||||||
@fl-hdr-bg: @fl-body-bg;
|
@fl-hdr-bg: @fl-body-bg;
|
||||||
@fl-hdr-color: @fl-primary-color;
|
@fl-hdr-color: @fl-primary-color;
|
||||||
@fl-hdr-muted-color: @fl-body-muted-color;
|
@fl-hdr-muted-color: @fl-body-muted-color;
|
||||||
@fl-hdr-control-bg: @fl-body-control-bg;
|
@fl-hdr-control-bg: @fl-body-control-bg;
|
||||||
@fl-hdr-control-color: @fl-body-control-color;
|
@fl-hdr-control-color: @fl-body-control-color;
|
||||||
|
|
||||||
@fl-body-hero-bg: @fl-primary-color;
|
@fl-body-hero-bg: @fl-primary-color;
|
||||||
@fl-body-hero-color: #fff;
|
@fl-body-hero-color: #fff;
|
||||||
}
|
}
|
||||||
.define-hdr-variables(true) {
|
.define-hdr-variables(true) {
|
||||||
@fl-hdr-bg: @fl-primary-color;
|
@fl-hdr-bg: @fl-primary-color;
|
||||||
@fl-hdr-color: #fff;
|
@fl-hdr-color: #fff;
|
||||||
@fl-hdr-muted-color: fade(#fff, 50%);
|
@fl-hdr-muted-color: fade(#fff, 50%);
|
||||||
@fl-hdr-control-bg: fade(#000, 10%);
|
@fl-hdr-control-bg: fade(#000, 10%);
|
||||||
@fl-hdr-control-color: #fff;
|
@fl-hdr-control-color: #fff;
|
||||||
|
|
||||||
@fl-body-hero-bg: @fl-secondary-color;
|
@fl-body-hero-bg: @fl-secondary-color;
|
||||||
@fl-body-hero-color: @fl-body-muted-color;
|
@fl-body-hero-color: @fl-body-muted-color;
|
||||||
}
|
}
|
||||||
|
@@ -1,58 +1,58 @@
|
|||||||
.alerts {
|
.alerts {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
z-index: @zindex-alerts;
|
z-index: @zindex-alerts;
|
||||||
|
|
||||||
& .alert {
|
& .alert {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.3));
|
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.3));
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert {
|
.alert {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: @border-radius-base;
|
border-radius: @border-radius-base;
|
||||||
background: #FFF2AE;
|
background: #FFF2AE;
|
||||||
&, & a, & a:hover {
|
&, & a, & a:hover {
|
||||||
color: #AD6C00;
|
color: #AD6C00;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert-warning {
|
.alert-warning {
|
||||||
background: #D83E3E;
|
background: #D83E3E;
|
||||||
&, & a, & a:hover {
|
&, & a, & a:hover {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert-controls {
|
.alert-controls {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0 -8px 0 8px;
|
margin: 0 -8px 0 8px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
& li {
|
& li {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0 5px;
|
margin: 0 5px;
|
||||||
}
|
}
|
||||||
& a {
|
& a {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
& .btn {
|
& .btn {
|
||||||
margin: -10px;
|
margin: -10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.form-group { // probably move this elsewhere
|
.form-group { // probably move this elsewhere
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.form-alert {
|
.form-alert {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
& .alert {
|
& .alert {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,237 +2,242 @@
|
|||||||
// Buttons
|
// Buttons
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
border: 0;
|
border: 0;
|
||||||
.box-shadow(none);
|
.box-shadow(none);
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
|
|
||||||
& .fa {
|
& .fa {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.btn-group .btn + .btn {
|
.btn-group .btn + .btn {
|
||||||
margin-left: 1px;
|
margin-left: 1px;
|
||||||
}
|
}
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
padding-left: 9px;
|
padding-left: 9px;
|
||||||
padding-right: 9px;
|
padding-right: 9px;
|
||||||
}
|
}
|
||||||
.btn-link {
|
.btn-link {
|
||||||
color: @fl-body-muted-color;
|
color: @fl-body-muted-color;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus {
|
&:focus {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
& .icon-glyph {
|
& .icon-glyph {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.btn-user {
|
.btn-user {
|
||||||
& .avatar {
|
& .avatar {
|
||||||
margin: -2px 5px -2px -5px;
|
margin: -2px 5px -2px -5px;
|
||||||
.avatar-size(24px);
|
.avatar-size(24px);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.btn-more {
|
||||||
|
padding: 1px 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redefine Bootstrap's mixin to make some general changes
|
// Redefine Bootstrap's mixin to make some general changes
|
||||||
.button-variant(@color; @background; @border) {
|
.button-variant(@color; @background; @border) {
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus,
|
&:focus,
|
||||||
&.focus,
|
&.focus,
|
||||||
&:active,
|
&:active,
|
||||||
&.active,
|
&.active,
|
||||||
.open > .dropdown-toggle& {
|
.open > .dropdown-toggle& {
|
||||||
background-color: darken(@background, 5%);
|
background-color: darken(@background, 5%);
|
||||||
}
|
}
|
||||||
&.active {
|
&.active {
|
||||||
.box-shadow(none);
|
.box-shadow(none);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Little round icon buttons
|
// Little round icon buttons
|
||||||
.btn-icon.btn-sm {
|
.btn-icon.btn-sm {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
|
|
||||||
& .label, & .icon-caret {
|
& .label, & .icon-caret {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
& .fa-ellipsis-v {
|
& .fa-ellipsis-v {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buttons that blend into the background
|
// Buttons that blend into the background
|
||||||
.btn-naked {
|
.btn-naked {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
&:hover {
|
&:hover {
|
||||||
background: @fl-body-control-bg;
|
background: @fl-body-control-bg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-rounded {
|
.btn-rounded {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Form Controls
|
// Form Controls
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.form-control {
|
.form-control {
|
||||||
.box-shadow(none);
|
.box-shadow(none);
|
||||||
&:focus,
|
&:focus,
|
||||||
&.focus {
|
&.focus {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
color: @fl-body-color;
|
color: @fl-body-color;
|
||||||
.box-shadow(none);
|
.box-shadow(none);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search inputs
|
// Search inputs
|
||||||
// @todo Extract some of this into header-specific definitions
|
// @todo Extract some of this into header-specific definitions
|
||||||
.search-input {
|
.search-input {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
&:before {
|
&:before {
|
||||||
.fa();
|
.fa();
|
||||||
content: @fa-var-search;
|
content: @fa-var-search;
|
||||||
float: left;
|
float: left;
|
||||||
margin-right: -36px;
|
margin-right: -36px;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: @fl-body-muted-color;
|
color: @fl-body-muted-color;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: @padding-base-vertical - 1 0;
|
padding: @padding-base-vertical - 1 0;
|
||||||
line-height: @line-height-base;
|
line-height: @line-height-base;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.search-input .form-control {
|
.search-input .form-control {
|
||||||
float: left;
|
float: left;
|
||||||
width: 225px;
|
width: 225px;
|
||||||
padding-left: 36px;
|
padding-left: 36px;
|
||||||
padding-right: 36px;
|
padding-right: 36px;
|
||||||
.transition(~"all 0.4s");
|
.transition(~"all 0.4s");
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.search-input .clear {
|
.search-input .clear {
|
||||||
float: left;
|
float: left;
|
||||||
margin-left: -36px;
|
margin-left: -36px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
.scale(0.001);
|
.scale(0.001);
|
||||||
.transition(~"transform 0.15s");
|
.transition(~"transform 0.15s");
|
||||||
}
|
}
|
||||||
.search-input.clearable .clear {
|
.search-input.clearable .clear {
|
||||||
.scale(1);
|
.scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select inputs
|
// Select inputs
|
||||||
.select-input {
|
.select-input {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.select-input select {
|
.select-input select {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: auto;
|
width: auto;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
padding-right: @padding-base-horizontal + 16;
|
padding-right: @padding-base-horizontal + 16;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.select-input .fa {
|
.select-input .fa {
|
||||||
margin-left: -@padding-base-horizontal - 16;
|
margin-left: -@padding-base-horizontal - 16;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
color: @fl-body-muted-color;
|
color: @fl-body-muted-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Dropdown Menus
|
// Dropdown Menus
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
|
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
|
||||||
|
|
||||||
& > li > a {
|
& > li > a {
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
color: @fl-body-color;
|
color: @fl-body-color;
|
||||||
&:hover, &:focus {
|
&:hover, &:focus {
|
||||||
color: @fl-body-color;
|
color: @fl-body-color;
|
||||||
background-color: @fl-secondary-color;
|
background-color: @fl-secondary-color;
|
||||||
}
|
}
|
||||||
& .fa {
|
& .fa {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& .divider {
|
& .divider {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
background-color: darken(@fl-secondary-color, 2%);
|
background-color: darken(@fl-secondary-color, 2%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.dropdown-split.item-count-1 {
|
.dropdown-split.item-count-1 {
|
||||||
& .btn {
|
& .btn {
|
||||||
border-radius: @border-radius-base !important;
|
border-radius: @border-radius-base !important;
|
||||||
}
|
}
|
||||||
& .dropdown-toggle {
|
& .dropdown-toggle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Tooltips
|
// Tooltips
|
||||||
|
|
||||||
.tooltip-inner {
|
.tooltip-inner {
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Loading Indicators
|
// Loading Indicators
|
||||||
|
|
||||||
.loading-indicator {
|
.loading-indicator {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: @fl-primary-color;
|
color: @fl-primary-color;
|
||||||
}
|
}
|
||||||
.loading-indicator-block {
|
.loading-indicator-block {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Avatars
|
// Avatars
|
||||||
|
|
||||||
.avatar-size(@size) {
|
.avatar-size(@size) {
|
||||||
width: @size;
|
width: @size;
|
||||||
height: @size;
|
height: @size;
|
||||||
border-radius: @size / 2;
|
border-radius: @size / 2;
|
||||||
font-size: @size / 2;
|
font-size: @size / 2;
|
||||||
line-height: @size;
|
line-height: @size;
|
||||||
}
|
}
|
||||||
.avatar {
|
.avatar {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
.avatar-size(48px);
|
.avatar-size(48px);
|
||||||
|
|
||||||
& img {
|
& img {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,163 +2,163 @@
|
|||||||
// Composer
|
// Composer
|
||||||
|
|
||||||
.composer-container {
|
.composer-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: @zindex-composer;
|
z-index: @zindex-composer;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
.transition(left 0.2s);
|
.transition(left 0.2s);
|
||||||
|
|
||||||
.with-pane & {
|
.with-pane & {
|
||||||
left: @index-pane-width;
|
left: @index-pane-width;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.composer {
|
.composer {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
margin-left: -20px;
|
margin-left: -20px;
|
||||||
margin-right: 180px;
|
margin-right: 180px;
|
||||||
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
|
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
|
||||||
border-radius: @border-radius-base @border-radius-base 0 0;
|
border-radius: @border-radius-base @border-radius-base 0 0;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
|
transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
.transition(~"background 0.2s");
|
.transition(~"background 0.2s");
|
||||||
|
|
||||||
.index-index & {
|
.index-index & {
|
||||||
margin-left: 205px;
|
margin-left: 205px;
|
||||||
margin-right: -20px;
|
margin-right: -20px;
|
||||||
}
|
}
|
||||||
&.active, &.fullscreen {
|
&.active, &.fullscreen {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
&.minimized {
|
&.minimized {
|
||||||
height: 50px;
|
height: 50px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
&.fullscreen {
|
&.fullscreen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.composer-content {
|
.composer-content {
|
||||||
padding: 20px 20px 15px;
|
padding: 20px 20px 15px;
|
||||||
|
|
||||||
.minimized & {
|
.minimized & {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
.fullscreen & {
|
.fullscreen & {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.composer-handle {
|
.composer-handle {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin-bottom: -20px;
|
margin-bottom: -20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.minimized &, .fullscreen & {
|
.minimized &, .fullscreen & {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.composer-controls {
|
.composer-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
& li {
|
& li {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.minimized & {
|
.minimized & {
|
||||||
top: 7px;
|
top: 7px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fa-minus.minimize {
|
.fa-minus.minimize {
|
||||||
vertical-align: -5px;
|
vertical-align: -5px;
|
||||||
}
|
}
|
||||||
.composer-avatar {
|
.composer-avatar {
|
||||||
float: left;
|
float: left;
|
||||||
.avatar-size(64px);
|
.avatar-size(64px);
|
||||||
|
|
||||||
.minimized & {
|
.minimized & {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.composer-body {
|
.composer-body {
|
||||||
margin-left: 90px;
|
margin-left: 90px;
|
||||||
|
|
||||||
& h3 {
|
|
||||||
margin: 5px 0 10px;
|
|
||||||
&, & input {
|
|
||||||
color: @fl-body-muted-color;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
& input {
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.minimized & {
|
& h3 {
|
||||||
margin-left: 0;
|
margin: 5px 0 10px;
|
||||||
}
|
&, & input {
|
||||||
|
color: @fl-body-muted-color;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
& input {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimized & {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.composer-editor {
|
.composer-editor {
|
||||||
.minimized & {
|
.minimized & {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.text-editor {
|
.text-editor {
|
||||||
& textarea {
|
& textarea {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
resize: none;
|
resize: none;
|
||||||
color: @fl-body-color;
|
color: @fl-body-color;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
||||||
&, &:focus, &[disabled] {
|
&, &:focus, &[disabled] {
|
||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.text-editor-controls {
|
.text-editor-controls {
|
||||||
margin: 10px 0 0;
|
margin: 10px 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
& li {
|
& li {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-loading {
|
.composer-loading {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
border-radius: @border-radius-base @border-radius-base 0 0;
|
border-radius: @border-radius-base @border-radius-base 0 0;
|
||||||
.transition(opacity 0.2s);
|
.transition(opacity 0.2s);
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,239 +2,273 @@
|
|||||||
// Sidebar
|
// Sidebar
|
||||||
|
|
||||||
.discussion-nav {
|
.discussion-nav {
|
||||||
float: right;
|
float: right;
|
||||||
|
|
||||||
&, & > ul {
|
&, & > ul {
|
||||||
width: 150px;
|
width: 150px;
|
||||||
}
|
}
|
||||||
& > ul {
|
& > ul {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
margin: 30px 0 0;
|
margin: 30px 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
||||||
& > li {
|
& > li {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& .btn-group, & .btn {
|
& .btn-group, & .btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
& .btn-group:not(.item-count-1) {
|
& .btn-group:not(.item-count-1) {
|
||||||
& .btn {
|
& .btn {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
}
|
}
|
||||||
& .dropdown-toggle {
|
& .dropdown-toggle {
|
||||||
width: 19%;
|
width: 19%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Stream
|
// Stream
|
||||||
|
|
||||||
.discussion-posts {
|
.discussion-posts {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
margin-right: 200px;
|
margin-right: 200px;
|
||||||
& .item {
|
& .item {
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.gap {
|
.gap {
|
||||||
padding: 30px 0;
|
padding: 30px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 2px dashed @fl-body-bg;
|
border: 2px dashed @fl-body-bg;
|
||||||
background: #f2f2f2;
|
background: #f2f2f2;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
.transition(padding 0.2s);
|
.transition(padding 0.2s);
|
||||||
|
|
||||||
&:hover, &.loading, &.active {
|
&:hover, &.loading, &.active {
|
||||||
padding: 50px 0;
|
padding: 50px 0;
|
||||||
&.up:before, &.down:after {
|
&.up:before, &.down:after {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.loading {
|
&.loading {
|
||||||
.transition(none);
|
.transition(none);
|
||||||
}
|
}
|
||||||
&:before, &:after {
|
&:before, &:after {
|
||||||
font-family: 'FontAwesome';
|
font-family: 'FontAwesome';
|
||||||
display: block;
|
display: block;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
&.up:before {
|
&.up:before {
|
||||||
content: '\f077';
|
content: '\f077';
|
||||||
margin-top: -25px;
|
margin-top: -25px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
&.down:after {
|
&.down:after {
|
||||||
content: '\f078';
|
content: '\f078';
|
||||||
margin-bottom: -25px;
|
margin-bottom: -25px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
&:only-child {
|
&:only-child {
|
||||||
background: none;
|
background: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: @fl-primary-color;
|
color: @fl-primary-color;
|
||||||
&:before, &:after {
|
&:before, &:after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Posts
|
// Posts
|
||||||
|
|
||||||
.post {
|
.post {
|
||||||
padding-left: 90px;
|
padding-left: 90px;
|
||||||
transition: 0.2s box-shadow;
|
padding-bottom: 1px;
|
||||||
|
transition: 0.2s box-shadow;
|
||||||
|
|
||||||
& .contextual-controls {
|
& .contextual-controls {
|
||||||
float: right;
|
float: right;
|
||||||
margin: -2px 0 0 10px;
|
margin: -2px 0 0 10px;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
z-index: 1;
|
||||||
&:hover .contextual-controls, & .contextual-controls.open {
|
}
|
||||||
visibility: visible;
|
&:hover .contextual-controls, & .contextual-controls.open {
|
||||||
}
|
visibility: visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.item.highlight .post {
|
.item.highlight .post {
|
||||||
border: 8px solid rgba(255, 255, 0, 0.2);
|
border: 8px solid rgba(255, 255, 0, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 15px 15px 0 105px;
|
padding: 15px 15px 1px 105px;
|
||||||
margin: -23px -23px -8px -23px;
|
margin: -23px -23px -8px -23px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-header {
|
.post-header {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
color: @fl-body-muted-color;
|
color: @fl-body-muted-color;
|
||||||
|
|
||||||
& > ul {
|
& > ul {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
& > li {
|
& > li {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
& .user {
|
&, & a {
|
||||||
margin: 0;
|
color: @fl-body-muted-color;
|
||||||
display: inline;
|
}
|
||||||
font-weight: bold;
|
}
|
||||||
font-size: 15px;
|
& .post-user {
|
||||||
&, & a {
|
margin: 0;
|
||||||
color: @fl-body-heading-color;
|
display: inline;
|
||||||
}
|
font-weight: bold;
|
||||||
}
|
font-size: 15px;
|
||||||
& .avatar {
|
&, & a {
|
||||||
margin-left: -90px;
|
color: @fl-body-heading-color;
|
||||||
float: left;
|
}
|
||||||
.avatar-size(64px);
|
}
|
||||||
}
|
& .avatar {
|
||||||
& .time {
|
margin-left: -90px;
|
||||||
margin-left: 10px;
|
float: left;
|
||||||
&, & a {
|
.avatar-size(64px);
|
||||||
color: @fl-body-muted-color;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.post-body {
|
.post-body {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
padding-bottom: 1px;
|
|
||||||
}
|
}
|
||||||
.post-edited {
|
.post-icon {
|
||||||
margin-left: 10px;
|
float: left;
|
||||||
font-size: 14px;
|
margin-top: -2px;
|
||||||
|
margin-left: -90px;
|
||||||
|
width: 64px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.post.deleted {
|
||||||
|
& .post-user, & .post-header > ul, & .post-header > ul a:not(.btn) {
|
||||||
|
color: @fl-body-muted-more-color;
|
||||||
|
}
|
||||||
|
&:not(.reveal-content) {
|
||||||
|
& .post-body, & .post-footer, & .avatar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.reveal-content {
|
||||||
|
& .post-body, & .post-footer, & .avatar {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.post-meta {
|
||||||
|
width: 400px;
|
||||||
|
padding: 10px;
|
||||||
|
color: @fl-body-muted-color;
|
||||||
|
|
||||||
|
& .number {
|
||||||
|
color: @fl-body-color;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
& .time {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
& .permalink {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Scrubber
|
// Scrubber
|
||||||
|
|
||||||
@media (min-width: @screen-md-min) {
|
@media (min-width: @screen-md-min) {
|
||||||
.stream-scrubber {
|
.stream-scrubber {
|
||||||
margin: 30px 0 0 0;
|
margin: 30px 0 0 0;
|
||||||
}
|
}
|
||||||
.scrubber {
|
.scrubber {
|
||||||
& a {
|
& a {
|
||||||
margin-left: -5px;
|
margin-left: -5px;
|
||||||
color: @fl-body-muted-color;
|
color: @fl-body-muted-color;
|
||||||
& .fa {
|
& .fa {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
&:hover, &:focus {
|
&:hover, &:focus {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: @link-hover-color;
|
color: @link-hover-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrubber-scrollbar {
|
.scrubber-scrollbar {
|
||||||
margin: 8px 0 8px 3px;
|
margin: 8px 0 8px 3px;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
min-height: 50px; // JavaScript sets a max-height
|
min-height: 50px; // JavaScript sets a max-height
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.scrubber-before, .scrubber-after {
|
.scrubber-before, .scrubber-after {
|
||||||
border-left: 1px solid darken(@fl-secondary-color, 2%);
|
border-left: 1px solid darken(@fl-secondary-color, 2%);
|
||||||
}
|
}
|
||||||
.scrubber-slider {
|
.scrubber-slider {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
}
|
}
|
||||||
.scrubber-handle {
|
.scrubber-handle {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 5px;
|
width: 5px;
|
||||||
background: @fl-primary-color;
|
background: @fl-primary-color;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
float: left;
|
float: left;
|
||||||
margin-left: -2px;
|
margin-left: -2px;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
.disabled & {
|
.disabled & {
|
||||||
background: @fl-secondary-color;
|
background: @fl-secondary-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrubber-info {
|
.scrubber-info {
|
||||||
height: (2em * @line-height-base);
|
height: (2em * @line-height-base);
|
||||||
margin-top: (-1em * @line-height-base);
|
margin-top: (-1em * @line-height-base);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
left: 15px;
|
left: 15px;
|
||||||
& strong {
|
& strong {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
& .description {
|
& .description {
|
||||||
color: @fl-body-muted-color;
|
color: @fl-body-muted-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrubber-highlights {
|
.scrubber-highlights {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.scrubber-highlights li {
|
.scrubber-highlights li {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -6px;
|
right: -6px;
|
||||||
background: #fc0;
|
background: #fc0;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
width: 13px;
|
width: 13px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15), inset 0 0 0 1px rgba(255, 255, 255, 0.5);
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15), inset 0 0 0 1px rgba(255, 255, 255, 0.5);
|
||||||
opacity: 0.99;
|
opacity: 0.99;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,89 +2,89 @@
|
|||||||
// Sidebar
|
// Sidebar
|
||||||
|
|
||||||
.index-nav {
|
.index-nav {
|
||||||
float: left;
|
float: left;
|
||||||
|
|
||||||
&, & > ul {
|
&, & > ul {
|
||||||
width: 175px;
|
width: 175px;
|
||||||
}
|
}
|
||||||
& > ul {
|
& > ul {
|
||||||
margin: 30px 0 0;
|
margin: 30px 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
||||||
&.affix {
|
&.affix {
|
||||||
top: 56px;
|
top: 56px;
|
||||||
}
|
}
|
||||||
& > li {
|
& > li {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& .new-discussion {
|
& .new-discussion {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand the dropdown-select component into a normal nav list
|
// Expand the dropdown-select component into a normal nav list
|
||||||
// @todo Extract this into a mixin as we'll need to do it elsewhere.
|
// @todo Extract this into a mixin as we'll need to do it elsewhere.
|
||||||
& .dropdown-select {
|
& .dropdown-select {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
& .dropdown-toggle {
|
& .dropdown-toggle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
& .dropdown-menu {
|
& .dropdown-menu {
|
||||||
display: block;
|
display: block;
|
||||||
border: 0;
|
border: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
float: none;
|
float: none;
|
||||||
position: static;
|
position: static;
|
||||||
background: none;
|
background: none;
|
||||||
.box-shadow(none);
|
.box-shadow(none);
|
||||||
& > li > a {
|
& > li > a {
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
color: @fl-body-muted-color;
|
color: @fl-body-muted-color;
|
||||||
& .fa {
|
& .fa {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
background: none;
|
background: none;
|
||||||
color: @link-hover-color;
|
color: @link-hover-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& > li.active > a {
|
& > li.active > a {
|
||||||
background: none;
|
background: none;
|
||||||
color: @fl-primary-color;
|
color: @fl-primary-color;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Results
|
// Results
|
||||||
|
|
||||||
.index-results {
|
.index-results {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
margin-left: 225px;
|
margin-left: 225px;
|
||||||
& .loading-indicator {
|
& .loading-indicator {
|
||||||
height: 46px;
|
height: 46px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.index-toolbar {
|
.index-toolbar {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
.index-toolbar-view {
|
.index-toolbar-view {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
& .control-show {
|
& .control-show {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.index-toolbar-action {
|
.index-toolbar-action {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
@@ -93,162 +93,161 @@
|
|||||||
@index-pane-width: 400px;
|
@index-pane-width: 400px;
|
||||||
|
|
||||||
.index-area {
|
.index-area {
|
||||||
left: -@index-pane-width;
|
left: -@index-pane-width;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&.paned {
|
&.paned {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: @zindex-pane;
|
z-index: @zindex-pane;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
top: 56px;
|
top: 56px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: @index-pane-width;
|
width: @index-pane-width;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding-bottom: 200px;
|
padding-bottom: 200px;
|
||||||
.box-shadow(2px 2px 6px -2px rgba(0, 0, 0, 0.25));
|
.box-shadow(2px 2px 6px -2px rgba(0, 0, 0, 0.25));
|
||||||
.transition(left 0.2s);
|
.transition(left 0.2s);
|
||||||
|
|
||||||
&.showing, .with-pane & {
|
&.showing, .with-pane & {
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
.with-pane & {
|
.with-pane & {
|
||||||
z-index: @zindex-composer - 1;
|
z-index: @zindex-composer - 1;
|
||||||
.transition(none);
|
.transition(none);
|
||||||
}
|
}
|
||||||
& .container {
|
& .container {
|
||||||
width: auto;
|
width: auto;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
& .index-results {
|
& .index-results {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
& .hero, & .index-nav, & .index-toolbar {
|
& .hero, & .index-nav, & .index-toolbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
& .discussions-list > li {
|
& .discussions-list > li {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-left: 65px + 15px;
|
padding-left: 65px + 15px;
|
||||||
padding-right: 65px + 15px;
|
padding-right: 65px + 15px;
|
||||||
&.active {
|
&.active {
|
||||||
background: @fl-secondary-color;
|
background: @fl-secondary-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& .discussion-summary {
|
& .discussion-summary {
|
||||||
& .title {
|
& .title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
& .count strong {
|
& .count strong {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the pane is pinned, move the other page content inwards
|
// When the pane is pinned, move the other page content inwards
|
||||||
.global-main, .global-footer {
|
.global-main, .global-footer {
|
||||||
.with-pane & {
|
.with-pane & {
|
||||||
margin-left: @index-pane-width;
|
margin-left: @index-pane-width;
|
||||||
& .container {
|
& .container {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0 30px;
|
padding: 0 30px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.global-header .container {
|
.global-header .container {
|
||||||
.with-pane & {
|
.with-pane & {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Discussions List
|
// Discussions List
|
||||||
|
|
||||||
.discussions-list {
|
.discussions-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
& > li {
|
& > li {
|
||||||
margin-right: -25px;
|
margin-right: -25px;
|
||||||
padding-right: 65px + 25px;
|
padding-right: 65px + 25px;
|
||||||
& .contextual-controls {
|
& .contextual-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 18px;
|
top: 18px;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
&:hover .contextual-controls, & .contextual-controls.open {
|
&:hover .contextual-controls, & .contextual-controls.open {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.discussion-summary {
|
.discussion-summary {
|
||||||
padding-left: 65px;
|
padding-left: 65px;
|
||||||
padding-right: 65px;
|
padding-right: 65px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
& .author {
|
&, & a {
|
||||||
float: left;
|
color: @fl-body-muted-color;
|
||||||
margin-left: -65px;
|
}
|
||||||
margin-top: 18px;
|
& .author {
|
||||||
}
|
float: left;
|
||||||
& .info {
|
margin-left: -65px;
|
||||||
display: inline-block;
|
margin-top: 18px;
|
||||||
width: 100%;
|
}
|
||||||
margin-right: -65px;
|
& .main {
|
||||||
color: @fl-body-muted-color;
|
display: inline-block;
|
||||||
padding: 20px 0;
|
width: 100%;
|
||||||
|
padding: 20px 0;
|
||||||
|
margin-right: -65px;
|
||||||
|
|
||||||
&:hover, &:active, &.active, &:focus {
|
&.active {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
& .title {
|
}
|
||||||
text-decoration: underline;
|
}
|
||||||
}
|
& .title {
|
||||||
}
|
margin: 0 0 5px;
|
||||||
&.active .title {
|
font-size: 15px;
|
||||||
text-decoration: none;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
}
|
& .info {
|
||||||
& .title {
|
list-style-type: none;
|
||||||
margin: 0 0 5px;
|
padding: 0;
|
||||||
font-size: 15px;
|
margin: 0;
|
||||||
line-height: 1.3;
|
|
||||||
&, & a {
|
|
||||||
font-weight: normal;
|
|
||||||
color: @fl-body-muted-color;
|
|
||||||
}
|
|
||||||
.unread&, .unread& a {
|
|
||||||
font-weight: bold;
|
|
||||||
color: @fl-body-heading-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& .count {
|
|
||||||
float: right;
|
|
||||||
margin-top: 18px;
|
|
||||||
margin-right: -65px;
|
|
||||||
width: 60px;
|
|
||||||
text-align: center;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: @fl-body-muted-color;
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
& strong {
|
& > li {
|
||||||
font-size: 20px;
|
display: inline-block;
|
||||||
display: block;
|
}
|
||||||
font-weight: 300;
|
}
|
||||||
}
|
& .count {
|
||||||
.unread&, .unread& strong {
|
float: right;
|
||||||
color: @fl-body-heading-color;
|
margin-top: 18px;
|
||||||
font-weight: bold;
|
margin-right: -65px;
|
||||||
}
|
width: 60px;
|
||||||
}
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: @fl-body-muted-color;
|
||||||
|
font-size: 11px;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
& strong {
|
||||||
|
font-size: 20px;
|
||||||
|
display: block;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
.unread& {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.unread&, .unread& strong {
|
||||||
|
color: @fl-body-heading-color;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.load-more {
|
.load-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
@@ -1,191 +1,192 @@
|
|||||||
body {
|
body {
|
||||||
background: @fl-body-bg;
|
background: @fl-body-bg;
|
||||||
color: @fl-body-color;
|
color: @fl-body-color;
|
||||||
padding-top: 56px;
|
padding-top: 56px;
|
||||||
}
|
}
|
||||||
.container-narrow {
|
.container-narrow {
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Header
|
// Header
|
||||||
|
|
||||||
.global-header {
|
.global-header {
|
||||||
background: fade(@fl-hdr-bg, 98%);
|
background: fade(@fl-hdr-bg, 98%);
|
||||||
transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
|
transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: @zindex-navbar-fixed;
|
z-index: @zindex-navbar-fixed;
|
||||||
.clearfix();
|
.clearfix();
|
||||||
.transition(box-shadow 0.2s);
|
.transition(box-shadow 0.2s);
|
||||||
|
|
||||||
.scrolled & {
|
.scrolled & {
|
||||||
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
|
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
|
||||||
}
|
}
|
||||||
|
|
||||||
& when (@fl-dark-hdr = true) {
|
& when (@fl-dark-hdr = true) {
|
||||||
&, & .btn-link {
|
&, & .btn-link {
|
||||||
color: @fl-hdr-control-color;
|
color: @fl-hdr-control-color;
|
||||||
}
|
}
|
||||||
& .form-control {
|
& .form-control {
|
||||||
background: @fl-hdr-control-bg;
|
background: @fl-hdr-control-bg;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: @fl-hdr-control-color;
|
color: @fl-hdr-control-color;
|
||||||
.placeholder(@fl-hdr-control-color);
|
.placeholder(@fl-hdr-control-color);
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
background: fadein(@fl-hdr-control-bg, 5%);
|
background: fadein(@fl-hdr-control-bg, 5%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& .search-input:before {
|
& .search-input:before {
|
||||||
color: @fl-hdr-control-color;
|
color: @fl-hdr-control-color;
|
||||||
}
|
}
|
||||||
& .btn-default, & .btn-default:hover {
|
& .btn-default, & .btn-default:hover {
|
||||||
background: @fl-hdr-control-bg;
|
background: @fl-hdr-control-bg;
|
||||||
color: @fl-hdr-control-color;
|
color: @fl-hdr-control-color;
|
||||||
}
|
}
|
||||||
& .btn-default.active, .open > .dropdown-toggle.btn-default {
|
& .btn-default.active, .open > .dropdown-toggle.btn-default {
|
||||||
background: fadein(@fl-hdr-control-bg, 5%);
|
background: fadein(@fl-hdr-control-bg, 5%);
|
||||||
}
|
}
|
||||||
& .btn-naked {
|
& .btn-naked {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.header-controls {
|
.header-controls {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
&, & > li {
|
&, & > li {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.header-primary {
|
.header-primary {
|
||||||
float: left;
|
float: left;
|
||||||
& h1 {
|
& h1 {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.header-title {
|
.header-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 36px;
|
line-height: 36px;
|
||||||
&, & a {
|
&, & a {
|
||||||
color: @fl-hdr-color;
|
color: @fl-hdr-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.header-secondary {
|
.header-secondary {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back button
|
// Back button
|
||||||
// @todo Lots of !importants in here, could we be more specific?
|
// @todo Lots of !importants in here, could we be more specific?
|
||||||
.back-button {
|
.back-button {
|
||||||
float: left;
|
float: left;
|
||||||
margin-right: 25px;
|
margin-right: 25px;
|
||||||
|
|
||||||
& .back {
|
& .back {
|
||||||
z-index: 3 !important; // z-index of an active .btn-group .btn is 2
|
z-index: 3 !important; // z-index of an active .btn-group .btn is 2
|
||||||
border-radius: @border-radius-base !important;
|
border-radius: @border-radius-base !important;
|
||||||
.transition(border-radius 0.2s);
|
.transition(border-radius 0.2s);
|
||||||
}
|
}
|
||||||
& .pin {
|
& .pin {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
margin-left: -36px !important;
|
margin-left: -36px !important;
|
||||||
.transition(~"opacity 0.2s, margin-left 0.2s");
|
.transition(~"opacity 0.2s, margin-left 0.2s");
|
||||||
|
|
||||||
&:not(.active) .fa {
|
&:not(.active) .fa {
|
||||||
.rotate(45deg);
|
.rotate(45deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.active {
|
&.active {
|
||||||
& .back {
|
& .back {
|
||||||
border-radius: @border-radius-base 0 0 @border-radius-base !important;
|
border-radius: @border-radius-base 0 0 @border-radius-base !important;
|
||||||
}
|
}
|
||||||
& .pin {
|
& .pin {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
margin-left: 1px !important;
|
margin-left: 1px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Main
|
// Main
|
||||||
|
|
||||||
.global-main, .paned {
|
.global-main, .paned {
|
||||||
border-top: 1px solid @fl-secondary-color;
|
border-top: 1px solid @fl-secondary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hero
|
// Hero
|
||||||
.hero {
|
.hero {
|
||||||
background: @fl-body-hero-bg;
|
background: @fl-body-hero-bg;
|
||||||
color: @fl-body-hero-color;
|
color: @fl-body-hero-color;
|
||||||
margin-top: -1px;
|
margin-top: -1px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 30px 0;
|
padding: 30px 0;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.hero .close {
|
.hero .close {
|
||||||
float: right;
|
float: right;
|
||||||
margin-top: -10px;
|
margin-top: -10px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.hero h2 {
|
.hero h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
|
||||||
& when (@fl-dark-hdr = true) {
|
& when (@fl-dark-hdr = true) {
|
||||||
color: @fl-body-color;
|
color: @fl-body-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hero p {
|
.hero p {
|
||||||
margin: 10px 0 0;
|
margin: 10px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Footer
|
// Footer
|
||||||
|
|
||||||
.global-footer {
|
.global-footer {
|
||||||
margin: 100px 0 20px;
|
margin: 100px 0 20px;
|
||||||
color: @fl-body-muted-more-color;
|
color: @fl-body-muted-more-color;
|
||||||
.clearfix();
|
.clearfix();
|
||||||
}
|
}
|
||||||
.footer-primary, .footer-secondary {
|
.footer-primary, .footer-secondary {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
||||||
& > li {
|
& > li {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
& a {
|
& a {
|
||||||
color: @fl-body-muted-more-color;
|
color: @fl-body-muted-more-color;
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus {
|
&:focus {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: @link-hover-color;
|
color: @link-hover-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.footer-primary {
|
.footer-primary {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
& > li {
|
& > li {
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.footer-secondary {
|
.footer-secondary {
|
||||||
float: right;
|
float: right;
|
||||||
& > li {
|
& > li {
|
||||||
margin-left: 15px;
|
margin-left: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user