diff --git a/framework/core/ember/app/components/back-button.js b/framework/core/ember/app/components/back-button.js
new file mode 100755
index 000000000..e36306575
--- /dev/null
+++ b/framework/core/ember/app/components/back-button.js
@@ -0,0 +1,26 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+ classNames: ['back-button'],
+ classNameBindings: ['active'],
+ active: Ember.computed.or('target.paneShowing', 'target.panePinned'),
+
+ 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');
+ }
+ }
+
+});
diff --git a/framework/core/ember/app/components/discussions/discussion-listing.js b/framework/core/ember/app/components/discussions/discussion-listing.js
index ff4880908..3dc433d90 100755
--- a/framework/core/ember/app/components/discussions/discussion-listing.js
+++ b/framework/core/ember/app/components/discussions/discussion-listing.js
@@ -2,20 +2,18 @@ import Ember from 'ember';
import TaggedArray from '../../utils/tagged-array';
import ActionButton from '../ui/controls/action-button';
+import SeparatorItem from '../ui/items/separator-item';
export default Ember.Component.extend({
- _init: function() {
- // this.set('controls', Menu.create());
- }.on('init'),
+ terminalPostType: 'last',
+ countType: 'unread',
tagName: 'li',
attributeBindings: ['discussionId:data-id'],
+ classNames: ['discussion-summary'],
classNameBindings: [
- 'discussion.unread:unread',
- 'discussion.sticky:sticky',
- 'discussion.locked:locked',
- 'discussion.following:following',
+ 'discussion.isUnread:unread',
'active'
],
layoutName: 'components/discussions/discussion-listing',
@@ -24,9 +22,19 @@ export default Ember.Component.extend({
return this.get('childViews').anyBy('active');
}.property('childViews.@each.active'),
- discussionId: function() {
- return this.get('discussion.id');
- }.property('discussion.id'),
+ displayUnread: function() {
+ return this.get('countType') == 'unread' && this.get('discussion.isUnread');
+ }.property('countType', '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 [];
@@ -39,115 +47,106 @@ export default Ember.Component.extend({
return [this.get('discussion.lastPost')];
}
}.property('discussion.relevantPosts', 'discussion.startPost', 'discussion.lastPost'),
-
- icon: function() {
- if (this.get('discussion.unread')) return 'circle';
- }.property('discussion.unread'),
-
- iconAction: function() {
- if (this.get('discussion.unread')) return function() {
-
- };
- }.property('discussion.unread'),
-
- categoryClass: function() {
- return 'category-'+this.get('discussion.category').toLowerCase();
- }.property('discussion.category'),
didInsertElement: function() {
- this.$().hide().fadeIn('slow');
+ var $this = this.$().css({opacity: 0});
- this.$().find('.terminal-post a').tooltip();
+ setTimeout(function() {
+ $this.animate({opacity: 1}, 'fast');
+ }, 100);
- var view = this;
- this.$().find('a.info, .terminal-post a').click(function() {
- view.set('controller.paneShowing', false);
- });
+ if (this.get('discussion.isUnread')) {
+ this.$().find('.count').tooltip();
+ }
+
+ // 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.$().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;
- }
+ // $(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();
+ // isMoved = true;
+ // e.preventDefault();
- var diffX = touchesNow.x - touchesStart.x;
- var translate = diffX;
- var actionsRightWidth = 150;
+ // var diffX = touchesNow.x - touchesStart.x;
+ // var translate = diffX;
+ // var actionsRightWidth = 150;
- if (translate < -actionsRightWidth) {
- translate = -actionsRightWidth - Math.pow(-translate - actionsRightWidth, 0.8);
- }
+ // if (translate < -actionsRightWidth) {
+ // translate = -actionsRightWidth - Math.pow(-translate - actionsRightWidth, 0.8);
+ // }
- $(this).css('left', translate);
- });
+ // $(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;
+ // $(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');
- });
- });
+ // 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');
+ // });
+ // });
- var discussion = this.get('discussion');
- // var controls = this.get('controls');
-
- // controls.addItem('sticky', MenuItem.extend({title: 'Sticky', icon: 'thumb-tack', action: 'sticky'}));
- // controls.addItem('lock', MenuItem.extend({title: 'Lock', icon: 'lock', action: 'lock'}));
-
- // controls.addSeparator();
-
- // controls.addItem('delete', MenuItem.extend({title: 'Delete', icon: 'times', className: 'delete', action: function() {
- // // this.get('controller').send('delete', discussion);
- // var discussion = view.$().slideUp().find('.discussion');
- // discussion.css('position', 'relative').animate({left: -discussion.width()});
- // }}));
+ this.set('controls', TaggedArray.create());
},
+ populateControlsDefault: function(controls) {
+ controls.pushObjectWithTag(ActionButton.create({
+ label: 'Delete',
+ icon: 'times',
+ className: 'delete'
+ }), 'delete');
+ }.on('populateControls'),
+
actions: {
- icon: function() {
- this.get('iconAction')();
+ populateControls: function() {
+ if ( ! this.get('controls.length')) {
+ this.trigger('populateControls', this.get('controls'));
+ }
}
}
diff --git a/framework/core/ember/app/components/discussions/post-content-comment.js b/framework/core/ember/app/components/discussions/post-content-comment.js
new file mode 100644
index 000000000..7ed7d64b6
--- /dev/null
+++ b/framework/core/ember/app/components/discussions/post-content-comment.js
@@ -0,0 +1,8 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+ tagName: 'article',
+ layoutName: 'components/discussions/post-content-comment',
+
+ editDescription: ''
+});
diff --git a/framework/core/ember/app/components/discussions/post-full.js b/framework/core/ember/app/components/discussions/post-wrapper.js
similarity index 50%
rename from framework/core/ember/app/components/discussions/post-full.js
rename to framework/core/ember/app/components/discussions/post-wrapper.js
index a42a018d4..09e63d298 100644
--- a/framework/core/ember/app/components/discussions/post-full.js
+++ b/framework/core/ember/app/components/discussions/post-wrapper.js
@@ -5,10 +5,12 @@ import ActionButton from '../ui/controls/action-button';
export default Ember.Component.extend({
tagName: 'article',
- layoutName: 'components/discussions/post-full',
+ layoutName: 'components/discussions/post-wrapper',
// controls: null,
+ post: Ember.computed.alias('content'),
+
contentComponent: function() {
return 'discussions/post-content-'+this.get('post.type');
}.property('post.type'),
@@ -16,26 +18,31 @@ export default Ember.Component.extend({
classNames: ['post'],
classNameBindings: ['post.deleted', 'post.edited'],
- construct: function() {
- // this.set('controls', Menu.create());
+ // construct: function() {
+ // // this.set('controls', Menu.create());
- // var post = this.get('post');
+ // // var post = this.get('post');
- // if (post.get('deleted')) {
- // this.addControl('restore', 'Restore', 'reply', 'canEdit');
- // this.addControl('delete', 'Delete', 'times', 'canDelete');
- // } else {
- // if (post.get('type') == 'comment') {
- // this.addControl('edit', 'Edit', 'pencil', 'canEdit');
- // this.addControl('hide', 'Delete', 'times', 'canEdit');
- // } else {
- // this.addControl('delete', 'Delete', 'times', 'canDelete');
- // }
- // }
- }.on('init'),
+ // // if (post.get('deleted')) {
+ // // this.addControl('restore', 'Restore', 'reply', 'canEdit');
+ // // this.addControl('delete', 'Delete', 'times', 'canDelete');
+ // // } else {
+ // // if (post.get('type') == 'comment') {
+ // // this.addControl('edit', 'Edit', 'pencil', 'canEdit');
+ // // this.addControl('hide', 'Delete', 'times', 'canEdit');
+ // // } else {
+ // // this.addControl('delete', 'Delete', 'times', 'canDelete');
+ // // }
+ // // }
+ // }.on('init'),
didInsertElement: function() {
- this.$().hide().fadeIn('slow');
+ var $this = this.$();
+ $this.css({opacity: 0});
+
+ setTimeout(function() {
+ $this.animate({opacity: 1}, 'fast');
+ }, 100);
},
addControl: function(tag, title, icon, permissionAttribute) {
diff --git a/framework/core/ember/app/components/discussions/stream-content.js b/framework/core/ember/app/components/discussions/stream-content.js
new file mode 100644
index 000000000..29d0b5590
--- /dev/null
+++ b/framework/core/ember/app/components/discussions/stream-content.js
@@ -0,0 +1,258 @@
+import Ember from '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'),
+
+ 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) {
+ // 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);
+ },
+
+ loadedNumber: function(number) {
+ // 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).done(function() {
+ view.set('paused', false);
+ });
+ });
+ },
+
+ loadingIndex: function(index) {
+ // 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);
+ },
+
+ loadedIndex: function(index) {
+ // 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).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) {
+ // 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);
+ },
+
+ // Scroll down to a certain post by index (or the gap the post is in.)
+ jumpToIndex: function(index) {
+ var $item = this.findNearestToIndex(index);
+ return this.scrollToItem($item);
+ },
+
+ scrollToItem: function($item) {
+ var $container = $('html, body');
+ if ($item.length) {
+ var marginTop = this.getMarginTop();
+ var scrollTop = $item.is(':first-child') ? 0 : $item.offset().top - marginTop;
+ if (scrollTop != $(document).scrollTop()) {
+ $container.stop(true).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) {
+ 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);
+
+ // 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);
+ });
+ },
+
+ goToIndex: function(index) {
+ // 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);
+
+ // 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).then(function() {
+ controller.trigger('loadedIndex', index);
+ });
+ },
+
+ loadRange: function(start, end, backwards) {
+ this.get('stream').loadRange(start, end, backwards);
+ }
+ }
+});
\ No newline at end of file
diff --git a/framework/core/ember/app/components/discussions/stream-item.js b/framework/core/ember/app/components/discussions/stream-item.js
index 6ad077932..f51853b0a 100644
--- a/framework/core/ember/app/components/discussions/stream-item.js
+++ b/framework/core/ember/app/components/discussions/stream-item.js
@@ -14,34 +14,17 @@ export default Ember.Component.extend({
'number:data-number'
],
- start: function() {
- return this.get('item.indexStart');
- }.property('item.indexStart'),
-
- end: function() {
- return this.get('item.indexEnd');
- }.property('item.indexEnd'),
+ start: Ember.computed.alias('item.indexStart'),
+ end: Ember.computed.alias('item.indexEnd'),
+ time: Ember.computed.alias('item.content.time'),
+ number: Ember.computed.alias('item.content.number'),
+ loading: Ember.computed.alias('item.loading'),
+ direction: Ember.computed.alias('item.direction'),
count: function() {
return this.get('end') - this.get('start') + 1;
}.property('start', 'end'),
- time: function() {
- return this.get('item.post.time');
- }.property('item.post.time'),
-
- number: function() {
- return this.get('item.post.number');
- }.property('item.post.number'),
-
- loading: function() {
- return this.get('item.loading');
- }.property('item.loading'),
-
- direction: function() {
- return this.get('item.direction');
- }.property(),
-
loadingChanged: function() {
this.rerender();
}.observes('loading'),
@@ -73,8 +56,10 @@ export default Ember.Component.extend({
} else {
var self = this;
this.$().hover(function(e) {
- var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
- self.set('direction', up ? 'up' : 'down');
+ if (! self.get('loading')) {
+ var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
+ self.set('direction', up ? 'up' : 'down');
+ }
});
}
},
@@ -97,7 +82,7 @@ export default Ember.Component.extend({
// 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('controller.postStream').one('postsLoaded', function() {
+ this.get('stream').one('postsLoaded', function() {
if (anchor.length) {
var scrollOffset = anchor.offset().top - $(document).scrollTop();
}
diff --git a/framework/core/ember/app/components/discussions/stream-scrollbar.js b/framework/core/ember/app/components/discussions/stream-scrollbar.js
deleted file mode 100644
index dae130106..000000000
--- a/framework/core/ember/app/components/discussions/stream-scrollbar.js
+++ /dev/null
@@ -1,275 +0,0 @@
-import Ember from 'ember';
-
-import Scrollbar from '../../utils/scrollbar';
-import PostStreamMixin from '../../mixins/post-stream';
-
-export default Ember.View.extend(PostStreamMixin, {
-
- layoutName: 'components/discussions/stream-scrollbar',
- classNames: ['scrubber', 'discussion-scrubber'],
-
- // An object which represents/ecapsulates the scrollbar.
- scrollbar: null,
-
- // Right after the controller finished loading a discussion, we want to
- // trigger a scroll event on the window so the interface is kept up-to-date.
- loadedChanged: function() {
- this.scrollbar.setDisabled(! this.get('controller.loaded'));
- }.observes('controller.loaded'),
-
- countChanged: function() {
- this.scrollbar.setCount(this.get('controller.postStream.count'));
- }.observes('controller.postStream.count'),
-
- windowWasResized: function(event) {
- var view = event.data.view;
- // view.scrollbar.$.height($('#sidebar-content').height() + $('#sidebar-content').offset().top - view.scrollbar.$.offset().top - 80);
- view.scrollbar.update();
- },
-
- windowWasScrolled: function(event) {
- var view = event.data.view,
- $window = $(window);
-
- if (! view.get('controller.loaded') || $window.data('disableScrollHandler')) {
- return;
- }
-
- var scrollTop = $window.scrollTop(),
- windowHeight = $window.height();
-
- // 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 index = $('.posts .item:first').data('end');
- var visiblePosts = 0;
- var period = '';
-
- var first = $('.posts .item[data-start=0]');
- var offsetTop = first.length ? first.offset().top : 0;
-
- // 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.
- // @todo cache item top positions to speed this up?
- $('.posts .item').each(function(k) {
- var $this = $(this),
- top = $this.offset().top - offsetTop,
- height = $this.outerHeight();
-
- // 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) {
- 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 visiblePosts
- // 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) {
- visiblePosts = (top + height - scrollTop) / height;
- index = parseFloat($this.data('end')) + 1 - visiblePosts;
- }
-
- // If the top half of this item is visible at the bottom of the
- // viewport, then add the visible proportion to the visiblePosts
- // counter.
- else if (top + height >= scrollTop + windowHeight) {
- visiblePosts += (scrollTop + windowHeight - top) / height;
- }
-
- // If the whole item is visible in the viewport, then increment the
- // visiblePosts counter.
- else {
- visiblePosts++;
- }
-
- // 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');
- }
- });
-
- // Now that we've looped through all of the items and have worked out
- // the scrollbar's current index and the number of posts visible in the
- // viewport, we can update the scrollbar.
- view.scrollbar.setIndex(index);
- view.scrollbar.setVisible(visiblePosts);
- view.scrollbar.update();
-
- view.scrollbar.$.find('.index').text(Math.ceil(index + visiblePosts));
- view.scrollbar.$.find('.description').text(moment(period).format('MMMM YYYY'));
- },
-
- mouseWasMoved: function(event) {
- var view = event.data.view;
-
- if ( ! event.data.handle) {
- return;
- }
-
- var offsetPixels = event.clientY - event.data.mouseStart;
- var offsetPercent = offsetPixels / view.scrollbar.$.outerHeight() * 100;
-
- var offsetIndex = offsetPercent / view.scrollbar.percentPerPost().index;
- var newIndex = Math.max(0, Math.min(event.data.indexStart + offsetIndex, view.scrollbar.count - 1));
-
- view.scrollToIndex(newIndex);
- },
-
- mouseWasReleased: function(event) {
- var view = event.data.view;
-
- if (! event.data.handle) {
- return;
- }
-
- event.data.mouseStart = 0;
- event.data.indexStart = 0;
- event.data.handle = null;
-
- $(window).data('disableScrollHandler', false);
-
- view.get('controller').send('jumpToIndex', Math.floor(view.scrollbar.index));
-
- $(window).scroll();
- $('body').css('cursor', '');
- },
-
- didInsertElement: function() {
- var view = this;
-
- // Set up scrollbar object
- this.scrollbar = new Scrollbar($('.discussion-scrubber .scrollbar'));
- this.scrollbar.setDisabled(true);
- this.countChanged();
- this.loadedChanged();
-
- // 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);
-
- this.get('controller').on('loadingIndex', this, this.loadingIndex);
-
- // Now we want to make the scrollbar handle draggable. Let's start by
- // preventing default browser events from messing things up.
- this.scrollbar.$
- .css('user-select', 'none')
- .bind('dragstart mousedown', function(e) {
- e.preventDefault();
- });
-
- // When the mouse is pressed on the scrollbar handle, we need to capture
- // some information about the current position.
- var scrollData = {
- view: this,
- mouseStart: 0,
- indexStart: 0,
- handle: null
- };
-
- this.scrollbar.$.find('.scrollbar-slider').css('cursor', 'move').mousedown(function(e) {
- scrollData.mouseStart = e.clientY;
- scrollData.indexStart = view.scrollbar.index;
- scrollData.handle = $(this);
- $(window).data('disableScrollHandler', true);
- $('body').css('cursor', 'move');
- });
-
- // When the mouse moves,
- $(document)
- .on('mousemove', scrollData, this.mouseWasMoved)
- .on('mouseup', scrollData, this.mouseWasReleased);
-
- // When any part of the whole scrollbar is clicked, we want to jump to
- // that position.
- this.scrollbar.$.click(function(e) {
-
- // Calculate the index which we want to jump to.
- // @todo document how this complexity works.
- var offsetPixels = e.clientY - view.scrollbar.$.offset().top + $('body').scrollTop();
- var offsetPercent = offsetPixels / view.scrollbar.$.outerHeight() * 100;
-
- var handleHeight = parseFloat(view.scrollbar.$.find('.scrollbar-slider')[0].style.height);
-
- var offsetIndex = (offsetPercent - handleHeight / 2) / view.scrollbar.percentPerPost().index;
- var newIndex = Math.max(0, Math.min(view.scrollbar.count - 1, offsetIndex));
-
- view.get('controller').send('jumpToIndex', Math.floor(newIndex));
- })
-
- // Exempt the scrollbar handle from this 'jump to' click event.
- this.scrollbar.$.find('.scrollbar-slider').click(function(e) {
- e.stopPropagation();
- });
-
- },
-
- actions: {
- firstPost: function() {
- this.get('controller').send('jumpToIndex', 0);
- },
-
- lastPost: function() {
- this.get('controller').send('jumpToIndex', this.scrollbar.count - 1);
- }
- },
-
- loadingIndex: function(index) {
- this.scrollToIndex(index, true);
- },
-
- // Instantly scroll to a certain index in the discussion. The index doesn't
- // have to be an integer; any fraction of a post will be scrolled to.
- scrollToIndex: function(index, animate) {
- index = Math.max(0, Math.min(index, this.scrollbar.count - 1));
- var indexFloor = Math.floor(index);
-
- // Find
- var nearestItem = this.findNearestToIndex(indexFloor);
- var first = $('.posts .item[data-start=0]');
- var offsetTop = first.length ? first.offset().top : 0;
-
- var pos = nearestItem.offset().top - offsetTop;
- if (! nearestItem.is('.gap')) {
- pos += nearestItem.outerHeight() * (index - indexFloor);
- } else {
- nearestItem.addClass('active');
- }
-
- $('.posts .item.gap').not(nearestItem).removeClass('active');
-
- if (animate) {
- // $('html, body').animate({scrollTop: pos});
- } else {
- $('html, body').scrollTop(pos);
- }
- this.scrollbar.setIndex(index);
- this.scrollbar.update(animate);
- },
-
- willDestroyElement: function() {
- $(window)
- .off('resize', this.windowWasResized)
- .off('scroll', this.windowWasScrolled);
-
- $(document)
- .off('mousemove', this.mouseWasMoved)
- .off('mouseup', this.mouseWasReleased);
-
- this.get('controller').off('loadingIndex', this, this.loadingIndex);
- }
-});
diff --git a/framework/core/ember/app/components/discussions/stream-scrubber.js b/framework/core/ember/app/components/discussions/stream-scrubber.js
new file mode 100644
index 000000000..91e685918
--- /dev/null
+++ b/framework/core/ember/app/components/discussions/stream-scrubber.js
@@ -0,0 +1,389 @@
+import Ember from '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;
+ view.set('streamContent.paused', false);
+
+ // 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).gap) {
+ view.get('streamContent').send('goToIndex', intIndex);
+ }
+ },
+
+ // 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() {
+ if (this.get('streamContent.active')) {
+ this.update();
+ this.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(k) {
+ 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('goToIndex', 0);
+ },
+
+ last: function() {
+ this.get('streamContent').send('goToIndex', this.get('count') - 1);
+ }
+ }
+});
diff --git a/framework/core/ember/app/components/ui/controls/action-button.js b/framework/core/ember/app/components/ui/controls/action-button.js
index f24158268..d27e2032f 100644
--- a/framework/core/ember/app/components/ui/controls/action-button.js
+++ b/framework/core/ember/app/components/ui/controls/action-button.js
@@ -1,24 +1,28 @@
import Ember from 'ember';
export default Ember.Component.extend({
- title: '',
+ label: '',
icon: '',
className: '',
action: null,
divider: false,
active: false,
- classNames: ['btn', 'btn-default'],
+ classNames: [],
tagName: 'a',
- attributeBindings: ['href'],
+ attributeBindings: ['href', 'title'],
classNameBindings: ['className'],
href: '#',
- layout: Ember.Handlebars.compile('{{#if icon}}{{fa-icon icon class="fa-fw"}} {{/if}}{{title}}'),
+ layout: Ember.Handlebars.compile('{{#if icon}}{{fa-icon icon class="fa-fw icon-glyph"}} {{/if}}{{label}}'),
click: function(e) {
e.preventDefault();
- // this.sendAction('action');
- this.get('action')();
+ var action = this.get('action');
+ if (typeof action == 'string') {
+ this.sendAction('action');
+ } else if (typeof action == 'function') {
+ action();
+ }
}
});
\ No newline at end of file
diff --git a/framework/core/ember/app/components/ui/controls/dropdown-button.js b/framework/core/ember/app/components/ui/controls/dropdown-button.js
index ff74c9287..e0748429e 100644
--- a/framework/core/ember/app/components/ui/controls/dropdown-button.js
+++ b/framework/core/ember/app/components/ui/controls/dropdown-button.js
@@ -4,12 +4,12 @@ export default Ember.Component.extend({
items: null, // TaggedArray
layoutName: 'components/ui/controls/dropdown-button',
classNames: ['dropdown', 'btn-group'],
- classNameBindings: ['itemCountClass'],
+ classNameBindings: ['itemCountClass', 'class'],
title: 'Controls',
icon: 'ellipsis-v',
- buttonClass: 'btn-default',
- menuClass: 'pull-right',
+ buttonClass: 'btn btn-default',
+ menuClass: '',
dropdownMenuClass: function() {
return 'dropdown-menu '+this.get('menuClass');
@@ -17,5 +17,11 @@ export default Ember.Component.extend({
itemCountClass: function() {
return 'item-count-'+this.get('items.length');
- }.property('items.length')
+ }.property('items.length'),
+
+ actions: {
+ buttonClick: function() {
+ this.sendAction('buttonClick');
+ }
+ }
});
diff --git a/framework/core/ember/app/components/ui/controls/dropdown-select.js b/framework/core/ember/app/components/ui/controls/dropdown-select.js
index 367cdccbf..eead02e6c 100644
--- a/framework/core/ember/app/components/ui/controls/dropdown-select.js
+++ b/framework/core/ember/app/components/ui/controls/dropdown-select.js
@@ -4,10 +4,10 @@ export default Ember.Component.extend({
items: [],
layoutName: 'components/ui/controls/dropdown-select',
classNames: ['dropdown', 'dropdown-select', 'btn-group'],
- classNameBindings: ['itemCountClass'],
+ classNameBindings: ['itemCountClass', 'class'],
- buttonClass: 'btn-default',
- menuClass: 'pull-right',
+ buttonClass: 'btn btn-default',
+ menuClass: '',
icon: 'ellipsis-v',
mainButtonClass: function() {
@@ -25,9 +25,4 @@ export default Ember.Component.extend({
activeItem: function() {
return this.get('menu.childViews').findBy('active');
}.property('menu.childViews.@each.active')
-
-}).reopenClass({
- createWithItems: function(items) {
- return this.create({items: items});
- }
-});
+});
\ No newline at end of file
diff --git a/framework/core/ember/app/components/ui/controls/dropdown-split.js b/framework/core/ember/app/components/ui/controls/dropdown-split.js
index 099479e69..037165ca8 100644
--- a/framework/core/ember/app/components/ui/controls/dropdown-split.js
+++ b/framework/core/ember/app/components/ui/controls/dropdown-split.js
@@ -5,6 +5,7 @@ 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');
diff --git a/framework/core/ember/app/components/ui/controls/loading-indicator.js b/framework/core/ember/app/components/ui/controls/loading-indicator.js
index fc3c97e22..e908f30f0 100644
--- a/framework/core/ember/app/components/ui/controls/loading-indicator.js
+++ b/framework/core/ember/app/components/ui/controls/loading-indicator.js
@@ -1,9 +1,10 @@
import Ember from 'ember';
export default Ember.Component.extend({
- classNames: ['loading'],
+ classNames: ['loading-indicator'],
layout: Ember.Handlebars.compile(' '),
+ size: 'small',
didInsertElement: function() {
this.$().spin(this.get('size'));
diff --git a/framework/core/ember/app/components/ui/controls/search-input.js b/framework/core/ember/app/components/ui/controls/search-input.js
index dea3d1987..5edcbb21d 100644
--- a/framework/core/ember/app/components/ui/controls/search-input.js
+++ b/framework/core/ember/app/components/ui/controls/search-input.js
@@ -4,6 +4,8 @@ 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) {
@@ -21,7 +23,7 @@ export default Ember.Component.extend({
clear: function() {
this.set('value', '');
- this.sendAction('action', '');
+ this.send('search');
this.$().find('input').focus();
},
@@ -32,7 +34,7 @@ export default Ember.Component.extend({
actions: {
search: function() {
- this.sendAction('action', this.get('value'));
+ this.get('action')(this.get('value'));
}
}
});
diff --git a/framework/core/ember/app/components/ui/controls/select-input.js b/framework/core/ember/app/components/ui/controls/select-input.js
index 18c797501..52381dacb 100644
--- a/framework/core/ember/app/components/ui/controls/select-input.js
+++ b/framework/core/ember/app/components/ui/controls/select-input.js
@@ -2,6 +2,8 @@ import Ember from 'ember';
export default Ember.View.extend({
tagName: 'span',
- classNames: ['select'],
- layout: Ember.Handlebars.compile('{{view "select" content=view.content optionValuePath=view.optionValuePath optionLabelPath=view.optionLabelPath value=view.value}} {{fa-icon "sort"}}')
+ 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"}}')
});
diff --git a/framework/core/ember/app/components/ui/items/nav-item.js b/framework/core/ember/app/components/ui/items/nav-item.js
index 76d585df9..432ddbfb7 100644
--- a/framework/core/ember/app/components/ui/items/nav-item.js
+++ b/framework/core/ember/app/components/ui/items/nav-item.js
@@ -2,7 +2,7 @@ import Ember from 'ember';
export default Ember.Component.extend({
icon: '',
- title: '',
+ label: '',
action: null,
badge: '',
@@ -22,7 +22,7 @@ export default Ember.Component.extend({
// },
layout: function() {
- return Ember.Handlebars.compile('{{#link-to '+this.get('linkTo')+'}}'+this.get('iconTemplate')+' {{title}} {{badge}}{{/link-to}}');
+ return Ember.Handlebars.compile('{{#link-to '+this.get('linkTo')+'}}'+this.get('iconTemplate')+' {{label}} {{badge}}{{/link-to}}');
}.property('linkTo', 'iconTemplate'),
iconTemplate: function() {
diff --git a/framework/core/ember/app/components/welcome-hero.js b/framework/core/ember/app/components/welcome-hero.js
new file mode 100644
index 000000000..d9170f0fa
--- /dev/null
+++ b/framework/core/ember/app/components/welcome-hero.js
@@ -0,0 +1,15 @@
+import Ember from 'ember';
+
+export default Ember.Component.extend({
+
+ tagName: 'header',
+ classNames: ['hero', 'welcome-hero'],
+
+ didInsertElement: function() {
+ var hero = this.$();
+ hero.find('.close').click(function() {
+ hero.slideUp();
+ });
+ }
+
+});
\ No newline at end of file
diff --git a/framework/core/ember/app/controllers/application.js b/framework/core/ember/app/controllers/application.js
index c5b925c80..3752856ca 100644
--- a/framework/core/ember/app/controllers/application.js
+++ b/framework/core/ember/app/controllers/application.js
@@ -1,48 +1,35 @@
import Ember from 'ember';
-// import NotificationMessage from '../models/notification-message';
-
export default Ember.Controller.extend({
- needs: ['discussions'],
-
// The title of the forum.
// TODO: Preload this value in the index.html payload from Laravel config.
forumTitle: 'Ninetech Support Forum',
- // forumTitle: ' TV Addicts',
- // forumTitle: '
',
- // forumTitle: ' Med Students Forum',
- pageTitle: '',
- documentTitle: function() {
- return this.get('pageTitle') || this.get('forumTitle');
- }.property('pageTitle', 'forumTitle'),
- _updateTitle: function() {
+ // The title of the current page. This should be set as appropriate in
+ // controllers/views.
+ pageTitle: '',
+
+ // When either the forum title or the page title changes, we want to
+ // refresh the document's title.
+ updateTitle: function() {
var parts = [this.get('forumTitle')];
var pageTitle = this.get('pageTitle');
- if (pageTitle) parts.unshift(pageTitle);
+ if (pageTitle) {
+ parts.unshift(pageTitle);
+ }
document.title = parts.join(' - ');
}.observes('pageTitle', 'forumTitle'),
+ // Whether or not a pane is currently pinned to the side of the interface.
+ panePinned: false,
+
searchQuery: '',
searchActive: false,
- showDiscussionStream: false,
-
- // notificationMessage: NotificationMessage.create({text: 'Sorry, you do not have permission to do that!', class: 'message-warning'}), // currently displaying notification message object
-
- currentUser: null,
-
actions: {
-
- hideMessage: function() {
- this.set('notificationMessage', null);
- },
-
search: function(query) {
- this.transitionToRoute('discussions', {queryParams: {searchQuery: query, sort: query ? 'relevance' : 'recent'}});
- },
-
+ this.transitionToRoute('index', {queryParams: {searchQuery: query, sort: query ? 'relevance' : 'recent'}});
+ }
}
-
});
diff --git a/framework/core/ember/app/controllers/composer.js b/framework/core/ember/app/controllers/composer.js
index 199a258fa..ed8fb90ae 100644
--- a/framework/core/ember/app/controllers/composer.js
+++ b/framework/core/ember/app/controllers/composer.js
@@ -2,7 +2,9 @@ import Ember from 'ember';
export default Ember.Controller.extend({
- needs: ['discussions'],
+ needs: ['index'],
+
+ user: Ember.Object.create({avatarNumber: 1}),
showing: false,
diff --git a/framework/core/ember/app/controllers/discussion.js b/framework/core/ember/app/controllers/discussion.js
index 2558c1864..dc38ff961 100644
--- a/framework/core/ember/app/controllers/discussion.js
+++ b/framework/core/ember/app/controllers/discussion.js
@@ -1,6 +1,6 @@
import Ember from 'ember';
-import Stream from '../models/stream';
+import PostStream from '../models/post-stream';
export default Ember.ObjectController.extend(Ember.Evented, {
@@ -19,7 +19,7 @@ export default Ember.ObjectController.extend(Ember.Evented, {
// Set up the post stream object. It needs to know about the discussion
// its representing the posts for, and we also need to inject the Ember
// data store.
- var stream = Stream.create();
+ var stream = PostStream.create();
stream.set('discussion', discussion);
stream.set('store', this.get('store'));
this.set('stream', stream);
@@ -30,74 +30,25 @@ export default Ember.ObjectController.extend(Ember.Evented, {
var promise = discussion.get('posts') ? Ember.RSVP.resolve(discussion) : discussion.reload();
// When we know we have the post IDs, we can set up the post stream with
- // them. Then we're ready to load some posts!
+ // them. Then the view will trigger the stream to load as it sees fit.
var controller = this;
promise.then(function(discussion) {
stream.setup(discussion.get('postIds'));
controller.set('loaded', true);
- controller.send('jumpToNumber', controller.get('start'));
});
},
actions: {
-
reply: function() {
this.set('controllers.composer.showing', true);
this.set('controllers.composer.title', 'Replying to '+this.get('model.title')+'');
},
- jumpToNumber: function(number) {
- // In some instances, we might be given a placeholder start index
- // value. We need to convert this into a numerical value.
- switch (number) {
- case 'last':
- number = this.get('model.lastPostNumber');
- break;
-
- case 'unread':
- number = this.get('model.readNumber') + 1;
- break;
- }
-
- number = Math.max(number, 1);
-
- // Let's start by telling our listeners that we're going to load
- // posts near this number. The discussion view will listen and
- // consequently scroll down to the appropriate position in the
- // discussion.
- this.trigger('loadingNumber', number);
-
- // 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, the view will scroll down to the appropriate post.
- var controller = this;
- this.get('stream').loadNearNumber(number).then(function() {
- Ember.run.scheduleOnce('afterRender', function() {
- controller.trigger('loadedNumber', number);
- });
- });
- },
-
- jumpToIndex: function(index) {
- // Let's start by telling our listeners that we're going to load
- // posts at this index. The discussion view will listen and
- // consequently scroll down to the appropriate position in the
- // discussion.
- this.trigger('loadingIndex', index);
-
- // Now we have to actually make sure the posts around this index are
- // loaded. We will tell our listeners when they are. Again, the view
- // will scroll down to the appropriate post.
- var controller = this;
- this.get('stream').loadNearIndex(index).then(function() {
- Ember.run.scheduleOnce('afterRender', function() {
- controller.trigger('loadedIndex', index);
- });
- });
- },
-
- loadRange: function(start, end, backwards) {
- this.get('stream').loadRange(start, end, backwards);
+ // 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);
}
}
});
diff --git a/framework/core/ember/app/controllers/discussions.js b/framework/core/ember/app/controllers/index.js
similarity index 67%
rename from framework/core/ember/app/controllers/discussions.js
rename to framework/core/ember/app/controllers/index.js
index 1f8da14b0..c988f69ab 100644
--- a/framework/core/ember/app/controllers/discussions.js
+++ b/framework/core/ember/app/controllers/index.js
@@ -2,44 +2,16 @@ import Ember from 'ember';
import DiscussionResult from '../models/discussion-result';
import PostResult from '../models/post-result';
+import PaneableMixin from '../mixins/paneable';
-export default Ember.ArrayController.extend(Ember.Evented, {
+export default Ember.ArrayController.extend(Ember.Evented, PaneableMixin, {
needs: ['application', 'composer'],
- paned: false,
- paneShowing: false,
- paneTimeout: null,
- panePinned: false,
-
- current: null,
-
- index: function() {
- var index = '?';
- var id = this.get('current.id');
- this.get('model').some(function(result, i) {
- if (result.get('id') == id) {
- index = i + 1;
- return true;
- }
- });
- return index;
- }.property('current', 'model.@each'),
-
count: function() {
return this.get('model.length');
}.property('model.@each'),
- previous: function() {
- var result = this.get('model').objectAt(this.get('index') - 2);
- return result && result.get('content');
- }.property('index'),
-
- next: function() {
- var result = this.get('model').objectAt(this.get('index'));
- return result && result.get('content');
- }.property('index'),
-
queryParams: ['sort', 'show', {searchQuery: 'q'}, 'filter'],
sort: 'recent',
show: 'discussions',
@@ -55,8 +27,12 @@ export default Ember.ArrayController.extend(Ember.Evented, {
{sort: 'oldest', label: 'Oldest'},
],
- displayStartUsers: function() {
- return ['newest', 'oldest'].indexOf(this.get('sort')) != -1;
+ terminalPostType: function() {
+ return ['newest', 'oldest'].indexOf(this.get('sort')) != -1 ? 'start' : 'last';
+ }.property('sort'),
+
+ countType: function() {
+ return this.get('sort') == 'replies' ? 'replies' : 'unread';
}.property('sort'),
discussionsCount: function() {
@@ -80,10 +56,11 @@ export default Ember.ArrayController.extend(Ember.Evented, {
var show = this.get('show');
var searchQuery = this.get('searchQuery');
- if (sort == 'newest') sort = 'created';
- else if (sort == 'oldest') {
+ if (sort == 'newest') {
+ sort = 'created';
+ order = 'desc';
+ } else if (sort == 'oldest') {
sort = 'created';
- order = 'asc';
}
else if (sort == 'recent') {
sort = '';
@@ -124,37 +101,24 @@ export default Ember.ArrayController.extend(Ember.Evented, {
},
actions: {
- showDiscussionPane: function() {
- this.set('paneShowing', true);
- },
-
- hideDiscussionPane: function() {
- this.set('paneShowing', false);
- },
-
- togglePinned: function() {
- this.set('panePinned', ! this.get('panePinned'));
- },
-
loadMore: function() {
var self = this;
this.set('start', this.get('length'));
- this.set('loadingMore', true);
+ this.set('resultsLoading', true);
this.getResults(this.get('start')).then(function(results) {
self.get('model').addObjects(results);
self.set('meta', results.get('meta'));
- // self.set('moreResults', !! results.get('meta.moreUrl'));
- self.set('loadingMore', false);
+ self.set('resultsLoading', false);
});
},
- delete: function(discussion) {
- alert('are you sure you want to delete discusn: '+discussion.get('title'));
+ transitionFromBackButton: function() {
+ this.transitionToRoute('index');
}
},
- queryDidChange: function(q) {
+ searchQueryDidChange: function(q) {
this.get('controllers.application').set('searchQuery', this.get('searchQuery'));
this.get('controllers.application').set('searchActive', !! this.get('searchQuery'));
diff --git a/framework/core/ember/app/controllers/discussions/index.js b/framework/core/ember/app/controllers/index/index.js
similarity index 66%
rename from framework/core/ember/app/controllers/discussions/index.js
rename to framework/core/ember/app/controllers/index/index.js
index 11f03b553..e2128002e 100644
--- a/framework/core/ember/app/controllers/discussions/index.js
+++ b/framework/core/ember/app/controllers/index/index.js
@@ -1,5 +1,5 @@
import Ember from 'ember';
export default Ember.ArrayController.extend({
- needs: ['application', 'composer']
+ needs: ['application']
});
diff --git a/framework/core/ember/app/helpers/fa-icon.js b/framework/core/ember/app/helpers/fa-icon.js
index b1b464acc..6c5375e9d 100644
--- a/framework/core/ember/app/helpers/fa-icon.js
+++ b/framework/core/ember/app/helpers/fa-icon.js
@@ -1,6 +1,6 @@
import Ember from 'ember';
export default Ember.Handlebars.makeBoundHelper(function(icon, options) {
- return new Handlebars.SafeString('');
+ return new Handlebars.SafeString('');
});
diff --git a/framework/core/ember/app/mixins/paneable.js b/framework/core/ember/app/mixins/paneable.js
new file mode 100644
index 000000000..8d1dc076d
--- /dev/null
+++ b/framework/core/ember/app/mixins/paneable.js
@@ -0,0 +1,47 @@
+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
+// from the side of the screen. This is useful, for instance, when you have
+// nested 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({
+ needs: ['application'],
+
+ // Whether or not the "paneable" interface element is paned.
+ paned: false,
+
+ // Whether or not the pane should be visible on screen.
+ paneShowing: false,
+ paneHideTimeout: null,
+
+ // Whether or not the pane is always visible on screen, even when the
+ // mouse is taken away.
+ panePinned: false,
+
+ actions: {
+ showPane: function() {
+ if (this.get('paned')) {
+ clearTimeout(this.get('paneHideTimeout'));
+ this.set('paneShowing', true);
+ }
+ },
+
+ hidePane: function(delay) {
+ var controller = this;
+ controller.set('paneHideTimeout', setTimeout(function() {
+ controller.set('paneShowing', false);
+ }, delay || 250));
+ },
+
+ togglePinned: function() {
+ this.toggleProperty('panePinned');
+ }
+ },
+
+ // Tell the application controller when we pin/unpin the pane so that
+ // other parts of the interface can respond appropriately.
+ panePinnedChanged: function() {
+ this.set('controllers.application.panePinned', this.get('paned') && this.get('panePinned'));
+ }.observes('paned', 'panePinned')
+});
\ No newline at end of file
diff --git a/framework/core/ember/app/mixins/post-stream.js b/framework/core/ember/app/mixins/post-stream.js
deleted file mode 100644
index bce952d97..000000000
--- a/framework/core/ember/app/mixins/post-stream.js
+++ /dev/null
@@ -1,33 +0,0 @@
-export default Ember.Mixin.create({
- // 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 container the requested post.
- findNearestToNumber: function(number) {
- var nearestItem = $();
- $('.posts .item').each(function() {
- var $this = $(this),
- thisNumber = $this.data('number');
- if (thisNumber > number) {
- return false;
- }
- nearestItem = $this;
- });
- return nearestItem;
- },
-
- findNearestToIndex: function(index) {
- var nearestItem = $('.posts .item[data-start='+index+'][data-end='+index+']');
-
- if (! nearestItem.length) {
- $('.posts .item').each(function() {
- var $this = $(this);
- if ($this.data('start') <= index && $this.data('end') >= index) {
- nearestItem = $this;
- return false;
- }
- });
- }
-
- return nearestItem;
- }
-});
diff --git a/framework/core/ember/app/models/discussion-state.js b/framework/core/ember/app/models/discussion-state.js
deleted file mode 100644
index d2cb576c6..000000000
--- a/framework/core/ember/app/models/discussion-state.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Ember from 'ember';
-import DS from 'ember-data';
-
-export default DS.Model.extend({
- readTime: DS.attr('date'),
- readNumber: DS.attr('number')
-});
diff --git a/framework/core/ember/app/models/stream.js b/framework/core/ember/app/models/post-stream.js
similarity index 81%
rename from framework/core/ember/app/models/stream.js
rename to framework/core/ember/app/models/post-stream.js
index c4d19fd7d..9e3aa997e 100644
--- a/framework/core/ember/app/models/stream.js
+++ b/framework/core/ember/app/models/post-stream.js
@@ -6,8 +6,10 @@ import Ember from 'ember';
export default Ember.ArrayProxy.extend(Ember.Evented, {
// An array of all of the post IDs, in chronological order, in the discussion.
- ids: Em.A(),
- content: Em.A(),
+ ids: null,
+
+ content: null,
+
store: null,
discussion: null,
@@ -19,12 +21,14 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
setup: function(ids) {
this.set('ids', ids);
- this.clear();
+ this.get('content').objectAt(0).set('indexEnd', this.get('count') - 1);
},
- count: function() {
- return this.get('ids.length');
- }.property('ids'),
+ count: Ember.computed.alias('ids.length'),
+
+ loadedCount: function() {
+ return this.get('content').filterBy('content').length;
+ }.property('content.@each'),
firstLoaded: function() {
var first = this.objectAt(0);
@@ -38,15 +42,15 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
// Clear the contents of the post stream, resetting it to one big gap.
clear: function() {
- var stream = this.get('content');
- stream.enumerableContentWillChange();
- stream.clear().pushObject(Em.Object.create({
+ var content = Ember.A();
+ content.clear().pushObject(Em.Object.create({
gap: true,
indexStart: 0,
indexEnd: this.get('count') - 1,
loading: true
}));
- stream.enumerableContentDidChange();
+ this.set('content', content);
+ this.set('ids', Ember.A());
},
loadRange: function(start, end, backwards) {
@@ -92,8 +96,8 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
// request.) Or, if it's a gap, we'll switch on its loading flag.
var item = this.findNearestToNumber(number);
if (item) {
- if (item.get('post.number') == number) {
- return Ember.RSVP.resolve([item.get('post')]);
+ if (item.get('content.number') == number) {
+ return Ember.RSVP.resolve([item.get('content')]);
} else if (item.gap) {
item.set('direction', 'down').set('loading', true);
}
@@ -102,7 +106,8 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
var stream = this;
return this.store.find('post', {
discussions: this.get('discussion.id'),
- near: number
+ near: number,
+ count: this.get('postLoadCount')
}).then(function(posts) {
stream.addPosts(posts);
});
@@ -116,11 +121,11 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
var item = this.findNearestToIndex(index);
if (item) {
if (! item.gap) {
- return Ember.RSVP.resolve([item.get('post')]);
+ return Ember.RSVP.resolve([item.get('content')]);
} else {
item.set('direction', 'down').set('loading', true);
}
- return this.loadRange(Math.max(item.indexStart, index - 10), item.indexEnd);
+ return this.loadRange(Math.max(item.indexStart, index - this.get('postLoadCount') / 2), item.indexEnd);
}
return Ember.RSVP.reject();
@@ -158,7 +163,7 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
newItems.push(Ember.Object.create({
indexStart: index,
indexEnd: index,
- post: post
+ content: post
}));
if (item.indexEnd > index) {
newItems.push(Ember.Object.create({
@@ -167,35 +172,29 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
indexEnd: item.indexEnd
}));
}
- content.enumerableContentWillChange();
content.replace(i, 1, newItems);
- content.enumerableContentDidChange();
return true;
}
- });
+ });
+ },
+
+ findNearestTo: function(index, property) {
+ var nearestItem;
+ this.get('content').some(function(item) {
+ nearestItem = item;
+ if (item.get(property) > index) {
+ return true;
+ }
+ });
+ return nearestItem;
},
findNearestToNumber: function(number) {
- var nearestItem;
- this.get('content').some(function(item) {
- var thisNumber = item.get('post.number');
- if (thisNumber > number) {
- return true;
- }
- nearestItem = item;
- });
- return nearestItem;
+ return this.findNearestTo(number, 'content.number');
},
findNearestToIndex: function(index) {
- var nearestItem;
- this.get('content').some(function(item) {
- if (item.indexStart <= index && item.indexEnd >= index) {
- nearestItem = item;
- return true;
- }
- });
- return nearestItem;
+ return this.findNearestTo(index, 'indexEnd');
}
});
diff --git a/framework/core/ember/app/router.js b/framework/core/ember/app/router.js
index 7e5ad6637..86b1f3ac9 100644
--- a/framework/core/ember/app/router.js
+++ b/framework/core/ember/app/router.js
@@ -1,14 +1,13 @@
import Ember from 'ember';
import config from './config/environment';
-console.log(config.locationType);
var Router = Ember.Router.extend({
location: config.locationType
});
Router.map(function() {
- this.resource('discussions', {path: '/'}, function() {
+ this.resource('index', {path: '/'}, function() {
this.resource('discussion', {path: '/:id/:slug'}, function() {
this.route('near', {path: '/:near'});
});
diff --git a/framework/core/ember/app/routes/discussion.js b/framework/core/ember/app/routes/discussion.js
index c0dee2df9..e745e0496 100644
--- a/framework/core/ember/app/routes/discussion.js
+++ b/framework/core/ember/app/routes/discussion.js
@@ -16,40 +16,45 @@ export default Ember.Route.extend({
controller.set('start', '1');
controller.set('searchQuery', '');
controller.set('loaded', false);
- controller.set('postStream', null);
+ controller.set('stream', null);
},
setupController: function(controller, model) {
controller.setup(model);
- this.controllerFor('application').set('showDiscussionStream', true);
- this.controllerFor('discussions').set('paned', true);
- this.controllerFor('discussions').set('current', model);
+ // Tell the discussions controller that the discussions list should be
+ // displayed as a pane, hidden on the side of the screen. Also set the
+ // application back button's target as the discussions controller.
+ this.controllerFor('index').set('paned', true);
+ this.controllerFor('application').set('backButtonTarget', this.controllerFor('index'));
},
actions: {
queryParamsDidChange: function(params) {
- // We're only interested in changes to the ?start param, and we're
- // not interested if nothing has actually changed. If the start
- // param has changed, we want to tell the controller to load posts
- // near it.
- if (! params.start || params.start == this.get('controller.start') || ! this.get('controller.loaded')) {
- return;
- }
- this.get('controller').send('jumpToNumber', params.start);
+ // 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 controller = this.get('controller'),
+ oldStart = this.get('controller.start');
+ Ember.run.next(function() {
+ if (! params.start || ! controller || ! controller.get('loaded') || params.start == oldStart) {
+ return;
+ }
+ controller.trigger('startWasChanged', params.start);
+ });
},
willTransition: function(transition) {
- // If we're going to transition out, we need to abort any unfinished
- // AJAX requests. We need to do this because sometimes a transition
- // to another discussion will happen very rapidly (i.e. when using
- // the arrow buttons on the result stream.) If a previous
- // discussion's posts finish loading while displaying a new
- // discussion, strange things will happen.
- this.store.adapterFor('discussion').xhr.forEach(function(xhr) {
- xhr.abort();
- });
+ // When we transition away from this 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.
+ this.controllerFor('index').set('paneShowing', false);
}
}
diff --git a/framework/core/ember/app/routes/discussions/index.js b/framework/core/ember/app/routes/discussions/index.js
deleted file mode 100644
index 41b5f5b11..000000000
--- a/framework/core/ember/app/routes/discussions/index.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Route.extend({
-
- setupController: function(controller, model) {
- this.controllerFor('discussions').set('paneShowing', false);
- this.controllerFor('discussions').set('paned', false);
- this.controllerFor('application').set('showDiscussionStream', false);
- this._super(controller, model);
- }
-
-});
diff --git a/framework/core/ember/app/routes/discussions.js b/framework/core/ember/app/routes/index.js
similarity index 99%
rename from framework/core/ember/app/routes/discussions.js
rename to framework/core/ember/app/routes/index.js
index b3f55afa8..ef31d3be6 100644
--- a/framework/core/ember/app/routes/discussions.js
+++ b/framework/core/ember/app/routes/index.js
@@ -8,7 +8,7 @@ export default Ember.Route.extend({
if ( ! model.get('length')) {
controller.set('resultsLoading', true);
-
+
controller.getResults().then(function(results) {
controller
.set('resultsLoading', false)
diff --git a/framework/core/ember/app/routes/index/index.js b/framework/core/ember/app/routes/index/index.js
new file mode 100644
index 000000000..8cd8adfc1
--- /dev/null
+++ b/framework/core/ember/app/routes/index/index.js
@@ -0,0 +1,13 @@
+import Ember from 'ember';
+
+export default Ember.Route.extend({
+
+ // When we enter the discussions list view, we no longer want the
+ // discussions list to be in pane mode.
+ setupController: function(controller, model) {
+ this.controllerFor('index').set('paned', false);
+ this.controllerFor('index').set('paneShowing', false);
+ this._super(controller, model);
+ }
+
+});
diff --git a/framework/core/ember/app/styles/app.less b/framework/core/ember/app/styles/app.less
index cd63f717d..6ba7349be 100644
--- a/framework/core/ember/app/styles/app.less
+++ b/framework/core/ember/app/styles/app.less
@@ -1,12 +1,24 @@
+// This files is where our LESS journey begins.
+
+// We begin by importing our own configuration variables, which are used all
+// throughout the stylesheets. These pertain to
// @import "config-default.less";
@import "config.less";
-@flarum-base: "flarum/";
-@bootstrap-base: "../../bower_components/bootstrap/less/";
+@flarum-base: "flarum/";
+@bootstrap-base: "../../bower_components/bootstrap/less/";
@font-awesome-base: "../../bower_components/font-awesome/less/";
@import "bootstrap/bootstrap.less";
-@import "@{font-awesome-base}font-awesome.less";
+// We want to specify the @fa-font-path variable AFTER we import font awesome
+// so that it overrides the default definition.
+@import "@{font-awesome-base}font-awesome.less";
@fa-font-path: "../font-awesome/fonts";
+// Finally, with our vendor CSS loaded, we can import Flarum-specific stuff.
+@import "@{flarum-base}components.less";
+@import "@{flarum-base}layout.less";
+
+@import "@{flarum-base}index.less";
+@import "@{flarum-base}discussion.less";
\ No newline at end of file
diff --git a/framework/core/ember/app/styles/bootstrap/bootstrap.less b/framework/core/ember/app/styles/bootstrap/bootstrap.less
index 18e83472b..de196ca7f 100644
--- a/framework/core/ember/app/styles/bootstrap/bootstrap.less
+++ b/framework/core/ember/app/styles/bootstrap/bootstrap.less
@@ -25,19 +25,19 @@
// @import "@{bootstrap-base}navs.less";
// @import "@{bootstrap-base}navbar.less";
// @import "@{bootstrap-base}breadcrumbs.less";
-@import "@{bootstrap-base}pagination.less";
+// @import "@{bootstrap-base}pagination.less";
// @import "@{bootstrap-base}pager.less";
// @import "@{bootstrap-base}labels.less";
// @import "@{bootstrap-base}badges.less";
// @import "@{bootstrap-base}jumbotron.less";
// @import "@{bootstrap-base}thumbnails.less";
// @import "@{bootstrap-base}alerts.less";
-@import "@{bootstrap-base}progress-bars.less";
+// @import "@{bootstrap-base}progress-bars.less";
// @import "@{bootstrap-base}media.less";
// @import "@{bootstrap-base}list-group.less";
// @import "@{bootstrap-base}panels.less";
// @import "@{bootstrap-base}wells.less";
-@import "@{bootstrap-base}close.less";
+// @import "@{bootstrap-base}close.less";
// Components w/ JavaScript
@import "@{bootstrap-base}modals.less";
diff --git a/framework/core/ember/app/styles/bootstrap/variables.less b/framework/core/ember/app/styles/bootstrap/variables.less
index c25915906..9fa87c801 100644
--- a/framework/core/ember/app/styles/bootstrap/variables.less
+++ b/framework/core/ember/app/styles/bootstrap/variables.less
@@ -1 +1,19 @@
-@btn-primary-bg: @fl-primary-color;
\ No newline at end of file
+@brand-primary: @fl-primary-color;
+
+@body-bg: @fl-body-bg;
+@text-color: @fl-body-color;
+@font-size-base: 13px;
+
+@font-family-sans-serif: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
+
+@padding-base-vertical: 8px;
+@padding-base-horizontal: 13px;
+
+@btn-default-bg: @fl-body-control-bg;
+@btn-default-color: @fl-body-control-color;
+
+@input-bg: @fl-body-control-bg;
+@input-border-focus: darken(@fl-body-control-bg, 5%);
+@input-border: @fl-body-control-bg;
+@input-color: @fl-body-control-color;
+@input-color-placeholder: @fl-body-control-color;
\ No newline at end of file
diff --git a/framework/core/ember/app/styles/config.less b/framework/core/ember/app/styles/config.less
index 83426b9c4..bc1b75392 100644
--- a/framework/core/ember/app/styles/config.less
+++ b/framework/core/ember/app/styles/config.less
@@ -1,4 +1,4 @@
-@fl-primary-color: #4d698e;
+@fl-primary-color: #4d698e;//#E7562E;
@fl-secondary-color: #edf2f7;
@fl-dark-hdr: false;
@@ -7,7 +7,7 @@
@fl-body-bg: #fff;
@fl-body-color: #444;
-@fl-body-muted-color: hsl(hue(@fl-secondary-color), 18%, 72%); // todo
+@fl-body-muted-color: hsv(hue(@fl-secondary-color), 18%, 72%); // todo
@fl-body-muted-more-color: #bbb;
@fl-body-heading-color: @fl-body-color;
@@ -20,14 +20,24 @@
// ---------------------------------
// HEADER
-@fl-hdr-light-bg: @fl-body-bg;
-@fl-hdr-light-color: @fl-primary-color;
-@fl-hdr-light-muted-color: @fl-body-muted-color;
-@fl-hdr-light-control-bg: @fl-body-control-bg;
-@fl-hdr-light-control-color: @fl-body-control-color;
+.define-hdr-variables(@fl-dark-hdr);
+.define-hdr-variables(false) {
+ @fl-hdr-bg: @fl-body-bg;
+ @fl-hdr-color: @fl-primary-color;
+ @fl-hdr-muted-color: @fl-body-muted-color;
+ @fl-hdr-control-bg: @fl-body-control-bg;
+ @fl-hdr-control-color: @fl-body-control-color;
-@fl-hdr-dark-bg: @fl-primary-color;
-@fl-hdr-dark-color: #fff;
-@fl-hdr-dark-muted-color: fade(#fff, 0.5);
-@fl-hdr-dark-control-bg: fade(#000, 0.1);
-@fl-hdr-dark-control-color: #fff;
\ No newline at end of file
+ @fl-body-hero-bg: @fl-primary-color;
+ @fl-body-hero-color: #fff;
+}
+.define-hdr-variables(true) {
+ @fl-hdr-bg: @fl-primary-color;
+ @fl-hdr-color: #fff;
+ @fl-hdr-muted-color: fade(#fff, 0.5);
+ @fl-hdr-control-bg: fade(#000, 0.1);
+ @fl-hdr-control-color: #fff;
+
+ @fl-body-hero-bg: @fl-secondary-color;
+ @fl-body-hero-color: @fl-body-muted-color;
+}
\ No newline at end of file
diff --git a/framework/core/ember/app/styles/flarum/components.less b/framework/core/ember/app/styles/flarum/components.less
new file mode 100644
index 000000000..1787924a8
--- /dev/null
+++ b/framework/core/ember/app/styles/flarum/components.less
@@ -0,0 +1,224 @@
+// ------------------------------------
+// Buttons
+
+.btn {
+ border: 0;
+ .box-shadow(none);
+ line-height: 20px;
+
+ & .fa {
+ font-size: 14px;
+ }
+}
+.btn-group .btn + .btn {
+ margin-left: 1px;
+}
+.btn-icon {
+ padding-left: 9px;
+ padding-right: 9px;
+}
+.btn-link {
+ color: @fl-body-muted-color;
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ }
+}
+.btn-primary {
+ font-weight: bold;
+ & .icon-glyph {
+ display: none;
+ }
+}
+
+// Redefine Bootstrap's mixin to make some general changes
+.button-variant(@color; @background; @border) {
+ &:hover,
+ &:focus,
+ &.focus,
+ &:active,
+ &.active,
+ .open > .dropdown-toggle& {
+ background-color: darken(@background, 5%);
+ }
+ &.active {
+ .box-shadow(none);
+ }
+}
+
+// Little round icon buttons
+.btn-icon.btn-sm {
+ border-radius: 12px;
+ height: 24px;
+ width: 24px;
+ text-align: center;
+ padding: 3px 0;
+
+ & .label, & .icon-caret {
+ display: none;
+ }
+ & .fa-ellipsis-v {
+ font-size: 17px;
+ vertical-align: middle;
+ }
+}
+
+// Buttons that blend into the background
+.btn-blend {
+ background: transparent;
+ &:hover {
+ background: @fl-body-control-bg;
+ }
+}
+
+// ------------------------------------
+// Form Controls
+
+.form-control {
+ .box-shadow(none);
+ &:focus,
+ &.focus {
+ background-color: @input-border-focus;
+ .box-shadow(none);
+ }
+}
+
+// Search inputs
+// @todo Extract some of this into header-specific definitions
+.search-input {
+ margin-right: 10px;
+ &:before {
+ .fa();
+ content: @fa-var-search;
+ float: left;
+ margin-right: -36px;
+ width: 36px;
+ font-size: 14px;
+ text-align: center;
+ color: @fl-body-muted-color;
+ position: relative;
+ padding: @padding-base-vertical - 1 0;
+ line-height: @line-height-base;
+ pointer-events: none;
+ }
+}
+.search-input .form-control {
+ float: left;
+ width: 225px;
+ padding-left: 36px;
+ padding-right: 36px;
+ .transition(~"all 0.4s");
+
+ &:focus {
+ width: 400px;
+ }
+}
+.search-input .clear {
+ float: left;
+ margin-left: -36px;
+ vertical-align: top;
+ .scale(0.001);
+ .transition(~"transform 0.15s");
+}
+.search-input.clearable .clear {
+ .scale(1);
+}
+
+// Select inputs
+.select-input {
+ display: inline-block;
+ vertical-align: middle;
+}
+.select-input select {
+ display: inline-block;
+ width: auto;
+ -webkit-appearance: none;
+ padding-right: @padding-base-horizontal + 16;
+ cursor: pointer;
+}
+.select-input .fa {
+ margin-left: -@padding-base-horizontal - 16;
+ pointer-events: none;
+ color: @fl-body-muted-color;
+}
+
+// ------------------------------------
+// Dropdown Menus
+
+.dropdown-menu {
+ border: 0;
+ padding: 8px 0;
+ margin-top: 7px;
+ .box-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
+
+ & > li > a {
+ padding: 8px 15px;
+ color: @fl-body-color;
+ &:hover, &:focus {
+ color: @fl-body-color;
+ background-color: @fl-secondary-color;
+ }
+ & .fa {
+ margin-right: 5px;
+ font-size: 14px;
+ }
+ }
+ & .divider {
+ margin: 10px 0;
+ background-color: darken(@fl-secondary-color, 2%);
+ }
+}
+.dropdown-split.item-count-1 {
+ & .btn {
+ border-radius: @border-radius-base !important;
+ }
+ & .dropdown-toggle {
+ display: none;
+ }
+}
+
+// ------------------------------------
+// Tooltips
+
+.tooltip-inner {
+ padding: 5px 10px;
+}
+
+// ------------------------------------
+// Loading Indicators
+
+.loading-indicator {
+ position: relative;
+ color: @fl-primary-color;
+}
+.loading-indicator-block {
+ height: 100px;
+}
+
+// ------------------------------------
+// Avatars
+
+.avatar-size(@size) {
+ width: @size;
+ height: @size;
+ border-radius: @size / 2;
+ font-size: @size / 2;
+ line-height: @size;
+}
+.avatar {
+ display: inline-block;
+ color: #fff;
+ font-weight: 300;
+ text-align: center;
+ vertical-align: top;
+ .avatar-size(48px);
+
+ & img {
+ display: inline-block;
+ width: 100%;
+ height: 100%;
+ border-radius: 100%;
+ vertical-align: top;
+ }
+}
\ No newline at end of file
diff --git a/framework/core/ember/app/styles/flarum/composer.less b/framework/core/ember/app/styles/flarum/composer.less
new file mode 100644
index 000000000..f33c744b2
--- /dev/null
+++ b/framework/core/ember/app/styles/flarum/composer.less
@@ -0,0 +1,61 @@
+// ------------------------------------
+// Composer
+
+.composer-container {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: @zindex-navbar-fixed;
+ pointer-events: none;
+}
+.composer {
+ pointer-events: auto;
+ margin-left: 200px;
+ .box-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
+ border-radius: 4px 4px 0 0;
+ background: rgba(255, 255, 255, 0.98);
+ transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
+}
+.composer-content {
+ padding: 0 20px 15px;
+}
+.composer-handle {
+ height: 20px;
+ cursor: row-resize;
+}
+.composer-controls {
+ float: right;
+ margin: -5px 15px 0 0;
+}
+.composer-avatar {
+ float: left;
+ width: 64px;
+ height: 64px;
+}
+.composer-body {
+ margin-left: 84px;
+
+ & h3 {
+ margin: 5px 0 10px;
+ color: @fl-body-muted-color;
+ font-size: 16px;
+ font-weight: normal;
+ }
+}
+.composer-editor textarea {
+ background: none;
+ border-radius: 0;
+ padding: 0;
+ margin-bottom: 10px;
+ height: 200px;
+ border: 0;
+ resize: none;
+ color: @fl-body-color;
+ font-size: 15px;
+ line-height: 1.6;
+
+ &:focus {
+ background: none;
+ }
+}
\ No newline at end of file
diff --git a/framework/core/ember/app/styles/flarum/discussion.less b/framework/core/ember/app/styles/flarum/discussion.less
new file mode 100644
index 000000000..fd9af5dee
--- /dev/null
+++ b/framework/core/ember/app/styles/flarum/discussion.less
@@ -0,0 +1,232 @@
+// ------------------------------------
+// Sidebar
+
+.discussion-nav {
+ float: right;
+
+ &, & > ul {
+ width: 150px;
+ }
+ & > ul {
+ position: fixed;
+ margin: 30px 0 0;
+ padding: 0;
+ list-style-type: none;
+
+ & > li {
+ margin-bottom: 10px;
+ }
+ }
+ & .btn-group, & .btn {
+ width: 100%;
+ }
+ & .btn-group:not(.item-count-1) {
+ & .btn {
+ width: 80%;
+ }
+ & .dropdown-toggle {
+ width: 19%;
+ }
+ }
+}
+
+// ------------------------------------
+// Stream
+
+.discussion-posts {
+ margin-top: 40px;
+ margin-right: 225px;
+ & .item {
+ margin-bottom: 40px;
+ }
+}
+.gap {
+ padding: 30px 0;
+ text-align: center;
+ color: #aaa;
+ cursor: pointer;
+ border: 2px dashed @fl-body-bg;
+ background: #f2f2f2;
+ text-transform: uppercase;
+ font-size: 12px;
+ font-weight: bold;
+ overflow: hidden;
+ position: relative;
+ .transition(padding 0.2s);
+
+ &:hover, &.loading, &.active {
+ padding: 50px 0;
+ &.up:before, &.down:after {
+ opacity: 1;
+ }
+ }
+ &.loading {
+ .transition(none);
+ }
+ &:before, &:after {
+ font-family: 'FontAwesome';
+ display: block;
+ opacity: 0;
+ transition: opacity 0.2s;
+ height: 15px;
+ color: #aaa;
+ }
+ &.up:before {
+ content: '\f077';
+ margin-top: -25px;
+ margin-bottom: 10px;
+ }
+ &.down:after {
+ content: '\f078';
+ margin-bottom: -25px;
+ margin-top: 10px;
+ }
+ &:only-child {
+ background: none;
+ border: 0;
+ color: @fl-primary-color;
+ &:before, &:after {
+ display: none;
+ }
+ }
+}
+
+// ------------------------------------
+// Posts
+
+.post {
+ padding-left: 90px;
+ transition: 0.2s box-shadow;
+
+ & .contextual-controls {
+ float: right;
+ margin: -2px 0 0 10px;
+ visibility: hidden;
+ }
+ &:hover .contextual-controls, & .contextual-controls.open {
+ visibility: visible;
+ }
+}
+.item.highlight .post {
+ border: 10px solid rgba(255, 255, 0, 0.2);
+ border-radius: 10px;
+ padding: 15px 15px 0 105px;
+ margin: -25px -25px -10px -25px;
+}
+
+.post-header {
+ margin-bottom: 10px;
+ color: @fl-body-muted-color;
+
+ & .user {
+ margin: 0;
+ display: inline;
+ font-weight: bold;
+ font-size: 18px;
+ &, & a {
+ color: @fl-body-heading-color;
+ }
+ }
+ & .avatar {
+ margin-left: -90px;
+ float: left;
+ .avatar-size(64px);
+ }
+ & .time {
+ margin-left: 10px;
+ &, & a {
+ color: @fl-body-muted-color;
+ }
+ }
+}
+.post-body {
+ font-size: 15px;
+ line-height: 1.6;
+ padding-bottom: 1px;
+}
+.post-edited {
+ margin-left: 10px;
+ font-size: 14px;
+}
+
+// ------------------------------------
+// Scrubber
+
+@media (min-width: @screen-md-min) {
+ .stream-scrubber {
+ margin: 30px 0 0 0;
+ }
+ .scrubber {
+ & a {
+ margin-left: -5px;
+ color: @fl-body-muted-color;
+ & .fa {
+ font-size: 14px;
+ margin-right: 2px;
+ }
+ &:hover, &:focus {
+ text-decoration: none;
+ color: @link-hover-color;
+ }
+ }
+ }
+ .scrubber-scrollbar {
+ margin: 8px 0 8px 3px;
+ height: 300px;
+ min-height: 50px; // JavaScript sets a max-height
+ position: relative;
+ }
+ .scrubber-before, .scrubber-after {
+ border-left: 1px solid darken(@fl-secondary-color, 2%);
+ }
+ .scrubber-slider {
+ position: relative;
+ width: 100%;
+ padding: 5px 0;
+ }
+ .scrubber-handle {
+ height: 100%;
+ width: 5px;
+ background: @fl-primary-color;
+ border-radius: 4px;
+ float: left;
+ margin-left: -2px;
+ transition: background 0.2s;
+ .disabled & {
+ background: @fl-secondary-color;
+ }
+ }
+ .scrubber-info {
+ height: (2em * @line-height-base);
+ margin-top: (-1em * @line-height-base);
+ position: absolute;
+ top: 50%;
+ width: 100%;
+ left: 15px;
+ & strong {
+ display: block;
+ }
+ & .description {
+ color: @fl-body-muted-color;
+ }
+ }
+ .scrubber-highlights {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ list-style-type: none;
+ pointer-events: none;
+ }
+ .scrubber-highlights li {
+ position: absolute;
+ right: -6px;
+ background: #fc0;
+ height: 8px;
+ width: 13px;
+ 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);
+ opacity: 0.99;
+ }
+}
\ No newline at end of file
diff --git a/framework/core/ember/app/styles/flarum/index.less b/framework/core/ember/app/styles/flarum/index.less
new file mode 100644
index 000000000..dd4467969
--- /dev/null
+++ b/framework/core/ember/app/styles/flarum/index.less
@@ -0,0 +1,253 @@
+// ------------------------------------
+// Sidebar
+
+.index-nav {
+ float: left;
+
+ &, & > ul {
+ width: 175px;
+ }
+ & > ul {
+ margin: 30px 0 0;
+ padding: 0;
+ list-style-type: none;
+
+ &.affix {
+ top: 56px;
+ }
+ & > li {
+ margin-bottom: 10px;
+ }
+ }
+ & .new-discussion {
+ display: block;
+ margin-bottom: 20px;
+ }
+
+ // Expand the dropdown-select component into a normal nav list
+ // @todo Extract this into a mixin as we'll need to do it elsewhere.
+ & .dropdown-select {
+ display: block;
+
+ & .dropdown-toggle {
+ display: none;
+ }
+ & .dropdown-menu {
+ display: block;
+ border: 0;
+ width: auto;
+ margin: 0;
+ padding: 0;
+ min-width: 0;
+ float: none;
+ position: static;
+ background: none;
+ .box-shadow(none);
+ & > li > a {
+ padding: 8px 0;
+ color: @fl-body-muted-color;
+ & .fa {
+ margin-right: 8px;
+ font-size: 15px;
+ }
+ &:hover {
+ background: none;
+ color: @link-hover-color;
+ }
+ }
+ & > li.active > a {
+ background: none;
+ color: @fl-primary-color;
+ font-weight: bold;
+ }
+ }
+ }
+}
+
+// ------------------------------------
+// Results
+
+.index-results {
+ margin-top: 30px;
+ margin-left: 225px;
+ & .loading-indicator {
+ height: 40px;
+ }
+}
+.index-toolbar {
+ margin-bottom: 15px;
+}
+.index-toolbar-view {
+ display: inline-block;
+ & .control-show {
+ margin-right: 10px;
+ }
+}
+.index-toolbar-action {
+ float: right;
+}
+
+// ------------------------------------
+// Discussions Pane
+
+@index-pane-width: 440px;
+
+.index-area {
+ left: -@index-pane-width;
+ width: 100%;
+
+ &.paned {
+ position: fixed;
+ z-index: @zindex-navbar-fixed + 1;
+ overflow: auto;
+ top: 56px;
+ bottom: 0;
+ width: @index-pane-width;
+ background: #fff;
+ padding-bottom: 200px;
+ .box-shadow(2px 2px 6px -2px rgba(0, 0, 0, 0.25));
+ .transition(left 0.2s);
+
+ &.showing, .with-pane & {
+ left: 0;
+ }
+ & .container {
+ width: auto;
+ margin: 0;
+ padding: 0 !important;
+ }
+ & .index-results {
+ margin: 0;
+ }
+ & .hero, & .index-nav, & .index-toolbar {
+ display: none;
+ }
+ & .discussions-list > li {
+ margin: 0;
+ padding-left: 65px + 15px;
+ padding-right: 65px + 25px;
+ &.active {
+ background: @fl-secondary-color;
+ }
+ }
+ & .discussion-summary {
+ & .title {
+ font-size: 15px;
+ }
+ & .count strong {
+ font-size: 18px;
+ }
+ }
+ }
+}
+
+// When the pane is pinned, move the other page content inwards
+.global-main, .global-footer {
+ .transition(margin-left 0.2s);
+
+ .with-pane & {
+ margin-left: @index-pane-width;
+ & .container {
+ max-width: 100%;
+ padding: 0 30px;
+ }
+ }
+}
+.global-header .container {
+ .transition(width 0.2s);
+
+ .with-pane & {
+ width: 100%;
+ }
+}
+
+// ------------------------------------
+// Discussions List
+
+.discussions-list {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+ position: relative;
+
+ & > li {
+ margin-right: -25px;
+ padding-right: 65px + 25px;
+ & .contextual-controls {
+ position: absolute;
+ right: 0;
+ top: 18px;
+ visibility: hidden;
+ }
+ &:hover .contextual-controls, & .contextual-controls.open {
+ visibility: visible;
+ }
+ }
+}
+.discussion-summary {
+ padding-left: 65px;
+ padding-right: 65px;
+ position: relative;
+
+ & .author {
+ float: left;
+ margin-left: -65px;
+ margin-top: 18px;
+ }
+ & .info {
+ display: inline-block;
+ width: 100%;
+ margin-right: -65px;
+ color: @fl-body-muted-color;
+ padding: 20px 0;
+
+ &:hover, &:active, &.active, &:focus {
+ text-decoration: none;
+ & .title {
+ text-decoration: underline;
+ }
+ }
+ &.active .title {
+ text-decoration: none;
+ }
+ }
+ & .title {
+ margin: 0 0 5px;
+ font-size: 16px;
+ 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;
+
+ & strong {
+ font-size: 20px;
+ display: block;
+ font-weight: 300;
+ }
+ .unread&, .unread& strong {
+ color: @fl-body-heading-color;
+ font-weight: bold;
+ cursor: pointer;
+ }
+ }
+}
+
+.load-more {
+ text-align: center;
+ margin-top: 10px;
+}
\ No newline at end of file
diff --git a/framework/core/ember/app/styles/flarum/layout.less b/framework/core/ember/app/styles/flarum/layout.less
new file mode 100644
index 000000000..911722059
--- /dev/null
+++ b/framework/core/ember/app/styles/flarum/layout.less
@@ -0,0 +1,158 @@
+body {
+ background: @fl-body-bg;
+ color: @fl-body-color;
+ padding-top: 56px;
+}
+.container-narrow {
+ max-width: 500px;
+ margin: 0 auto;
+}
+
+// ------------------------------------
+// Header
+
+.global-header {
+ background: fade(@fl-hdr-bg, 98%);
+ transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
+ padding: 10px;
+ height: 56px;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: @zindex-navbar-fixed;
+ .clearfix();
+ .transition(box-shadow 0.2s);
+
+ .scrolled & {
+ .box-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
+ }
+}
+.header-controls {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+ &, & > li {
+ display: inline-block;
+ vertical-align: top;
+ }
+}
+.header-primary {
+ float: left;
+ & h1 {
+ display: inline-block;
+ vertical-align: top;
+ }
+}
+.header-title {
+ font-size: 18px;
+ font-weight: normal;
+ margin: 0;
+ line-height: 36px;
+ &, & a {
+ color: @fl-hdr-color;
+ }
+}
+.header-secondary {
+ float: right;
+}
+
+// Back button
+// @todo Lots of !importants in here, could we be more specific?
+.back-button {
+ float: left;
+ margin-right: 25px;
+
+ & .back {
+ z-index: 3; // z-index of an active .btn-group .btn is 2
+ border-radius: @border-radius-base !important;
+ .transition(border-radius 0.2s);
+ }
+ & .pin {
+ opacity: 0;
+ margin-left: -36px !important;
+ .transition(~"opacity 0.2s, margin-left 0.2s");
+
+ &:not(.active) .fa {
+ .rotate(45deg);
+ }
+ }
+ &.active {
+ & .back {
+ border-radius: @border-radius-base 0 0 @border-radius-base !important;
+ }
+ & .pin {
+ opacity: 1;
+ margin-left: 1px !important;
+ }
+ }
+}
+
+// ------------------------------------
+// Main
+
+.global-main, .paned {
+ border-top: 1px solid @fl-secondary-color;
+}
+
+// Hero
+.hero {
+ background: @fl-body-hero-bg;
+ color: @fl-body-hero-color;
+ margin-top: -1px;
+ text-align: center;
+ padding: 30px 0;
+}
+.hero .close {
+ float: right;
+ margin-top: -10px;
+ color: #fff;
+ opacity: 0.5;
+}
+.hero h2 {
+ margin: 0;
+ font-size: 22px;
+ font-weight: normal;
+}
+.hero p {
+ margin: 10px 0 0;
+}
+
+// ------------------------------------
+// Footer
+
+.global-footer {
+ margin: 100px 0 20px;
+ color: @fl-body-muted-more-color;
+ .clearfix();
+}
+.footer-primary, .footer-secondary {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+
+ & > li {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ & a {
+ color: @fl-body-muted-more-color;
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ color: @link-hover-color;
+ }
+ }
+}
+.footer-primary {
+ display: inline-block;
+ & > li {
+ margin-right: 15px;
+ }
+}
+.footer-secondary {
+ float: right;
+ & > li {
+ margin-left: 15px;
+ }
+}
\ No newline at end of file
diff --git a/framework/core/ember/app/templates/application.hbs b/framework/core/ember/app/templates/application.hbs
index 483f43f9c..a4bc0f819 100644
--- a/framework/core/ember/app/templates/application.hbs
+++ b/framework/core/ember/app/templates/application.hbs
@@ -1,20 +1,14 @@
-