Implement redesign, refactor everything

- Write CSS for everything, update templates.
- Refactor discussion view. Stream is split into two components
(content and scrubber) which have their own responsibilities.
- Extract pane functionality into a mixin.
- Implement global “back button” system. You give a “paneable” target
to the application controller, the back button will modulate its
pane-related properties as necessary, and call an action when the
button is clicked.
- Extract welcome-hero into its own component.
- Lots of other general improvements/refactoring. The code is quite
well-commented so take a look!
This commit is contained in:
Toby Zerner 2015-01-16 17:26:10 +10:30
parent 69be3b9929
commit 4e9a6931e4
69 changed files with 2564 additions and 1334 deletions

View File

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

View File

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

View File

@ -0,0 +1,8 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'article',
layoutName: 'components/discussions/post-content-comment',
editDescription: ''
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}}<span>{{title}}</span>'),
layout: Ember.Handlebars.compile('{{#if icon}}{{fa-icon icon class="fa-fw icon-glyph"}} {{/if}}<span>{{label}}</span>'),
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();
}
}
});

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['loading'],
classNames: ['loading-indicator'],
layout: Ember.Handlebars.compile('&nbsp;'),
size: 'small',
didInsertElement: function() {
this.$().spin(this.get('size'));

View File

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

View File

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

View File

@ -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}} <span class="count">{{badge}}</span>{{/link-to}}');
return Ember.Handlebars.compile('{{#link-to '+this.get('linkTo')+'}}'+this.get('iconTemplate')+' {{label}} <span class="count">{{badge}}</span>{{/link-to}}');
}.property('linkTo', 'iconTemplate'),
iconTemplate: function() {

View File

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

View File

@ -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: '<img src="tv.png" height="24" style="vertical-align: baseline; margin-right: 5px"> TV Addicts',
// forumTitle: '<img src="gametoaid.png" height="50">',
// forumTitle: '<i class="fa fa-stethoscope" style="font-size: 140%"></i>&nbsp; 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'}});
}
}
});

View File

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

View File

@ -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 <em>'+this.get('model.title')+'</em>');
},
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);
}
}
});

View File

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

View File

@ -1,5 +1,5 @@
import Ember from 'ember';
export default Ember.ArrayController.extend({
needs: ['application', 'composer']
needs: ['application']
});

View File

@ -1,6 +1,6 @@
import Ember from 'ember';
export default Ember.Handlebars.makeBoundHelper(function(icon, options) {
return new Handlebars.SafeString('<i class="fa fa-icon fa-'+icon+' '+(options.hash.class || '')+'"></i>');
return new Handlebars.SafeString('<i class="fa fa-fw fa-'+icon+' '+(options.hash.class || '')+'"></i>');
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1,19 @@
@btn-primary-bg: @fl-primary-color;
@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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +1,14 @@
<div id="wrapper">
<div {{bind-attr class=":page panePinned:with-pane"}}>
<header id="header">
<header id="header" class="global-header">
<div id="back-nav">
<div class="btn-group">
<button class="btn">{{fa-icon "chevron-left"}}</button>
<button class="btn">{{fa-icon "thumb-tack"}}</button>
</div>
</div>
{{back-button target=backButtonTarget}}
<div id="header-content">
<div class="container">
<div id="header-primary">
<h1 id="header-title">
{{#link-to "discussions" (query-params searchQuery="" sort="recent" show="discussions")}}
<div class="header-primary">
<h1 class="header-title">
{{#link-to "index" (query-params searchQuery="" sort="recent" show="discussions")}}
{{#if view.image}}
<img {{bind-attr src=view.image alt=view.title}}>
{{else}}
@ -23,39 +17,39 @@
{{/link-to}}
</h1>
{{ui/controls/item-list items=headerPrimaryControls class="nav"}}
{{ui/controls/item-list items=view.headerPrimaryItems class="header-controls"}}
</div>
<div id="header-secondary">
<div class="search">
{{ui/controls/search-input placeholder="Search forum" value=searchQuery active=searchActive action="search"}}
</div>
{{ui/controls/item-list items=headerSecondaryControls}}
<div class="header-secondary">
{{ui/controls/item-list items=view.headerSecondaryItems class="header-controls"}}
</div>
</div>
</header>
<main id="body">
<main id="main" class="global-main">
{{outlet}}
{{!-- <div class="composer-container">
<div class="container">
{{render "composer"}}
</div>
</div> --}}
</main>
<footer id="footer">
{{ui/controls/item-list items=footerControls}}
<footer id="footer" class="global-footer">
<div class="container">
{{ui/controls/item-list items=view.footerPrimaryItems class="footer-primary"}}
{{ui/controls/item-list items=view.footerSecondaryItems class="footer-secondary"}}
</div>
</footer>
</div>
{{outlet "modal"}}
{{render "composer"}}
<div id="alerts">
<div class="alerts">
{{#each alert in alerts}}
{{alert-message message=alert}}
{{/each}}

View File

@ -0,0 +1,8 @@
{{#if target}}
<div class="btn-group">
<button class="btn btn-default btn-icon back" {{action "back"}}>{{fa-icon "chevron-left"}}</button>
{{#if target.paned}}
<button {{bind-attr class=":btn :btn-default :btn-icon :pin target.panePinned:active"}} {{action "togglePinned"}}>{{fa-icon "thumb-tack"}}</button>
{{/if}}
</div>
{{/if}}

View File

@ -1,47 +1,46 @@
{{ui/controls/dropdown-button items=controls class="contextual-controls"}}
{{ui/controls/dropdown-button
items=controls
class="contextual-controls"
buttonClass="btn btn-default btn-icon btn-sm btn-blend"
buttonClick="populateControls"
menuClass="pull-right"}}
<div class="discussion-summary">
{{#link-to "user" discussion.startUser class="author"}}{{user-avatar discussion.startUser}}{{/link-to}}
{{#link-to "user" discussion.startUser}}{{user-avatar discussion.startUser}}{{/link-to}}
<div class="badges">
{{#each badge in badges}}
{{fl-badge badge}}
{{/each}}
</div>
<div class="badges">
{{#each badge in badges}}
{{fl-badge badge}}
{{#link-to "discussion" discussion.content (query-params searchQuery=searchQuery start=start) current-when="discussion" class="info"}}
<h3 class="title">{{highlight-words discussion.title searchQuery}}</h3>
<span class="terminal-post">
{{#if displayLastPost}}
<span class="username">{{discussion.lastUser.username}}</span> replied
<span class="time">{{human-time discussion.lastTime}}</span>
{{else}}
<span class="username">{{discussion.startUser.username}}</span> started
<span class="time">{{human-time discussion.startTime}}</span>
{{/if}}
</span>
{{/link-to}}
<div class="count" title="Mark as Read" {{action "markAsRead"}}>
{{#if displayUnread}}
<strong>{{abbreviate-number discussion.unreadCount}}</strong> unread
{{else}}
<strong>{{abbreviate-number discussion.repliesCount}}</strong> replies
{{/if}}
</div>
{{#if relevantPosts}}
<div class="relevant-posts">
{{#each post in relevantPosts}}
{{#link-to "discussion" discussion.content (query-params start=post.number) class="post item"}}
{{user-avatar post.user class="avatar-thumb"}}
<span class="post-body">{{highlight-words post.relevantContent searchQuery}}</span>
{{/link-to}}
{{/each}}
</div>
{{#link-to "discussion" discussion.content (query-params searchQuery=searchQuery) class="info"}}
<span class="name">
<h3 class="title">{{highlight-words discussion.title searchQuery}}</h3>
</span>
<span class="terminal-post">
{{#if displayStartPosts}}
{{#link-to "user" discussion.startUser}}{{discussion.startUser.username}}{{/link-to}} posted
{{#link-to "discussion" discussion.content}}{{abbreviate-time discussion.startTime}}{{/link-to}}
{{else}}
{{#link-to "user" discussion.lastUser}}{{discussion.lastUser.username}}{{/link-to}} posted
{{#link-to "discussion" discussion.content (query-params start="last")}}{{abbreviate-time discussion.lastTime}}{{/link-to}}
{{/if}}
</span>
{{/link-to}}
<div class="count">
{{#if discussion.isUnread}}
<strong>{{abbreviate-number discussion.unreadCount}}</strong> unread
{{else}}
<strong>{{abbreviate-number discussion.repliesCount}}</strong> replies
{{/if}}
</div>
{{#if relevantPosts}}
<div class="relevant-posts">
{{#each post in relevantPosts}}
{{#link-to "discussion" discussion.content (query-params start=post.number) class="post item"}}
{{user-avatar post.user class="avatar-thumb"}}
<span class="post-body">{{highlight-words post.relevantContent searchQuery}}</span>
{{/link-to}}
{{/each}}
</div>
{{/if}}
</div>
{{/if}}

View File

@ -2,12 +2,12 @@
{{fa-icon "trash-o" class="post-icon"}}
{{/if}}
<header>
<header class="post-header">
<h3 class="user">
{{#link-to "user" post.user}}{{user-avatar post.user}} {{post.user.username}}{{/link-to}}
</h3>
{{#link-to "discussion" post.discussion (query-params start=post.number) class="time"}}
{{abbreviate-time post.time}}
{{human-time post.time}}
{{/link-to}}
{{#if post.editTime}}
<span class="post-edited" {{bind-attr title=editDescription}}>{{fa-icon "pencil"}}</span>

View File

@ -1,3 +0,0 @@
{{ui/controls/dropdown-button items=controls}}
{{dynamic-component type=contentComponent post=post}}

View File

@ -0,0 +1,8 @@
{{ui/controls/dropdown-button
items=controls
class="contextual-controls"
buttonClass="btn btn-default btn-icon btn-sm btn-blend"
buttonClick="populateControls"
menuClass="pull-right"}}
{{dynamic-component type=contentComponent post=post}}

View File

@ -1,20 +0,0 @@
<a href="#" class="scrubber-first" {{action "firstPost" target="view"}}>Original Post <i class="fa fa-angle-double-up"></i></a>
<div class="scrollbar disabled">
<div class="scrollbar-before"></div>
<div class="scrollbar-slider">
<div class="handle"></div>
<div class="info">
<strong><span class="index">0</span> of <span class="count">{{postStream.count}}</span> posts</strong>
<span class="description"></span>
</div>
</div>
<div class="scrollbar-after"></div>
{{#if relevantPostRanges}}
<ul class="scrollbar-highlights">
{{#each range in relevantPostRanges}}
<li {{bind-attr style=range}}></li>
{{/each}}
</ul>
{{/if}}
</div>
<a href="#" class="scrubber-last" {{action "lastPost" target="view"}}>Now <i class="fa fa-angle-double-down"></i></a>

View File

@ -0,0 +1,7 @@
{{#each item in stream}}
{{#discussions/stream-item item=item stream=stream loadRange="loadRange"}}
{{#if item.content}}
{{dynamic-component type=component content=item.content}}
{{/if}}
{{/discussions/stream-item}}
{{/each}}

View File

@ -0,0 +1,20 @@
<a href="#" class="scrubber-first" {{action "first"}}>{{fa-icon "angle-double-up"}} Original Post</a>
<div class="scrubber-scrollbar">
<div class="scrubber-before"></div>
<div class="scrubber-slider">
<div class="scrubber-handle"></div>
{{#if loaded}}
<div class="scrubber-info">
<strong><span class="index">{{visibleIndex}}</span> of <span class="count">{{count}}</span> posts</strong>
<span class="description">{{description}}</span>
</div>
{{/if}}
</div>
<div class="scrubber-after"></div>
<ul class="scrubber-highlights">
{{#each index in relevantPostIndexes}}
<li {{bind-attr style=index}}></li>
{{/each}}
</ul>
</div>
<a href="#" class="scrubber-last" {{action "last"}}>{{fa-icon "angle-double-down"}} Now</a>

View File

@ -1,8 +1,6 @@
{{#if items}}
<button {{bind-attr class=":dropdown-toggle :btn buttonClass"}} data-toggle="dropdown">
{{fa-icon icon class="icon-glyph"}}
<span class="label">{{title}}</span>
{{fa-icon "caret-down" class="icon-caret"}}
</button>
{{ui/controls/item-list items=items class=dropdownMenuClass}}
{{/if}}
<a href="#" {{bind-attr class=":dropdown-toggle buttonClass"}} data-toggle="dropdown" {{action "buttonClick"}}>
{{fa-icon icon class="icon-glyph"}}
<span class="label">{{title}}</span>
{{fa-icon "caret-down" class="icon-caret"}}
</a>
{{ui/controls/item-list items=items class=dropdownMenuClass}}

View File

@ -1,7 +1,7 @@
{{#if items}}
<button {{bind-attr class=":dropdown-toggle :btn buttonClass"}} data-toggle="dropdown">
<span class="label">{{activeItem.title}}</span>
<a href="#" {{bind-attr class=":dropdown-toggle buttonClass"}} data-toggle="dropdown">
<span class="label">{{activeItem.label}}</span>
{{fa-icon "sort" class="icon-caret"}}
</button>
</a>
{{ui/controls/item-list items=items class=dropdownMenuClass viewName="menu"}}
{{/if}}

View File

@ -1,2 +1,2 @@
{{input type="text" placeholder=placeholder class="form-control" value=value action="search"}}
<button class="clear btn btn-icon">{{fa-icon "times"}}</a>
<button class="clear btn btn-icon btn-link">{{fa-icon "times"}}</button>

View File

@ -1,11 +1,9 @@
<textarea class="form-control" {{bind-attr placeholder=placeholder}}></textarea>
<div class="composer-editor-controls">
<div class="pull-left">
<button class="btn btn-primary">Submit Post</button>
<div class="btn-group">
<button class="btn btn-default"><i class="fa fa-fw fa-image"></i></button>
<button class="btn btn-default"><i class="fa fa-fw fa-paperclip"></i></button>
</div>
<button class="btn btn-primary">Submit Reply</button>
<div class="btn-group">
<button class="btn btn-default btn-icon"><i class="fa fa-fw fa-image"></i></button>
<button class="btn btn-default btn-icon"><i class="fa fa-fw fa-paperclip"></i></button>
</div>
</div>

View File

@ -0,0 +1,7 @@
<div class="container">
<button class="close btn btn-icon btn-link">{{fa-icon "times"}}</button>
<div class="container-narrow">
<h2>Welcome to Flarum Prototype Forum</h2>
<p>Thanks for stopping by! Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Maecenas sed diam eget risus varius blandit sit amet non magna.</p>
</div>
</div>

View File

@ -1,19 +1,29 @@
<div class="composer-handle"></div>
<div class="composer-controls">
<a href="#" {{action "fullScreen"}}>{{fa-icon "fa-expand"}}</a>
<a href="#" {{action "hide"}}>{{fa-icon "fa-caret-square-o-down"}}</a>
<a href="#" {{action "close"}}>{{fa-icon "fa-times"}}</a>
<div class="composer-controls btn-group">
<div class="btn-group dropdown">
<a href="#" {{action "fullScreen"}} class="btn btn-icon btn-link dropdown-toggle" data-toggle="dropdown">{{fa-icon "ellipsis-v"}}</a>
<ul class="dropdown-menu pull-right">
<li><a href="#">{{fa-icon "expand"}} Full Screen</a></li>
<li><a href="#">{{fa-icon "external-link"}} Pop-Out</a></li>
</ul>
</div>
<a href="#" {{action "hide"}} class="btn btn-icon btn-link">{{fa-icon "chevron-down"}}</a>
<a href="#" {{action "close"}} class="btn btn-icon btn-link">{{fa-icon "times"}}</a>
</div>
{{user-avatar user}}
<div class="composer-content">
<div class="composer-body">
{{user-avatar user class="composer-avatar"}}
<h3>{{{title}}}</h3>
<div class="composer-body">
<h3>{{{title}}}</h3>
<div class="composer-editor">
{{ui/controls/text-editor placeholder=""}}
</div>
<div class="composer-editor">
{{ui/controls/text-editor placeholder=""}}
</div>
</div>
</div>

View File

@ -1,15 +1,18 @@
<header class="page-header discussion-header">
<h1>{{title}}</h1>
<header class="hero discussion-hero">
<div class="container">
<h2>{{title}}</h2>
</div>
</header>
<nav class="discussion-nav">
{{ui/controls/item-list items=view.sidebarItems}}
</nav>
<div class="container">
<nav class="discussion-nav">
{{ui/controls/item-list items=view.sidebarItems}}
</nav>
<section class="discussion-posts stream posts">
{{#each item in stream}}
{{#discussions/stream-item item=item loadRange="loadRange"}}
{{#if item.post}}{{discussions/post-full post=item.post}}{{/if}}
{{/discussions/stream-item}}
{{/each}}
</section>
{{discussions/stream-content
viewName="streamContent"
stream=stream
class="discussion-posts posts"
component="discussions/post-wrapper"
updateStart="updateStart"}}
</div>

View File

@ -1,49 +0,0 @@
<div class="discussions-area">
<nav class="discussions-nav">
{{ui/controls/item-list items=view.sidebarItems}}
</nav>
<div class="discussions-results">
<div class="discussions-toolbar">
<div class="discussions-toolbar-view">
<span class="btn-group control-show">
{{#link-to (query-params show="discussions") class="btn btn-default"}}{{fa-icon "bars"}}{{/link-to}}
{{#link-to (query-params show="posts") class="btn btn-default"}}{{fa-icon "square-o"}}{{/link-to}}
</span>
{{ui/controls/select-input class="control-sort" content=sortOptions optionValuePath="content.sort" optionLabelPath="content.label" value=sort}}
</div>
<div class="discussions-toolbar-action">
{{#ui/controls/action-button class="control-markAsRead"}}{{fa-icon "check"}}{{/ui/controls/action-button}}
</div>
</div>
{{#if resultsLoading}}
{{ui/controls/loading-indicator size="small"}}
{{else}}
<ul class="discussions-list">
{{#each discussion in content}}
{{discussions/discussion-listing discussion=discussion searchQuery=searchQuery}}
{{/each}}
</ul>
{{#if moreResults}}
<div class="load-more">
{{#if loadingMore}}
{{ui/controls/loading-indicator size="small"}}
{{else}}
{{#ui/controls/action-button class="control-loadMore" action="loadMore"}}Load More{{/ui/controls/action-button}}
{{/if}}
</div>
{{/if}}
{{/if}}
</div>
</div>
<div class="discussion-area">
{{outlet}}
</div>

View File

@ -1,5 +1,13 @@
<h1>Oops! Something went wrong.</h1>
<header class="hero error-hero">
<div class="container">
<h2>Error</h2>
</div>
</header>
<p>{{message}}</p>
<div class="error-area">
<div class="container">
<p>{{message}}</p>
<pre>{{stack}}</pre>
<pre>{{stack}}</pre>
</div>
</div>

View File

@ -0,0 +1,57 @@
<div {{bind-attr class=":index-area paned paneShowing:showing"}}>
{{welcome-hero}}
<div class="container">
<nav class="index-nav">
{{ui/controls/item-list items=view.sidebarItems}}
</nav>
<div class="index-results">
<div class="index-toolbar">
<div class="index-toolbar-view">
<span class="btn-group control-show">
{{#link-to (query-params show="discussions") class="btn btn-default btn-icon"}}{{fa-icon "bars"}}{{/link-to}}
{{#link-to (query-params show="posts") class="btn btn-default btn-icon"}}{{fa-icon "square-o"}}{{/link-to}}
</span>
{{ui/controls/select-input class="control-sort" content=sortOptions optionValuePath="content.sort" optionLabelPath="content.label" value=sort}}
</div>
<div class="index-toolbar-action">
{{ui/controls/action-button class="control-markAllAsRead btn btn-default btn-icon" icon="check" title="Mark All as Read"}}
</div>
</div>
<ul class="discussions-list">
{{#each discussion in content}}
{{discussions/discussion-listing
discussion=discussion
searchQuery=searchQuery
terminalPostType=terminalPostType
countType=countType}}
{{/each}}
</ul>
{{#if resultsLoading}}
{{ui/controls/loading-indicator size="small"}}
{{/if}}
{{#if moreResults}}
<div class="load-more">
{{#unless resultsLoading}}
{{ui/controls/action-button class="control-loadMore btn btn-default" action="loadMore" label="Load More"}}
{{/unless}}
</div>
{{/if}}
</div>
</div>
</div>
<div class="discussion-area">
{{outlet}}
</div>

View File

@ -1 +1 @@
{{ui/controls/loading-indicator size="large"}}
{{ui/controls/loading-indicator class="loading-indicator-block"}}

View File

@ -1,62 +0,0 @@
// TODO probably change this into an Ember object/merge it into discussion-scrollbar
var Scrollbar = function(element) {
this.$ = $(element);
this.count = 1;
this.index = 0;
this.visible = 1;
this.disabled = false;
};
Scrollbar.prototype = {
setIndex: function(index) {
this.index = index;
},
setVisible: function(visible) {
this.visible = visible;
},
setCount: function(count) {
this.count = count;
},
setDisabled: function(disabled) {
this.disabled = disabled;
},
percentPerPost: function() {
// 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. Subsequently, 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.$.outerHeight() * 100;
var percentPerVisiblePost = Math.max(100 / this.count, minPercentVisible / this.visible);
var percentPerPost = this.count == this.visible ? 0 : (100 - percentPerVisiblePost * this.visible) / (this.count - this.visible);
return {
index: percentPerPost,
visible: percentPerVisiblePost
};
},
update: function(animate) {
var percentPerPost = this.percentPerPost();
var before = percentPerPost.index * this.index,
slider = Math.min(100 - before, percentPerPost.visible * this.visible),
func = animate ? 'animate' : 'css';
this.$.find('.scrollbar-before').stop(true)[func]({height: before+'%'}).css('overflow', 'visible');
this.$.find('.scrollbar-slider').stop(true)[func]({height: slider+'%'}).css('overflow', 'visible');
this.$.find('.scrollbar-after').stop(true)[func]({height: (100 - before - slider)+'%'}).css('overflow', 'visible');
this.$.toggleClass('disabled', this.disabled || slider >= 100);
}
};
export default Scrollbar;

View File

@ -1,9 +1,111 @@
import Ember from 'ember';
import ActionButton from '../components/ui/controls/action-button';
import SearchInput from '../components/ui/controls/search-input';
import DropdownSelect from '../components/ui/controls/dropdown-select';
import TaggedArray from '../utils/tagged-array';
export default Ember.View.extend({
title: function() {
return this.get('controller.forumTitle');
}.property('controller.forumTitle')
}.property('controller.forumTitle'),
didInsertElement: function() {
// Create and populate an array of items to be rendered in the header.
this.set('headerPrimaryItems', TaggedArray.create());
this.set('headerSecondaryItems', TaggedArray.create());
this.trigger('populateHeader', this.get('headerPrimaryItems'), this.get('headerSecondaryItems'));
// Create and populate an array of items to be rendered in the footer.
this.set('footerPrimaryItems', TaggedArray.create());
this.set('footerSecondaryItems', TaggedArray.create());
this.trigger('populateFooter', this.get('footerPrimaryItems'), this.get('footerSecondaryItems'));
// Add a class to the body when the window is scrolled down.
$(window).scroll(function() {
$('body').toggleClass('scrolled', $(window).scrollTop() > 0);
}).scroll();
// Resize the main content area so that the footer sticks to the
// bottom of the viewport.
$(window).resize(function() {
$('#main').css('min-height', $(window).height() - $('#header').outerHeight() - $('#footer').outerHeight(true));
}).resize();
},
populateHeaderDefault: function(primary, secondary) {
var controller = this.get('controller');
var search = SearchInput.create({
placeholder: 'Search Forum',
controller: controller,
valueBinding: Ember.Binding.oneWay('controller.searchQuery'),
activeBinding: Ember.Binding.oneWay('controller.searchActive'),
action: function(value) {
controller.send('search', value);
}
});
secondary.pushObjectWithTag(search, 'search');
var signUp = ActionButton.create({
label: 'Sign Up',
className: 'btn btn-link'
});
secondary.pushObjectWithTag(signUp, 'signUp');
var logIn = ActionButton.create({
label: 'Log In',
className: 'btn btn-link'
});
secondary.pushObjectWithTag(logIn, 'logIn');
}.on('populateHeader'),
populateFooterDefault: function(primary, secondary) {
primary.pushObjectWithTag(ActionButton.create({
icon: 'arrow-up',
action: function() { $('html, body').stop(true).animate({scrollTop: 0}); },
title: 'Go to Top',
class: 'control-top'
}), 'top');
primary.pushObjectWithTag(Ember.Component.create({
layout: Ember.Handlebars.compile('{{discussions}} discussions'),
discussions: 12
}), 'statistics.discussions');
primary.pushObjectWithTag(Ember.Component.create({
layout: Ember.Handlebars.compile('{{posts}} posts'),
posts: 12
}), 'statistics.posts');
primary.pushObjectWithTag(Ember.Component.create({
layout: Ember.Handlebars.compile('{{users}} users'),
users: 12
}), 'statistics.users');
primary.pushObjectWithTag(Ember.Component.create({
layout: Ember.Handlebars.compile('{{online}} online'),
online: 12
}), 'statistics.online');
var languages = TaggedArray.create();
languages.pushObjectWithTag(Ember.Component.create({
layout: Ember.Handlebars.compile('<a href="#">{{label}}</a>'),
label: 'English',
tagName: 'li',
classNameBindings: ['active'],
active: true
}));
secondary.pushObjectWithTag(DropdownSelect.create({
buttonClass: '',
class: 'dropup',
items: languages
}), 'language');
secondary.pushObjectWithTag(Ember.Component.create({
layout: Ember.Handlebars.compile('<a href="http://flarum.org" target="_blank">Powered by Flarum</a>'),
}), 'poweredBy');
}.on('populateFooter'),
});

View File

@ -3,35 +3,36 @@ import Ember from 'ember';
export default Ember.View.extend({
classNames: ['composer'],
classNameBindings: ['controller.showing:showing'],
showingChanged: function() {
if (this.$()) {
var view = this;
this.$().animate({bottom: this.get('controller.showing') ? 20 : -this.$().height()}, 'fast', function() {
if (view.get('controller.showing')) {
$(this).find('textarea').focus();
}
});
$('#body').animate({marginBottom: this.get('controller.showing') ? this.$().height() + 20 : 0}, 'fast');
}
}.observes('controller.showing'),
// classNameBindings: ['controller.showing:showing'],
panePinnedChanged: function() {
if (this.$()) {
var discussions = this.get('controller.controllers.discussions');
var $this = this.$();
Ember.run.scheduleOnce('afterRender', function() {
var discussion = $('.discussion-pane');
var width = discussion.length ? discussion.offset().left : $('#body').offset().left;
$this.css('left', width);
});
}
}.observes('controller.controllers.discussions.paned', 'controller.controllers.discussions.panePinned'),
// showingChanged: function() {
// if (this.$()) {
// var view = this;
// this.$().animate({bottom: this.get('controller.showing') ? 20 : -this.$().height()}, 'fast', function() {
// if (view.get('controller.showing')) {
// $(this).find('textarea').focus();
// }
// });
// $('#body').animate({marginBottom: this.get('controller.showing') ? this.$().height() + 20 : 0}, 'fast');
// }
// }.observes('controller.showing'),
// panePinnedChanged: function() {
// if (this.$()) {
// var discussions = this.get('controller.controllers.discussions');
// var $this = this.$();
// Ember.run.scheduleOnce('afterRender', function() {
// var discussion = $('.discussion-pane');
// var width = discussion.length ? discussion.offset().left : $('#body').offset().left;
// $this.css('left', width);
// });
// }
// }.observes('controller.controllers.discussions.paned', 'controller.controllers.discussions.panePinned'),
didInsertElement: function() {
this.showingChanged();
this.panePinnedChanged();
// this.showingChanged();
// this.panePinnedChanged();
}
});

View File

@ -4,249 +4,95 @@ import TaggedArray from '../utils/tagged-array';
import ActionButton from '../components/ui/controls/action-button';
import DropdownSplit from '../components/ui/controls/dropdown-split';
import DropdownButton from '../components/ui/controls/dropdown-button';
import DiscussionScrollbar from '../components/discussions/stream-scrollbar';
import PostStreamMixin from '../mixins/post-stream';
import StreamScrubber from '../components/discussions/stream-scrubber';
export default Ember.View.extend(Ember.Evented, PostStreamMixin, {
export default Ember.View.extend(Ember.Evented, {
sidebarItems: Ember.ContainerView,
sidebarItems: null,
// Set up a new menu view that will contain controls to be shown in the
// footer. The template will only render these controls if the last post is
// showing.
construct: function() {
// this.set('footerControls', this.createChildView(Menu));
// this.set('footerControls.controller', this.get('controller'));
// console.log(this.get('controller'));
}.on('init'),
didInsertElement: function() {
// Create and populate an array of items to be rendered in the sidebar.
var sidebarItems = TaggedArray.create();
this.trigger('populateSidebar', sidebarItems);
this.set('sidebarItems', sidebarItems);
// Whenever the model's title changes, we want to update that document's
// title the reflect the new title.
updateTitle: function() {
this.set('controller.controllers.application.pageTitle', this.get('controller.model.title'));
}.observes('controller.model.title'),
// By this stage the discussion controller has initialized the post
// stream object, and there may or may not be posts loaded into it.
// Either way, we want to tell our stream content component to jump
// down to the start position specified in the controller's query
// params.
this.loadStreamContentForNewDiscussion();
didInsertElement: function() {
// For that matter, whenever the controller's start query param
// changes, we want to tell our stream content component to jump down
// to it.
this.get('controller').on('startWasChanged', this, this.goToNumber);
},
// We've just inserted the discussion view.
// this.trigger('populateSidebar', this.get('sidebar'));
willDestroyElement: function() {
this.get('controller').off('startWasChanged', this, this.goToNumber);
},
// Whenever the window's scroll position changes, we want to check to
// see if any terminal 'gaps' are in the viewport and trigger their
// loading mechanism if they are. We also want to update the
// controller's 'start' query param with the current position.
$(window).on('scroll', {view: this}, this.windowWasScrolled);
goToNumber: function(start) {
// We can only proceed if the controller has loaded the discussion
// details and the view has been rendered.
if (this.get('controller.loaded') && this.get('streamContent')) {
this.get('streamContent').send('goToNumber', start);
}
},
// We need to listen for some events on the controller. Whenever the
// controller says that it's loading or has loaded posts near a certain
// post number, we want to scroll down to this post (or the gap which
// the post is in) and highlight it.
var controller = this.get('controller');
controller.on('loadingNumber', this, this.loadingNumber);
controller.on('loadedNumber', this, this.loadedNumber);
controller.on('loadingIndex', this, this.loadingIndex);
controller.on('loadedIndex', this, this.loadedIndex);
},
// ------------------------------------------------------------------------
// OBSERVERS
// ------------------------------------------------------------------------
willDestroyElement: function() {
$(window).off('scroll', this.windowWasScrolled);
// Whenever the controller has switched out the old discussion model for a
// new one, we want to
loadStreamContentForNewDiscussion: function() {
if (this.get('controller.loaded')) {
this.goToNumber(this.get('controller.start'));
}
}.observes('controller.loaded'),
var controller = this.get('controller');
controller.off('loadingNumber', this, this.loadingNumber);
controller.off('loadedNumber', this, this.loadedNumber);
controller.off('loadingIndex', this, this.loadingIndex);
controller.off('loadedIndex', this, this.loadedIndex);
},
// Whenever the model's title changes, we want to update that document's
// title the reflect the new title.
updateTitle: function() {
this.set('controller.controllers.application.pageTitle', this.get('controller.model.title'));
}.observes('controller.model.title'),
setupSidebar: function(sidebar) {
var items = TaggedArray.create();
this.trigger('populateControls', items);
sidebarItems.pushObject(DropdownSplit.create({
items: items,
icon: 'reply',
buttonClass: 'btn-primary',
menuClass: 'pull-right'
}), 'controls');
// ------------------------------------------------------------------------
// LISTENERS
// ------------------------------------------------------------------------
sidebar.pushObject(DropdownButton.create({items: this.get('controls')}));
populateSidebarDefault: function(sidebar) {
var controls = TaggedArray.create();
this.trigger('populateControls', controls);
sidebar.pushObjectWithTag(DropdownSplit.create({
items: controls,
icon: 'reply',
buttonClass: 'btn-primary'
}), 'controls');
sidebar.pushObject(DiscussionScrollbar.create());
}.on('populateSidebar'),
sidebar.pushObjectWithTag(StreamScrubber.create({
streamContent: this.get('streamContent')
}), 'scrubber');
}.on('populateSidebar'),
setupControls: function(controls) {
var view = this;
var ReplyItem = MenuItem.extend({
title: 'Reply',
icon: 'reply',
classNameBindings: ['className', 'replying:disabled'],
replying: function() {
return this.get('parentController.controllers.composer.showing');
}.property('parentController.controllers.composer.showing'),
action: function() {
var lastPost = $('.posts .item:last');
$('html, body').animate({scrollTop: lastPost.offset().top + lastPost.outerHeight() - $(window).height() + $('.composer').height() + 19}, 'fast');
view.get('controller').send('reply');
},
parentController: this.get('controller'),
});
controls.addItem('reply', ReplyItem);
}.on('populateControls'),
// This function handles the window's scroll event. 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.
windowWasScrolled: function(event) {
var view = event.data.view;
if (! view.get('controller.loaded') || $(window).data('disableScrollHandler')) {
return;
}
var posts = view.$().find('.posts'),
$this = $(this),
scrollTop = $this.scrollTop(),
viewportHeight = $this.height(),
firstItem = posts.find('.item[data-start=0]'),
firstItemOffset = firstItem.length ? firstItem.offset().top : 0,
currentNumber;
// 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.
posts.find('.item').each(function() {
var $this = $(this),
top = $this.offset().top - firstItemOffset,
height = $this.outerHeight();
// If this item is above the top of the viewport, skip to the
// next one. If it's below the bottom of the viewport, break
// out of the loop.
if (top + height < scrollTop) {
return;
}
if (top > scrollTop + viewportHeight) {
return false;
}
// Now we know that this item is in the viewport. 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.
! currentNumber && (currentNumber = $this.data('number'));
// If this item is a gap, then we may proceed to check if it's
// a *terminal* gap and trigger its loading mechanism.
var gapView;
if ($this.hasClass('gap') && (gapView = Ember.View.views[$this.attr('id')])) {
if ($this.is(':first-of-type')) {
gapView.set('direction', 'up').load();
}
else if ($this.is(':last-of-type')) {
gapView.set('direction', 'down').load();
}
}
});
// Finally, we want to update the controller's state with regards to the
// current viewing position of the discussion. However, we don't want to
// do this on every single scroll event as it will slow things down. So,
// let's do it at a minimum of 250ms by clearing and setting a timeout.
clearTimeout(this.updateStateTimeout);
this.updateStateTimeout = setTimeout(function() {
view.get('controller').set('start', currentNumber || 1);
}, 250);
},
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.
$(window).data('disableScrollHandler', 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;
this.jumpToNumber(number, function() {
$(window).data('disableScrollHandler', false).scroll();
});
},
// Scroll down to a certain post (or the gap which the post is in) and
// highlight it.
jumpToNumber: function(number, finish) {
// Clear the highlight class from all posts, and attempt to find and
// highlight a post with the specified number.
var item = this.$()
.find('.posts .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);
}
// We have an item to scroll to now. Let's get its position and animate
// a scroll-down!
if (item.length) {
$('html, body').stop(true).animate({scrollTop: number > 1 ? item.offset().top : 0});
}
if (finish) {
$('html, body').promise().done(finish);
}
},
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.
$(window).data('disableScrollHandler', 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;
this.jumpToIndex(index, function() {
$(window).data('disableScrollHandler', false).scroll();
});
},
jumpToIndex: function(index, finish) {
var item = this.findNearestToIndex(index);
// We have an item to scroll to now. Let's get its position and animate
// a scroll-down!
if (item.length) {
$('html, body').stop(true).animate({scrollTop: index > 0 ? item.offset().top : 0});
}
if (finish) {
$('html, body').promise().done(finish);
}
},
// 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() {
if (this.get('controller.loaded')) {
Ember.run.scheduleOnce('afterRender', function() {
$(window).scroll();
});
}
}.observes('controller.loaded')
populateControlsDefault: function(controls) {
var view = this;
var ReplyItem = ActionButton.extend({
label: 'Reply',
icon: 'reply',
classNameBindings: ['className', 'replying:disabled'],
replying: function() {
return this.get('parentController.controllers.composer.showing');
}.property('parentController.controllers.composer.showing'),
action: function() {
var lastPost = $('.posts .item:last');
$('html, body').animate({scrollTop: lastPost.offset().top + lastPost.outerHeight() - $(window).height() + $('.composer').height() + 19}, 'fast');
view.get('controller').send('reply');
},
parentController: this.get('controller'),
});
controls.pushObjectWithTag(ReplyItem.create(), 'reply');
}.on('populateControls')
});

View File

@ -1,101 +0,0 @@
import Ember from 'ember';
import DropdownSelect from '../components/ui/controls/dropdown-select';
import ActionButton from '../components/ui/controls/action-button';
import NavItem from '../components/ui/items/nav-item';
import TaggedArray from '../utils/tagged-array';
export default Ember.View.extend({
sidebarItems: null,
classNameBindings: ['pinned'],
pinned: function() {
return this.get('controller.panePinned');
}.property('controller.panePinned'),
didInsertElement: function() {
var sidebarItems = TaggedArray.create();
this.trigger('populateSidebar', sidebarItems);
this.set('sidebarItems', sidebarItems);
var view = this;
this.$().find('.discussions-pane').on('mouseenter', function() {
if (! $(this).hasClass('paned')) return;
clearTimeout(view.get('controller.paneTimeout'));
view.set('controller.paneShowing', true);
}).on('mouseleave', function() {
view.set('controller.paneShowing', false);
});
if (this.get('controller.test') !== null) {
var row = this.$().find('li[data-id='+this.get('controller.controllers.application.resultStream.currentResult.id')+']');
if (row.length) {
row.addClass('highlight');
}
// TODO: work out if the highlighted row is in view of the saved scroll position.
// If it isn't, don't use the saved scroll position - generate a new one.
$(window).scrollTop(this.get('controller.test'));
this.set('controller.test', null);
}
var self = this;
$(window).on('scroll.loadMore', function() {
if (self.get('controller.loadingMore') || ! self.get('controller.moreResults')) {
return;
}
var w = $(window),
d = $('.discussions'),
curPos = w.scrollTop() + w.height(),
endPos = d.offset().top + d.height() - 200;
if (curPos > endPos) {
self.get('controller').send('loadMore');
}
});
},
populateSidebarDefault: function(sidebar) {
var newDiscussion = ActionButton.create({
title: 'Start a Discussion',
icon: 'edit',
class: 'btn-primary'
})
sidebar.pushObjectWithTag(newDiscussion, 'newDiscussion');
var nav = TaggedArray.create();
this.trigger('populateNav', nav);
sidebar.pushObjectWithTag(DropdownSelect.createWithItems(nav), 'nav');
}.on('populateSidebar'),
populateNavDefault: function(nav) {
nav.pushObjectWithTag(NavItem.create({
title: 'All Discussions',
icon: 'comments-o',
linkTo: '"discussions" (query-params filter="")'
}), 'all');
nav.pushObjectWithTag(NavItem.create({
title: 'Private',
icon: 'envelope-o',
linkTo: '"discussions" (query-params filter="private")'
}), 'private');
nav.pushObjectWithTag(NavItem.create({
title: 'Following',
icon: 'star',
linkTo: '"discussions" (query-params filter="following")'
}), 'following');
}.on('populateNav'),
willDestroyElement: function() {
this.set('controller.test', $(window).scrollTop());
$(window).off('scroll.loadMore');
}
});

View File

@ -0,0 +1,91 @@
import Ember from 'ember';
import DropdownSelect from '../components/ui/controls/dropdown-select';
import ActionButton from '../components/ui/controls/action-button';
import NavItem from '../components/ui/items/nav-item';
import TaggedArray from '../utils/tagged-array';
export default Ember.View.extend({
sidebarItems: null,
didInsertElement: function() {
// Create and populate an array of items to be rendered in the sidebar.
var sidebarItems = TaggedArray.create();
this.trigger('populateSidebar', sidebarItems);
this.set('sidebarItems', sidebarItems);
// Affix the sidebar so that when the user scrolls down it will stick
// to the top of their viewport.
var $sidebar = this.$().find('.index-nav');
$sidebar.find('> ul').affix({
offset: {
top: function () {
return $sidebar.offset().top - $('#header').outerHeight(true) - parseInt($sidebar.css('margin-top'));
},
bottom: function () {
return (this.bottom = $('#footer').outerHeight(true))
}
}
});
// When viewing a discussion (for which the discussions route is the
// parent,) the discussion list is still rendered but it becomes a
// pane hidden on the side of the screen. When the mouse enters and
// leaves the discussions pane, we want to show and hide the pane
// respectively. We also create a 10px 'hot edge' on the left of the
// screen to activate the pane.
var controller = this.get('controller');
this.$('.index-area').hover(function() {
controller.send('showPane');
}, function() {
controller.send('hidePane');
});
$(document).on('mousemove.showPane', function(e) {
if (e.pageX < 10) {
controller.send('showPane');
}
});
},
willDestroyElement: function() {
$(document).off('mousemove.showPane');
},
populateSidebarDefault: function(sidebar) {
var newDiscussion = ActionButton.create({
label: 'Start a Discussion',
icon: 'edit',
className: 'btn btn-primary new-discussion'
})
sidebar.pushObjectWithTag(newDiscussion, 'newDiscussion');
var nav = TaggedArray.create();
this.trigger('populateNav', nav);
sidebar.pushObjectWithTag(DropdownSelect.create({
items: nav
}), 'nav');
}.on('populateSidebar'),
populateNavDefault: function(nav) {
nav.pushObjectWithTag(NavItem.create({
label: 'All Discussions',
icon: 'comments-o',
linkTo: '"index" (query-params filter="")'
}), 'all');
// The below items are just temporary; they will be extracted into
// extensions in the future.
nav.pushObjectWithTag(NavItem.create({
label: 'Private',
icon: 'envelope-o',
linkTo: '"index" (query-params filter="private")'
}), 'private');
nav.pushObjectWithTag(NavItem.create({
label: 'Following',
icon: 'star',
linkTo: '"index" (query-params filter="following")'
}), 'following');
}.on('populateNav')
});

View File

@ -1,12 +1,14 @@
import Ember from 'ember';
export default Ember.View.extend({
_updateTitle: function() {
var q = this.get('controller.searchQuery');
this.get('controller.controllers.application').set('pageTitle', q ? '"'+q+'"' : '');
}.observes('controller.searchQuery'),
didInsertElement: function() {
this._updateTitle();
}
this.updateTitle();
},
updateTitle: function() {
var q = this.get('controller.searchQuery');
this.get('controller.controllers.application').set('pageTitle', q ? '"'+q+'"' : '');
}.observes('controller.searchQuery')
});

View File

@ -13,7 +13,7 @@
"ember-qunit": "0.1.8",
"ember-qunit-notifications": "0.0.4",
"qunit": "~1.15.0",
"bootstrap": "~3.3.0",
"bootstrap": "~3.2.0",
"font-awesome": "~4",
"spin.js": "~2.0.1",
"pace": "~0.7.1",