1
0
mirror of https://github.com/flarum/core.git synced 2025-08-03 23:17:43 +02:00

Upgrade to Ember 1.11-beta.1

HTMLBars goodness! Since there was some breakage and a lot of fiddling
around to get some things working, I took this opportunity to do a big
cleanup of the whole Ember app. I accidentally worked on some new
features too :3

Note that the app is still broken right now, pending on
https://github.com/emberjs/ember.js/issues/10401

Cleanup:
- Restructuring of components
- Consolidation of some stuff into mixins, cleanup of some APIs that
will be public
- Change all instances of .property() / .observes() / .on() to
Ember.computed() / Ember.observer() / Ember.on() respectively (I think
it is more readable)
- More comments
- Start conforming to a code style (2 spaces for indentation)

New features:
- Post hiding/restoring
- Mark individual discussions as read by clicking
- Clicking on a read discussion jumps to the end
- Mark all discussions as read
- Progressively mark the discussion as read as the page is scrolled
- Unordered list post formatting
- Post permalink popup

Demo once that Ember regression is fixed!
This commit is contained in:
Toby Zerner
2015-02-10 18:05:40 +10:30
parent cf88fda8c8
commit c28307903b
164 changed files with 4623 additions and 4587 deletions

View File

@@ -2,7 +2,13 @@
var EmberApp = require('ember-cli/lib/broccoli/ember-app');
var app = new EmberApp();
var app = new EmberApp({
vendorFiles: {
'handlebars.js': null
}
});
app.import('bower_components/ember/ember-template-compiler.js');
app.import('bower_components/bootstrap/dist/js/bootstrap.js');
app.import('bower_components/spin.js/spin.js');

53
ember/README.md Normal file
View File

@@ -0,0 +1,53 @@
# Flarum
This README outlines the details of collaborating on this Ember application.
A short introduction of this app could easily go here.
## Prerequisites
You will need the following things properly installed on your computer.
* [Git](http://git-scm.com/)
* [Node.js](http://nodejs.org/) (with NPM)
* [Bower](http://bower.io/)
* [Ember CLI](http://www.ember-cli.com/)
* [PhantomJS](http://phantomjs.org/)
## Installation
* `git clone <repository-url>` this repository
* change into the new directory
* `npm install`
* `bower install`
## Running / Development
* `ember server`
* Visit your app at [http://localhost:4200](http://localhost:4200).
### Code Generators
Make use of the many generators for code, try `ember help generate` for more details
### Running Tests
* `ember test`
* `ember test --server`
### Building
* `ember build` (development)
* `ember build --environment production` (production)
### Deploying
Specify what it takes to deploy your app.
## Further Reading / Useful Links
* [ember.js](http://emberjs.com/)
* [ember-cli](http://www.ember-cli.com/)
* Development Browser Extensions
* [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi)
* [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)

View File

@@ -1,24 +1,38 @@
import DS from 'ember-data';
import JsonApiAdapter from 'ember-json-api/json-api-adapter';
import config from '../config/environment';
import config from 'flarum/config/environment';
import AlertMessage from 'flarum/components/ui/alert-message';
export default JsonApiAdapter.extend({
host: config.apiURL,
ajaxError: function(jqXHR) {
var errors = this._super(jqXHR);
// Reparse the errors in accordance with the JSON-API spec to fit with
// Ember Data style. Hopefully something like this will eventually be a
// part of the JsonApiAdapter.
if (errors instanceof DS.InvalidError) {
var newErrors = {};
for (var i in errors.errors) {
var error = errors.errors[i];
newErrors[error.path] = error.detail;
}
errors = new DS.InvalidError(newErrors);
} else if (errors instanceof JsonApiAdapter.ServerError) {
// @todo show an alert message
console.log(errors);
}
return errors;
return new DS.InvalidError(newErrors);
}
// If it's a server error, show an alert message. The alerts controller
// has been injected into this adapter.
if (errors instanceof JsonApiAdapter.ServerError) {
var message = AlertMessage.create({
type: 'warning',
message: errors.message
});
this.get('alerts').send('alert', message);
return;
}
return errors;
}
});

View File

@@ -1,44 +0,0 @@
import Ember from 'ember';
import TaggedArray from '../utils/tagged-array';
import ActionButton from 'flarum/components/ui/controls/action-button';
export default Ember.Component.extend(Ember.Evented, {
message: '',
type: '',
dismissable: true,
layoutName: 'components/alert-message',
classNames: ['alert'],
classNameBindings: ['classForType'],
classForType: function() {
return 'alert-'+this.get('type');
}.property('type'),
didInsertElement: function() {
var controls = TaggedArray.create();
this.trigger('populateControls', controls);
this.set('controls', controls);
},
populateControls: function(controls) {
if (this.get('dismissable')) {
var component = this;
var dismiss = ActionButton.create({
icon: 'times',
className: 'btn btn-icon btn-link',
action: function() {
component.send('dismiss');
}
});
controls.pushObjectWithTag(dismiss, 'dismiss');
}
},
actions: {
dismiss: function() {
this.sendAction('dismiss', this);
}
}
});

View File

@@ -0,0 +1,30 @@
import Ember from 'ember';
/**
The back/pin button group in the top-left corner of Flarum's interface.
*/
export default Ember.Component.extend({
classNames: ['back-button'],
classNameBindings: ['active'],
active: Ember.computed.or('target.paneIsShowing', 'target.paneIsPinned'),
mouseEnter: function() {
this.get('target').send('showPane');
},
mouseLeave: function() {
this.get('target').send('hidePane');
},
actions: {
back: function() {
this.get('target').send('transitionFromBackButton');
this.set('target', null);
},
togglePinned: function() {
this.get('target').send('togglePinned');
}
}
});

View File

@@ -0,0 +1,7 @@
import Ember from 'ember';
var precompileTemplate = Ember.Handlebars.compile;
export default Ember.Component.extend({
layout: precompileTemplate('{{number}} {{label}}')
});

View File

@@ -0,0 +1,12 @@
import Ember from 'ember';
import ActionButton from 'flarum/components/ui/action-button';
export default ActionButton.extend({
title: 'Go to Top',
icon: 'arrow-up',
className: 'control-top',
action: function() {
$('html, body').stop(true).animate({scrollTop: 0});
}
})

View File

@@ -0,0 +1,7 @@
import Ember from 'ember';
var precompileTemplate = Ember.Handlebars.compile;
export default Ember.Component.extend({
layout: precompileTemplate('<a href="http://flarum.org" target="_blank">Powered by Flarum</a>')
});

View File

@@ -0,0 +1,27 @@
import Ember from 'ember';
import HasItemLists from 'flarum/mixins/has-item-lists';
import DropdownButton from 'flarum/components/ui/dropdown-button';
import SeparatorItem from 'flarum/components/ui/separator-item';
export default DropdownButton.extend(HasItemLists, {
layoutName: 'components/application/user-dropdown',
itemLists: ['items'],
buttonClass: 'btn btn-default btn-naked btn-rounded btn-user',
menuClass: 'pull-right',
label: Ember.computed.alias('user.username'),
populateItems: function(items) {
this.addActionItem(items, 'profile', 'Profile', 'user');
this.addActionItem(items, 'settings', 'Settings', 'cog');
items.pushObject(SeparatorItem.create());
this.addActionItem(items, 'logout', 'Log Out', 'sign-out', null, null, this);
},
actions: {
logout: function() {
this.logout();
}
}
})

View File

@@ -1,27 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['back-button'],
classNameBindings: ['active'],
active: Ember.computed.or('target.paneIsShowing', 'target.paneIsPinned'),
mouseEnter: function() {
this.get('target').send('showPane');
},
mouseLeave: function() {
this.get('target').send('hidePane');
},
actions: {
back: function() {
this.get('target').send('transitionFromBackButton');
this.set('target', null);
},
togglePinned: function() {
this.get('target').send('togglePinned');
}
}
});

View File

@@ -0,0 +1,40 @@
import Ember from 'ember';
import HasItemLists from 'flarum/mixins/has-item-lists';
/**
This component is a base class for a composer body. It provides a template
with a list of controls, a text editor, and some default behaviour.
*/
export default Ember.Component.extend(HasItemLists, {
layoutName: 'components/composer/composer-body',
itemLists: ['controls'],
submitLabel: '',
placeholder: '',
content: '',
originalContent: '',
user: null,
submit: null,
loading: false,
confirmExit: '',
disabled: Ember.computed.alias('composer.minimized'),
actions: {
submit: function(content) {
this.get('submit')({
content: content
});
},
willExit: function(abort) {
// If the user has typed something, prompt them before exiting
// this composer state.
if (this.get('content') !== this.get('originalContent') && !confirm(this.get('confirmExit'))) {
abort();
}
}
}
});

View File

@@ -0,0 +1,41 @@
import Ember from 'ember';
import ComposerBody from 'flarum/components/composer/composer-body';
var precompileTemplate = Ember.Handlebars.compile;
/**
The composer body for starting a new discussion. Adds a text field as a
control so the user can enter the title of their discussion. Also overrides
the `submit` and `willExit` actions to account for the title.
*/
export default ComposerBody.extend({
submitLabel: 'Post Discussion',
confirmExit: 'You have not posted your discussion. Do you wish to discard it?',
titlePlaceholder: 'Discussion Title',
title: '',
populateControls: function(items) {
var title = Ember.Component.create({
tagName: 'h3',
layout: precompileTemplate('{{ui/text-input value=component.title class="form-control" placeholder=component.titlePlaceholder disabled=component.disabled autoGrow=true}}'),
component: this
});
items.pushObjectWithTag(title, 'title');
},
actions: {
submit: function(content) {
this.get('submit')({
title: this.get('title'),
content: content
});
},
willExit: function(abort) {
if ((this.get('title') || this.get('content')) && !confirm(this.get('confirmExit'))) {
abort();
}
}
}
});

View File

@@ -0,0 +1,26 @@
import Ember from 'ember';
import ComposerBody from 'flarum/components/composer/composer-body';
var precompileTemplate = Ember.Handlebars.compile;
/**
The composer body for editing a post. Sets the initial content to the
content of the post that is being edited, and adds a title control to
indicate which post is being edited.
*/
export default ComposerBody.extend({
submitLabel: 'Save Changes',
content: Ember.computed.oneWay('post.content'),
originalContent: Ember.computed.oneWay('post.content'),
populateControls: function(controls) {
var title = Ember.Component.create({
tagName: 'h3',
layout: precompileTemplate('Editing Post #{{component.post.number}} in <em>{{discussion.title}}</em>'),
discussion: this.get('post.discussion'),
component: this
});
controls.pushObjectWithTag(title, 'title');
}
});

View File

@@ -0,0 +1,22 @@
import Ember from 'ember';
import ComposerBody from 'flarum/components/composer/composer-body';
var precompileTemplate = Ember.Handlebars.compile;
/**
The composer body for posting a reply. Adds a title control to indicate
which discussion is being replied to.
*/
export default ComposerBody.extend({
submitLabel: 'Post Reply',
populateControls: function(items) {
var title = Ember.Component.create({
tagName: 'h3',
layout: precompileTemplate('Replying to <em>{{component.discussion.title}}</em>'),
component: this
});
items.pushObjectWithTag(title, 'title');
}
});

View File

@@ -0,0 +1,107 @@
import Ember from 'ember';
import UseComposer from 'flarum/mixins/use-composer';
import FadeIn from 'flarum/mixins/fade-in';
import HasItemLists from 'flarum/mixins/has-item-lists';
import ComposerEdit from 'flarum/components/composer/composer-edit';
import PostHeaderUser from 'flarum/components/discussion/post-header/user';
import PostHeaderMeta from 'flarum/components/discussion/post-header/meta';
import PostHeaderEdited from 'flarum/components/discussion/post-header/edited';
import PostHeaderToggle from 'flarum/components/discussion/post-header/toggle';
var precompileTemplate = Ember.Handlebars.compile;
/**
Component for a `comment`-typed post. Displays a number of item lists
(controls, header, and footer) surrounding the post's HTML content. Allows
the post to be edited with the composer, hidden, or restored.
*/
export default Ember.Component.extend(FadeIn, HasItemLists, UseComposer, {
layoutName: 'components/discussion/post-comment',
tagName: 'article',
classNames: ['post', 'post-comment'],
classNameBindings: [
'post.isHidden:deleted',
'post.isEdited:edited',
'revealContent:reveal-content'
],
itemLists: ['controls', 'header', 'footer'],
// The stream-content component instansiates this component and sets the
// `content` property to the content of the item in the post-stream object.
// This happens to be our post model!
post: Ember.computed.alias('content'),
populateControls: function(items) {
if (this.get('post.isHidden')) {
this.addActionItem(items, 'restore', 'Restore', 'reply', 'post.canEdit');
this.addActionItem(items, 'delete', 'Delete', 'times', 'post.canDelete');
} else {
this.addActionItem(items, 'edit', 'Edit', 'pencil', 'post.canEdit');
this.addActionItem(items, 'hide', 'Delete', 'times', 'post.canEdit');
}
},
// Since we statically populated controls based on the value of
// `post.isHidden`, we'll need to refresh them every time that property
// changes.
refreshControls: Ember.observer('post.isHidden', function() {
this.initItemList('controls');
}),
populateHeader: function(items) {
var properties = this.getProperties('post');
items.pushObjectWithTag(PostHeaderUser.create(properties), 'user');
items.pushObjectWithTag(PostHeaderMeta.create(properties), 'meta');
items.pushObjectWithTag(PostHeaderEdited.create(properties), 'edited');
items.pushObjectWithTag(PostHeaderToggle.create(properties, {parent: this}), 'toggle');
},
savePost: function(post, data) {
post.setProperties(data);
return this.saveAndDismissComposer(post);
},
actions: {
// In the template, we render the "controls" dropdown with the contents of
// the `renderControls` property. This way, when a post is initially
// rendered, it doesn't have to go to the trouble of rendering the
// controls right away, which speeds things up. When the dropdown button
// is clicked, this will fill in the actual controls.
renderControls: function() {
this.set('renderControls', this.get('controls'));
},
edit: function() {
var post = this.get('post');
var component = this;
this.showComposer(function() {
return ComposerEdit.create({
user: post.get('user'),
post: post,
submit: function(data) { component.savePost(post, data); }
});
});
},
hide: function() {
var post = this.get('post');
post.setProperties({
isHidden: true,
deleteTime: new Date,
deleteUser: this.get('session.user')
});
post.save();
},
restore: function() {
var post = this.get('post');
post.setProperties({
isHidden: false,
deleteTime: null,
deleteUser: null
});
post.save();
}
}
});

View File

@@ -0,0 +1,38 @@
import Ember from 'ember';
import humanTime from 'flarum/utils/human-time';
var precompileTemplate = Ember.Handlebars.compile;
/**
Component for the edited pencil icon in a post header. Shows a tooltip on
hover which details who edited the post and when.
*/
export default Ember.Component.extend({
tagName: 'span',
classNames: ['post-edited'],
attributeBindings: ['title'],
layout: precompileTemplate('{{fa-icon "pencil"}}'),
title: Ember.computed('post.editTime', 'post.editUser', function() {
return 'Edited by '+this.get('post.editUser.username')+' '+humanTime(this.get('post.editTime'));
}),
// In the context of an item list, this item will be hidden if the post
// hasn't been edited, or if it's been hidden.
hideItem: Ember.computed('post.isEdited', 'post.isHidden', function() {
return !this.get('post.isEdited') || this.get('post.isHidden');
}),
didInsertElement: function() {
this.$().tooltip();
},
// Whenever the title changes, we need to tell the tooltip to update to
// reflect the new value.
updateTooltip: Ember.observer('title', function() {
Ember.run.scheduleOnce('afterRender', this, function() {
this.$().tooltip('fixTitle');
});
})
});

View File

@@ -0,0 +1,36 @@
import Ember from 'ember';
var $ = Ember.$;
/**
Component for the meta part of a post header. Displays the time, and when
clicked, shows a dropdown containing more information about the post
(number, full time, permalink).
*/
export default Ember.Component.extend({
tagName: 'li',
classNames: ['dropdown'],
layoutName: 'components/discussion/post-header/time',
// Construct a permalink by looking up the router in the container, and
// using it to generate a link to this post within its discussion.
permalink: Ember.computed('post.discusion', 'post.number', function() {
var router = this.get('controller').container.lookup('router:main');
var path = router.generate('discussion', this.get('post.discussion'), {queryParams: {start: this.get('post.number')}});
return window.location.origin+path;
}),
didInsertElement: function() {
// When the dropdown menu is shown, select the contents of the permalink
// input so that the user can quickly copy the URL.
var component = this;
this.$('a').click(function() {
setTimeout(function() { component.$('.permalink').select(); }, 1);
});
// Prevent clicking on the dropdown menu from closing it.
this.$('.dropdown-menu').click(function(e) {
e.stopPropagation();
});
}
});

View File

@@ -0,0 +1,21 @@
import Ember from 'ember';
var precompileTemplate = Ember.Handlebars.compile;
/**
Component for the toggle button in a post header. Toggles the
`parent.revealContent` property when clicked. Only displays if the supplied
post is not hidden.
*/
export default Ember.Component.extend({
tagName: 'li',
layout: precompileTemplate('<a href="#" class="btn btn-default btn-more" {{action "toggle"}}>{{fa-icon "ellipsis-h"}}</a>'),
hideItem: Ember.computed.not('post.isHidden'),
actions: {
toggle: function() {
this.toggleProperty('parent.revealContent');
}
}
});

View File

@@ -0,0 +1,12 @@
import Ember from 'ember';
var precompileTemplate = Ember.Handlebars.compile;
/**
Component for the username/avatar in a post header.
*/
export default Ember.Component.extend({
tagName: 'h3',
classNames: ['post-user'],
layout: precompileTemplate('{{#link-to "user" post.user}}{{user-avatar post.user}} {{post.user.username}}{{/link-to}}')
});

View File

@@ -0,0 +1,293 @@
import Ember from 'ember';
var $ = Ember.$;
/**
Component which renders items in a `post-stream` object. It handles scroll
events so that when the user scrolls to the top/bottom of the page, more
posts will load. In doing this is also sends an action so that the parent
controller's state can be updated. Finally, it can be sent actions to jump
to a certain position in the stream and load posts there.
*/
export default Ember.Component.extend({
classNames: ['stream'],
// The stream object.
stream: null,
// Pause window scroll event listeners. This is set to true while loading
// posts, because we don't want a scroll event to trigger another block of
// posts to be loaded.
paused: false,
// Whether or not the stream's initial content has loaded.
loaded: Ember.computed.bool('stream.loadedCount'),
// When the stream content is not "active", window scroll event listeners
// will be ignored. For the stream content to be active, its initial
// content must be loaded and it must not be "paused".
active: Ember.computed('loaded', 'paused', function() {
return this.get('loaded') && !this.get('paused');
}),
// Whenever the stream object changes (i.e. we have transitioned to a
// different discussion), pause events and cancel any pending state updates.
refresh: Ember.observer('stream', function() {
this.set('paused', true);
clearTimeout(this.updateStateTimeout);
}),
didInsertElement: function() {
$(window).on('scroll', {view: this}, this.windowWasScrolled);
},
willDestroyElement: function() {
$(window).off('scroll', this.windowWasScrolled);
},
windowWasScrolled: function(event) {
event.data.view.update();
},
// Run any checks/updates according to the window's current scroll
// position. We check to see if any terminal 'gaps' are in the viewport
// and trigger their loading mechanism if they are. We also update the
// controller's 'start' query param with the current position. Note: this
// observes the 'active' property, so if the stream is 'unpaused', then an
// update will be triggered.
update: Ember.observer('active', function() {
if (!this.get('active')) { return; }
var $items = this.$().find('.item'),
$window = $(window),
marginTop = this.getMarginTop(),
scrollTop = $window.scrollTop() + marginTop,
viewportHeight = $window.height() - marginTop,
loadAheadDistance = 300,
startNumber,
endNumber;
// Loop through each of the items in the stream. An 'item' is either a
// single post or a 'gap' of one or more posts that haven't been
// loaded yet.
$items.each(function() {
var $this = $(this);
var top = $this.offset().top;
var height = $this.outerHeight(true);
// If this item is above the top of the viewport (plus a bit of
// leeway for loading-ahead gaps), skip to the next one. If it's
// below the bottom of the viewport, break out of the loop.
if (top + height < scrollTop - loadAheadDistance) { return; }
if (top > scrollTop + viewportHeight + loadAheadDistance) { return false; }
// If this item is a gap, then we may proceed to check if it's a
// *terminal* gap and trigger its loading mechanism.
if ($this.hasClass('gap')) {
var gapView = Ember.View.views[$this.attr('id')];
if ($this.is(':first-child')) {
gapView.set('direction', 'up').load();
} else if ($this.is(':last-child')) {
gapView.set('direction', 'down').load();
}
} else {
if (top + height < scrollTop + viewportHeight) {
endNumber = $this.data('number');
}
// Check if this item is in the viewport, minus the distance we
// allow for load-ahead gaps. If we haven't yet stored a post's
// number, then this item must be the FIRST item in the viewport.
// Therefore, we'll grab its post number so we can update the
// controller's state later.
if (top + height > scrollTop && ! startNumber) {
startNumber = $this.data('number');
}
}
});
// Finally, we want to update the controller's state with regards to the
// current viewing position of the discussion. However, we don't want to
// do this on every single scroll event as it will slow things down. So,
// let's do it at a minimum of 250ms by clearing and setting a timeout.
var view = this;
clearTimeout(this.updateStateTimeout);
this.updateStateTimeout = setTimeout(function() {
view.sendAction('positionChanged', startNumber || 1, endNumber);
}, 500);
}),
loadingNumber: function(number, noAnimation) {
// The post with this number is being loaded. We want to scroll to where
// we think it will appear. We may be scrolling to the edge of the page,
// but we don't want to trigger any terminal post gaps to load by doing
// that. So, we'll disable the window's scroll handler for now.
this.set('paused', true);
this.jumpToNumber(number, noAnimation);
},
loadedNumber: function(number, noAnimation) {
// The post with this number has been loaded. After we scroll to this
// post, we want to resume scroll events.
var view = this;
Ember.run.scheduleOnce('afterRender', function() {
view.jumpToNumber(number, noAnimation).done(function() {
view.set('paused', false);
});
});
},
loadingIndex: function(index, noAnimation) {
// The post at this index is being loaded. We want to scroll to where we
// think it will appear. We may be scrolling to the edge of the page,
// but we don't want to trigger any terminal post gaps to load by doing
// that. So, we'll disable the window's scroll handler for now.
this.set('paused', true);
this.jumpToIndex(index, noAnimation);
},
loadedIndex: function(index, noAnimation) {
// The post at this index has been loaded. After we scroll to this post,
// we want to resume scroll events.
var view = this;
Ember.run.scheduleOnce('afterRender', function() {
view.jumpToIndex(index, noAnimation).done(function() {
view.set('paused', false);
});
});
},
// Scroll down to a certain post by number (or the gap which we think the
// post is in) and highlight it.
jumpToNumber: function(number, noAnimation) {
// Clear the highlight class from all posts, and attempt to find and
// highlight a post with the specified number. However, we don't apply
// the highlight to the first post in the stream because it's pretty
// obvious that it's the top one.
var $item = this.$('.item').removeClass('highlight').filter('[data-number='+number+']');
if (!$item.is(':first-child')) {
$item.addClass('highlight');
}
// If we didn't have any luck, then a post with this number either
// doesn't exist, or it hasn't been loaded yet. We'll find the item
// that's closest to the post with this number and scroll to that
// instead.
if (!$item.length) {
$item = this.findNearestToNumber(number);
}
return this.scrollToItem($item, noAnimation);
},
// Scroll down to a certain post by index (or the gap the post is in.)
jumpToIndex: function(index, noAnimation) {
var $item = this.findNearestToIndex(index);
return this.scrollToItem($item, noAnimation);
},
scrollToItem: function($item, noAnimation) {
var $container = $('html, body').stop(true);
if ($item.length) {
var marginTop = this.getMarginTop();
var scrollTop = $item.is(':first-child') ? 0 : $item.offset().top - marginTop;
if (noAnimation) {
$container.scrollTop(scrollTop);
} else if (scrollTop !== $(document).scrollTop()) {
$container.animate({scrollTop: scrollTop});
}
}
return $container.promise();
},
// Find the DOM element of the item that is nearest to a post with a certain
// number. This will either be another post (if the requested post doesn't
// exist,) or a gap presumed to contain the requested post.
findNearestToNumber: function(number) {
var $nearestItem = $();
this.$('.item').each(function() {
var $this = $(this);
if ($this.data('number') > number) {
return false;
}
$nearestItem = $this;
});
return $nearestItem;
},
findNearestToIndex: function(index) {
var $nearestItem = this.$('.item[data-start='+index+'][data-end='+index+']');
if (! $nearestItem.length) {
this.$('.item').each(function() {
$nearestItem = $(this);
if ($nearestItem.data('end') >= index) {
return false;
}
});
}
return $nearestItem;
},
// Get the distance from the top of the viewport to the point at which we
// would consider a post to be the first one visible.
getMarginTop: function() {
return $('#header').outerHeight() + parseInt(this.$().css('margin-top'));
},
actions: {
goToNumber: function(number, noAnimation) {
number = Math.max(number, 1);
// Let's start by telling our listeners that we're going to load
// posts near this number. Elsewhere we will listen and
// consequently scroll down to the appropriate position.
this.trigger('loadingNumber', number, noAnimation);
// Now we have to actually make sure the posts around this new start
// position are loaded. We will tell our listeners when they are.
// Again, a listener will scroll down to the appropriate post.
var controller = this;
this.get('stream').loadNearNumber(number).then(function() {
controller.trigger('loadedNumber', number, noAnimation);
});
},
goToIndex: function(index, backwards, noAnimation) {
// Let's start by telling our listeners that we're going to load
// posts at this index. Elsewhere we will listen and consequently
// scroll down to the appropriate position.
this.trigger('loadingIndex', index, noAnimation);
// Now we have to actually make sure the posts around this index
// are loaded. We will tell our listeners when they are. Again, a
// listener will scroll down to the appropriate post.
var controller = this;
this.get('stream').loadNearIndex(index, backwards).then(function() {
controller.trigger('loadedIndex', index, noAnimation);
});
},
goToFirst: function() {
this.send('goToIndex', 0);
},
goToLast: function() {
this.send('goToIndex', this.get('stream.count') - 1, true);
// If the post stream is loading some new posts, then after it's
// done we'll want to immediately scroll down to the bottom of the
// page.
if (! this.get('stream.lastLoaded')) {
this.get('stream').one('postsLoaded', function() {
Ember.run.scheduleOnce('afterRender', function() {
$('html, body').stop(true).scrollTop($('body').height());
});
});
}
},
loadRange: function(start, end, backwards) {
this.get('stream').loadRange(start, end, backwards);
}
}
});

View File

@@ -0,0 +1,125 @@
import Ember from 'ember';
var $ = Ember.$;
/**
A stream 'item' represents one item in the post stream - this may be a
single post, or it may represent a gap of many posts which have not been
loaded.
*/
export default Ember.Component.extend({
classNames: ['item'],
classNameBindings: ['gap', 'loading', 'direction'],
attributeBindings: [
'start:data-start',
'end:data-end',
'time:data-time',
'number:data-number'
],
start: Ember.computed.alias('item.indexStart'),
end: Ember.computed.alias('item.indexEnd'),
number: Ember.computed.alias('item.content.number'),
loading: Ember.computed.alias('item.loading'),
direction: Ember.computed.alias('item.direction'),
gap: Ember.computed.not('item.content'),
time: Ember.computed('item.content.time', function() {
var time = this.get('item.content.time');
return time ? time.toString() : null;
}),
count: Ember.computed('start', 'end', function() {
return this.get('end') - this.get('start') + 1;
}),
loadingChanged: Ember.observer('loading', function() {
this.rerender();
}),
render: function(buffer) {
if (this.get('item.content')) {
return this._super(buffer);
}
buffer.push('<span>');
if (this.get('loading')) {
buffer.push('&nbsp;');
} else {
buffer.push(this.get('count')+' more post'+(this.get('count') !== 1 ? 's' : ''));
}
buffer.push('</span>');
},
didInsertElement: function() {
if (!this.get('gap')) {
return;
}
if (this.get('loading')) {
var view = this;
Ember.run.scheduleOnce('afterRender', function() {
view.$().spin('small');
});
} else {
var self = this;
this.$().hover(function(e) {
if (! self.get('loading')) {
var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
self.set('direction', up ? 'up' : 'down');
}
});
}
},
load: function(relativeIndex) {
// If this item is not a gap, or if we're already loading its posts,
// then we don't need to do anything.
if (! this.get('gap') || this.get('loading')) {
return false;
}
// If new posts are being loaded in an upwards direction, then when
// they are rendered, the rest of the posts will be pushed down the
// page. If loaded in a downwards direction from the end of a
// discussion, the terminal gap will disappear and the page will
// scroll up a bit before the new posts are rendered. In order to
// maintain the current scroll position relative to the content
// before/after the gap, we need to find item directly after the gap
// and use it as an anchor.
var siblingFunc = this.get('direction') === 'up' ? 'nextAll' : 'prevAll';
var anchor = this.$()[siblingFunc]('.item:first');
// Immediately after the posts have been loaded (but before they
// have been rendered,) we want to grab the distance from the top of
// the viewport to the top of the anchor element.
this.get('stream').one('postsLoaded', function() {
if (anchor.length) {
var scrollOffset = anchor.offset().top - $(document).scrollTop();
}
// After they have been rendered, we scroll back to a position
// so that the distance from the top of the viewport to the top
// of the anchor element is the same as before. If there is no
// anchor (i.e. this gap is terminal,) then we'll scroll to the
// bottom of the document.
Ember.run.scheduleOnce('afterRender', function() {
$('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
});
});
// Tell the controller that we want to load the range of posts that this
// gap represents. We also specify which direction we want to load the
// posts from.
this.sendAction(
'loadRange',
this.get('start') + (relativeIndex || 0),
this.get('end'),
this.get('direction') === 'up'
);
},
click: function() {
this.load();
}
});

View File

@@ -0,0 +1,392 @@
import Ember from 'ember';
var $ = Ember.$;
/**
Component which allows the user to scrub along the scrubber-content
component with a scrollbar.
*/
export default Ember.Component.extend({
layoutName: 'components/discussion/stream-scrubber',
classNames: ['scrubber', 'stream-scrubber'],
classNameBindings: ['disabled'],
// The stream-content component to which this scrubber is linked.
streamContent: null,
// The current index of the stream visible at the top of the viewport, and
// the number of items visible within the viewport. These aren't
// necessarily integers.
index: -1,
visible: 1,
// The description displayed alongside the index in the scrubber. This is
// set to the date of the first visible post in the scroll event.
description: '',
stream: Ember.computed.alias('streamContent.stream'),
loaded: Ember.computed.alias('streamContent.loaded'),
count: Ember.computed.alias('stream.count'),
// The integer index of the last item that is visible in the viewport. This
// is display on the scrubber (i.e. X of 100 posts).
visibleIndex: Ember.computed('index', 'visible', function() {
return Math.min(this.get('count'), Math.ceil(Math.max(0, this.get('index')) + this.get('visible')));
}),
// Disable the scrubber if the stream's initial content isn't loaded, or
// if all of the posts in the discussion are visible in the viewport.
disabled: Ember.computed('loaded', 'visible', 'count', function() {
return !this.get('loaded') || this.get('visible') >= this.get('count');
}),
// Whenever the stream object changes to a new one (i.e. when
// transitioning to a different discussion,) reset some properties and
// update the scrollbar to a neutral state.
refresh: Ember.observer('stream', function() {
this.set('index', -1);
this.set('visible', 1);
this.updateScrollbar();
}),
didInsertElement: function() {
var view = this;
// When the stream-content component begins loading posts at a certain
// index, we want our scrubber scrollbar to jump to that position.
this.get('streamContent').on('loadingIndex', this, this.loadingIndex);
// Whenever the window is resized, adjust the height of the scrollbar
// so that it fills the height of the sidebar.
$(window).on('resize', {view: this}, this.windowWasResized).resize();
// Define a handler to update the state of the scrollbar to reflect the
// current scroll position of the page.
$(window).on('scroll', {view: this}, this.windowWasScrolled);
// When any part of the whole scrollbar is clicked, we want to jump to
// that position.
this.$('.scrubber-scrollbar')
.click(function(e) {
if (!view.get('streamContent.active')) { return; }
// Calculate the index which we want to jump to based on the
// click position.
// 1. Get the offset of the click from the top of the
// scrollbar, as a percentage of the scrollbar's height.
var $this = $(this);
var offsetPixels = e.clientY - $this.offset().top + $('body').scrollTop();
var offsetPercent = offsetPixels / $this.outerHeight() * 100;
// 2. We want the handle of the scrollbar to end up centered
// on the click position. Thus, we calculate the height of
// the handle in percent and use that to find a new
// offset percentage.
offsetPercent = offsetPercent - parseFloat($this.find('.scrubber-slider')[0].style.height) / 2;
// 3. Now we can convert the percentage into an index, and
// tell the stream-content component to jump to that index.
var offsetIndex = offsetPercent / view.percentPerPost().index;
offsetIndex = Math.max(0, Math.min(view.get('count') - 1, offsetIndex));
view.get('streamContent').send('goToIndex', Math.floor(offsetIndex));
});
// Now we want to make the scrollbar handle draggable. Let's start by
// preventing default browser events from messing things up.
this.$('.scrubber-scrollbar')
.css({
cursor: 'pointer',
'user-select': 'none'
})
.bind('dragstart mousedown', function(e) {
e.preventDefault();
});
// When the mouse is pressed on the scrollbar handle, we capture some
// information about its current position. We will store this
// information in an object and pass it on to the document's
// mousemove/mouseup events later.
var dragData = {
view: this,
mouseStart: 0,
indexStart: 0,
handle: null
};
this.$('.scrubber-slider')
.css('cursor', 'move')
.mousedown(function(e) {
dragData.mouseStart = e.clientY;
dragData.indexStart = view.get('index');
dragData.handle = $(this);
view.set('streamContent.paused', true);
$('body').css('cursor', 'move');
})
// Exempt the scrollbar handle from the 'jump to' click event.
.click(function(e) {
e.stopPropagation();
});
// When the mouse moves and when it is released, we pass the
// information that we captured when the mouse was first pressed onto
// some event handlers. These handlers will move the scrollbar/stream-
// content as appropriate.
$(document)
.on('mousemove', dragData, this.mouseWasMoved)
.on('mouseup', dragData, this.mouseWasReleased);
// Finally, we'll just make sure the scrollbar is in the correct
// position according to the values of this.index/visible.
this.updateScrollbar(true);
},
willDestroyElement: function() {
this.get('streamContent').off('loadingIndex', this, this.loadingIndex);
$(window)
.off('resize', this.windowWasResized)
.off('scroll', this.windowWasScrolled);
$(document)
.off('mousemove', this.mouseWasMoved)
.off('mouseup', this.mouseWasReleased);
},
// When the stream-content component begins loading posts at a certain
// index, we want our scrubber scrollbar to jump to that position.
loadingIndex: function(index) {
this.set('index', index);
this.updateScrollbar(true);
},
windowWasResized: function(event) {
var view = event.data.view;
view.windowWasScrolled(event);
// Adjust the height of the scrollbar so that it fills the height of
// the sidebar and doesn't overlap the footer.
var scrollbar = view.$('.scrubber-scrollbar');
scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - $('#footer').outerHeight(true));
},
windowWasScrolled: function(event) {
var view = event.data.view;
if (view.get('streamContent.active')) {
view.update();
view.updateScrollbar();
}
},
mouseWasMoved: function(event) {
if (! event.data.handle) { return; }
var view = event.data.view;
// Work out how much the mouse has moved by - first in pixels, then
// convert it to a percentage of the scrollbar's height, and then
// finally convert it into an index. Add this delta index onto
// the index at which the drag was started, and then scroll there.
var deltaPixels = event.clientY - event.data.mouseStart;
var deltaPercent = deltaPixels / view.$('.scrubber-scrollbar').outerHeight() * 100;
var deltaIndex = deltaPercent / view.percentPerPost().index;
var newIndex = Math.min(event.data.indexStart + deltaIndex, view.get('count') - 1);
view.set('index', Math.max(0, newIndex));
view.updateScrollbar();
view.scrollToIndex(newIndex);
},
mouseWasReleased: function(event) {
if (!event.data.handle) { return; }
event.data.mouseStart = 0;
event.data.indexStart = 0;
event.data.handle = null;
$('body').css('cursor', '');
var view = event.data.view;
// If the index we've landed on is in a gap, then tell the stream-
// content that we want to load those posts.
var intIndex = Math.floor(view.get('index'));
if (!view.get('stream').findNearestToIndex(intIndex).content) {
view.get('streamContent').send('goToIndex', intIndex);
} else {
view.set('streamContent.paused', false);
}
},
// When the stream-content component resumes being 'active' (for example,
// after a bunch of posts have been loaded), then we want to update the
// scrubber scrollbar according to the window's current scroll position.
resume: Ember.observer('streamContent.active', function() {
var scrubber = this;
Ember.run.scheduleOnce('afterRender', function() {
if (scrubber.get('streamContent.active')) {
scrubber.update();
scrubber.updateScrollbar(true);
}
});
}),
// Update the index/visible/description properties according to the
// window's current scroll position.
update: function() {
if (!this.get('streamContent.active')) { return; }
var $window = $(window);
var marginTop = this.get('streamContent').getMarginTop();
var scrollTop = $window.scrollTop() + marginTop;
var windowHeight = $window.height() - marginTop;
// Before looping through all of the posts, we reset the scrollbar
// properties to a 'default' state. These values reflect what would be
// seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0.
var $items = this.get('streamContent').$().find('.item');
var index = $items.first().data('end') - 1;
var visible = 0;
var period = '';
// Now loop through each of the items in the discussion. An 'item' is
// either a single post or a 'gap' of one or more posts that haven't
// been loaded yet.
$items.each(function() {
var $this = $(this);
var top = $this.offset().top;
var height = $this.outerHeight(true);
// If this item is above the top of the viewport, skip to the next
// post. If it's below the bottom of the viewport, break out of the
// loop.
if (top + height < scrollTop) {
visible = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visible;
return;
}
if (top > scrollTop + windowHeight) {
return false;
}
// If the bottom half of this item is visible at the top of the
// viewport, then add the visible proportion to the visible
// counter, and set the scrollbar index to whatever the visible
// proportion represents. For example, if a gap represents indexes
// 0-9, and the bottom 50% of the gap is visible in the viewport,
// then the scrollbar index will be 5.
if (top <= scrollTop && top + height > scrollTop) {
visible = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visible;
}
// If the top half of this item is visible at the bottom of the
// viewport, then add the visible proportion to the visible
// counter.
else if (top + height >= scrollTop + windowHeight) {
visible += (scrollTop + windowHeight - top) / height;
}
// If the whole item is visible in the viewport, then increment the
// visible counter.
else {
visible++;
}
// If this item has a time associated with it, then set the
// scrollbar's current period to a formatted version of this time.
if ($this.data('time')) {
period = $this.data('time');
}
});
this.set('index', index);
this.set('visible', visible);
this.set('description', period ? moment(period).format('MMMM YYYY') : '');
},
// Update the scrollbar's position to reflect the current values of the
// index/visible properties.
updateScrollbar: function(animate) {
var percentPerPost = this.percentPerPost();
var index = this.get('index');
var count = this.get('count');
var visible = this.get('visible');
var heights = {};
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
heights.slider = Math.min(100 - heights.before, percentPerPost.visible * visible);
heights.after = 100 - heights.before - heights.slider;
var $scrubber = this.$();
var func = animate ? 'animate' : 'css';
for (var part in heights) {
var $part = $scrubber.find('.scrubber-'+part);
$part.stop(true, true)[func]({height: heights[part]+'%'});
// jQuery likes to put overflow:hidden, but because the scrollbar
// handle has a negative margin-left, we need to override.
if (func === 'animate') {
$part.css('overflow', 'visible');
}
}
},
// Instantly scroll to a certain index in the discussion. The index doesn't
// have to be an integer; any fraction of a post will be scrolled to.
scrollToIndex: function(index) {
index = Math.min(index, this.get('count') - 1);
// Find the item for this index, whether it's a post corresponding to
// the index, or a gap which the index is within.
var indexFloor = Math.max(0, Math.floor(index));
var $nearestItem = this.get('streamContent').findNearestToIndex(indexFloor);
// Calculate the position of this item so that we can scroll to it. If
// the item is a gap, then we will mark it as 'active' to indicate to
// the user that it will expand if they release their mouse.
// Otherwise, we will add a proportion of the item's height onto the
// scroll position.
var pos = $nearestItem.offset().top - this.get('streamContent').getMarginTop();
if ($nearestItem.is('.gap')) {
$nearestItem.addClass('active');
} else {
if (index >= 0) {
pos += $nearestItem.outerHeight(true) * (index - indexFloor);
} else {
pos += $nearestItem.offset().top * index;
}
}
// Remove the 'active' class from other gaps.
this.get('streamContent').$().find('.gap').not($nearestItem).removeClass('active');
$('html, body').scrollTop(pos);
},
percentPerPost: function() {
var count = this.get('count') || 1;
var visible = this.get('visible');
// To stop the slider of the scrollbar from getting too small when there
// are many posts, we define a minimum percentage height for the slider
// calculated from a 50 pixel limit. From this, we can calculate the
// minimum percentage per visible post. If this is greater than the
// actual percentage per post, then we need to adjust the 'before'
// percentage to account for it.
var minPercentVisible = 50 / this.$('.scrubber-scrollbar').outerHeight() * 100;
var percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
var percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
return {
index: percentPerPost,
visible: percentPerVisiblePost
};
},
actions: {
first: function() {
this.get('streamContent').send('goToFirst');
},
last: function() {
this.get('streamContent').send('goToLast');
}
}
});

View File

@@ -1,57 +0,0 @@
import Ember from 'ember';
import TaggedArray from '../../utils/tagged-array';
import { PositionEnum } from '../../controllers/composer';
var precompileTemplate = Ember.Handlebars.compile;
export default Ember.Component.extend(Ember.Evented, {
layoutName: 'components/discussions/composer-body',
submitLabel: 'Post Discussion',
titlePlaceholder: 'Discussion Title',
placeholder: '',
title: '',
content: '',
submit: null,
loading: false,
disabled: Ember.computed.equal('composer.position', PositionEnum.MINIMIZED),
didInsertElement: function() {
var controls = TaggedArray.create();
this.trigger('populateControls', controls);
this.set('controls', controls);
},
populateControls: function(controls) {
var title = Ember.Component.create({
tagName: 'h3',
layout: precompileTemplate('{{ui/controls/text-input value=component.title class="form-control" placeholder=component.titlePlaceholder disabled=component.disabled}}'),
component: this
});
controls.pushObjectWithTag(title, 'title');
},
actions: {
submit: function(content) {
this.get('submit')({
title: this.get('title'),
content: content
});
},
willExit: function(abort) {
// If the user has typed something, prompt them before exiting
// this composer state.
if ((this.get('title') || this.get('content')) && !confirm('You have not posted your discussion. Do you wish to discard it?')) {
abort();
}
},
reset: function() {
this.set('loading', false);
this.set('content', '');
}
}
});

View File

@@ -1,48 +0,0 @@
import Ember from 'ember';
import TaggedArray from '../../utils/tagged-array';
var precompileTemplate = Ember.Handlebars.compile;
export default Ember.Component.extend(Ember.Evented, {
layoutName: 'components/discussions/composer-body',
submitLabel: 'Save Changes',
placeholder: '',
content: Ember.computed.oneWay('post.content'),
originalContent: Ember.computed.oneWay('post.content'),
submit: null,
loading: false,
didInsertElement: function() {
var controls = TaggedArray.create();
this.trigger('populateControls', controls);
this.set('controls', controls);
},
populateControls: function(controls) {
var title = Ember.Component.create({
tagName: 'h3',
layout: precompileTemplate('Editing Post #{{component.post.number}} in <em>{{discussion.title}}</em>'),
discussion: this.get('post.discussion'),
component: this
});
controls.pushObjectWithTag(title, 'title');
},
actions: {
submit: function(content) {
this.get('submit')({
content: content
});
},
willExit: function(abort) {
// If the user has typed something, prompt them before exiting
// this composer state.
if (this.get('content') !== this.get('originalContent') && ! confirm('You have not saved your post. Do you wish to discard your changes?')) {
abort();
}
}
}
});

View File

@@ -1,51 +0,0 @@
import Ember from 'ember';
import TaggedArray from '../../utils/tagged-array';
var precompileTemplate = Ember.Handlebars.compile;
export default Ember.Component.extend(Ember.Evented, {
layoutName: 'components/discussions/composer-body',
submitLabel: 'Post Reply',
placeholder: '',
content: '',
submit: null,
loading: false,
didInsertElement: function() {
var controls = TaggedArray.create();
this.trigger('populateControls', controls);
this.set('controls', controls);
},
populateControls: function(controls) {
var title = Ember.Component.create({
tagName: 'h3',
layout: precompileTemplate('Replying to <em>{{component.discussion.title}}</em>'),
component: this
});
controls.pushObjectWithTag(title, 'title');
},
actions: {
submit: function(content) {
this.get('submit')({
content: content
});
},
willExit: function(abort) {
// If the user has typed something, prompt them before exiting
// this composer state.
if (this.get('content') && ! confirm('You have not posted your reply. Do you wish to discard it?')) {
abort();
}
},
reset: function() {
this.set('loading', false);
this.set('content', '');
}
}
});

View File

@@ -1,161 +0,0 @@
import Ember from 'ember';
import TaggedArray from '../../utils/tagged-array';
import ActionButton from '../ui/controls/action-button';
export default Ember.Component.extend({
terminalPostType: 'last',
countType: 'unread',
tagName: 'li',
attributeBindings: ['discussionId:data-id'],
classNames: ['discussion-summary'],
classNameBindings: [
'discussion.isUnread:unread',
'active'
],
layoutName: 'components/discussions/discussion-listing',
active: function() {
return this.get('childViews').anyBy('active');
}.property('childViews.@each.active'),
displayUnread: function() {
return this.get('countType') === 'unread' && this.get('discussion.isUnread');
}.property('countType', 'discussion.isUnread'),
countTitle: function() {
return this.get('discussion.isUnread') ? 'Mark as Read' : 'Jump to Last';
}.property('discussion.isUnread'),
displayLastPost: function() {
return this.get('terminalPostType') === 'last' && this.get('discussion.repliesCount');
}.property('terminalPostType', 'discussion.repliesCount'),
start: function() {
return this.get('discussion.isUnread') ? this.get('discussion.readNumber') + 1 : 1;
}.property('discussion.isUnread', 'discussion.readNumber'),
discussionId: Ember.computed.alias('discussion.id'),
relevantPosts: function() {
if (this.get('controller.show') !== 'posts') {
return [];
}
if (this.get('controller.searchQuery')) {
return this.get('discussion.relevantPosts');
} else if (this.get('controller.sort') === 'newest' || this.get('controller.sort') === 'oldest') {
return [this.get('discussion.startPost')];
} else {
return [this.get('discussion.lastPost')];
}
}.property('discussion.relevantPosts', 'discussion.startPost', 'discussion.lastPost'),
didInsertElement: function() {
var $this = this.$().css({opacity: 0});
setTimeout(function() {
$this.animate({opacity: 1}, 'fast');
}, 100);
// var view = this;
// this.$().find('a.info').click(function() {
// view.set('controller.paneShowing', false);
// });
// https://github.com/nolimits4web/Framework7/blob/master/src/js/swipeout.js
// this.$().find('.discussion').on('touchstart mousedown', function(e) {
// var isMoved = false;
// var isTouched = true;
// var isScrolling = undefined;
// var touchesStart = {
// x: e.type === 'touchstart' ? e.originalEvent.targetTouches[0].pageX : e.pageX,
// y: e.type === 'touchstart' ? e.originalEvent.targetTouches[0].pageY : e.pageY,
// };
// var touchStartTime = (new Date()).getTime();
// $(this).on('touchmove mousemove', function(e) {
// if (! isTouched) return;
// $(this).find('a.info').removeClass('pressed');
// var touchesNow = {
// x: e.type === 'touchmove' ? e.originalEvent.targetTouches[0].pageX : e.pageX,
// y: e.type === 'touchmove' ? e.originalEvent.targetTouches[0].pageY : e.pageY,
// };
// if (typeof isScrolling === 'undefined') {
// isScrolling = !!(isScrolling || Math.abs(touchesNow.y - touchesStart.y) > Math.abs(touchesNow.x - touchesStart.x));
// }
// if (isScrolling) {
// isTouched = false;
// return;
// }
// isMoved = true;
// e.preventDefault();
// var diffX = touchesNow.x - touchesStart.x;
// var translate = diffX;
// var actionsRightWidth = 150;
// if (translate < -actionsRightWidth) {
// translate = -actionsRightWidth - Math.pow(-translate - actionsRightWidth, 0.8);
// }
// $(this).css('left', translate);
// });
// $(this).on('touchend mouseup', function(e) {
// $(this).off('touchmove mousemove touchend mouseup');
// $(this).find('a.info').removeClass('pressed');
// if (!isTouched || !isMoved) {
// isTouched = false;
// isMoved = false;
// return;
// }
// isTouched = false;
// // isMoved = false;
// if (isMoved) {
// e.preventDefault();
// $(this).animate({left: -150});
// }
// });
// $(this).find('a.info').addClass('pressed').on('click', function(e) {
// if (isMoved) {
// e.preventDefault();
// e.stopImmediatePropagation();
// }
// $(this).off('click');
// });
// });
this.set('controls', TaggedArray.create());
},
populateControlsDefault: function(controls) {
controls.pushObjectWithTag(ActionButton.create({
label: 'Delete',
icon: 'times',
className: 'delete'
}), 'delete');
}.on('populateControls'),
actions: {
populateControls: function() {
if ( ! this.get('controls.length')) {
this.trigger('populateControls', this.get('controls'));
}
},
markAsRead: function() {
if (this.get('discussion.isUnread')) {
window.event.stopPropagation();
this.sendAction('markAsRead', this.get('discussion'));
}
}
}
});

View File

@@ -1,149 +0,0 @@
import Ember from 'ember';
import TaggedArray from '../../utils/tagged-array';
import ActionButton from '../ui/controls/action-button';
import ComposerEdit from '../discussions/composer-edit';
import AlertMessage from '../alert-message';
import humanTime from '../../utils/human-time';
var precompileTemplate = Ember.Handlebars.compile;
// @todo extend a base post class
export default Ember.Component.extend({
tagName: 'article',
layoutName: 'components/discussions/post-comment',
editDescription: function() {
return 'Edited by '+this.get('post.editUser.username')+' '+humanTime(this.get('post.editTime'));
}.property('post.editTime', 'post.editUser'),
post: Ember.computed.alias('content'),
classNames: ['post'],
classNameBindings: ['post.deleted', 'post.edited'],
didInsertElement: function() {
var $this = this.$();
$this.css({opacity: 0});
setTimeout(function() {
$this.animate({opacity: 1}, 'fast');
}, 100);
this.set('controls', TaggedArray.create());
this.trigger('populateControls', this.get('controls'));
this.set('header', TaggedArray.create());
this.trigger('populateHeader', this.get('header'));
},
populateControlsDefault: function(controls) {
if (this.get('post.deleted')) {
this.addControl('restore', 'Restore', 'reply', 'canEdit');
this.addControl('delete', 'Delete', 'times', 'canDelete');
} else {
this.addControl('edit', 'Edit', 'pencil', 'canEdit');
this.addControl('hide', 'Delete', 'times', 'canEdit');
}
}.on('populateControls'),
populateHeaderDefault: function(header) {
header.pushObjectWithTag(Ember.Component.create({
tagName: 'h3',
classNames: ['user'],
layout: precompileTemplate('{{#link-to "user" post.user}}{{user-avatar post.user}} {{post.user.username}}{{/link-to}}'),
post: this.get('post')
}));
header.pushObjectWithTag(Ember.Component.create({
tagName: 'li',
layout: precompileTemplate('{{#link-to "discussion" post.discussion (query-params start=post.number) class="time"}}{{human-time post.time}}{{/link-to}}'),
post: this.get('post')
}));
header.pushObjectWithTag(Ember.Component.extend({
tagName: 'li',
hideItem: Ember.computed.not('parent.post.isEdited'),
layout: precompileTemplate('<span class="post-edited" {{bind-attr title=parent.editDescription}}>{{fa-icon "pencil"}}</span>'),
parent: this,
didInsertElement: function() {
this.$('.post-edited').tooltip();
},
updateTooltip: function() {
Ember.run.scheduleOnce('afterRender', this, function() {
this.$('.post-edited').tooltip('fixTitle');
});
}.observes('parent.editDescription')
}).create());
}.on('populateHeader'),
addControl: function(tag, label, icon, permissionAttribute) {
if (permissionAttribute && !this.get('post').get(permissionAttribute)) {
return;
}
var self = this;
var action = function() {
self.get('controller').send(tag);
};
var item = ActionButton.create({label: label, icon: icon, action: action});
this.get('controls').pushObjectWithTag(item, tag);
},
savePost: function(post, data) {
var controller = this;
var composer = this.get('composer');
composer.set('content.loading', true);
this.get('alerts').send('clearAlerts');
post.set('content', data.content);
return post.save().then(function(post) {
composer.send('hide');
},
function(reason) {
var errors = reason.errors;
for (var i in reason.errors) {
var message = AlertMessage.create({
type: 'warning',
message: reason.errors[i]
});
controller.get('alerts').send('alert', message);
}
})
.finally(function() {
composer.set('content.loading', false);
});
},
actions: {
renderControls: function() {
this.set('renderControls', this.get('controls'));
// if (!this.get('controls.length')) {
// this.get('controls').pushObject(Ember.Component.create({tagName: 'li', classNames: ['dropdown-header'], layout: Ember.Handlebars.compile('No actions available')}));
// }
},
edit: function() {
var component = this;
var post = this.get('post');
var composer = this.get('composer');
// If the composer is already set up for this post, then we
// don't need to change its content - we can just show it.
if (!(composer.get('content') instanceof ComposerEdit) || composer.get('content.post') !== post) {
composer.switchContent(ComposerEdit.create({
user: post.get('user'),
post: post,
submit: function(data) {
component.savePost(post, data);
}
}));
}
composer.send('show');
}
}
});

View File

@@ -1,286 +0,0 @@
import Ember from 'ember';
var $ = Ember.$;
export default Ember.Component.extend({
classNames: ['stream'],
// The stream object.
stream: null,
// Pause window scroll event listeners. This is set to true while loading
// posts, because we don't want a scroll event to trigger another block of
// posts to be loaded.
paused: false,
// Whether or not the stream's initial content has loaded.
loaded: Ember.computed.bool('stream.loadedCount'),
// When the stream content is not "active", window scroll event listeners
// will be ignored. For the stream content to be active, its initial
// content must be loaded and it must not be "paused".
active: function() {
return this.get('loaded') && ! this.get('paused');
}.property('loaded', 'paused'),
refresh: function() {
this.set('paused', true);
clearTimeout(this.updateStateTimeout);
}.observes('stream'),
didInsertElement: function() {
$(window).on('scroll', {view: this}, this.windowWasScrolled);
},
willDestroyElement: function() {
$(window).off('scroll', this.windowWasScrolled);
},
windowWasScrolled: function(event) {
event.data.view.update();
},
// Run any checks/updates according to the window's current scroll
// position. We check to see if any terminal 'gaps' are in the viewport
// and trigger their loading mechanism if they are. We also update the
// controller's 'start' query param with the current position. Note: this
// observes the 'active' property, so if the stream is 'unpaused', then an
// update will be triggered.
update: function() {
if (! this.get('active')) {
return;
}
var $items = this.$().find('.item'),
$window = $(window),
marginTop = this.getMarginTop(),
scrollTop = $window.scrollTop() + marginTop,
viewportHeight = $window.height() - marginTop,
loadAheadDistance = 300,
currentNumber;
// Loop through each of the items in the stream. An 'item' is either a
// single post or a 'gap' of one or more posts that haven't been
// loaded yet.
$items.each(function() {
var $this = $(this),
top = $this.offset().top,
height = $this.outerHeight(true);
// If this item is above the top of the viewport (plus a bit of
// leeway for loading-ahead gaps), skip to the next one. If it's
// below the bottom of the viewport, break out of the loop.
if (top + height < scrollTop - loadAheadDistance) {
return;
}
if (top > scrollTop + viewportHeight + loadAheadDistance) {
return false;
}
// If this item is a gap, then we may proceed to check if it's a
// *terminal* gap and trigger its loading mechanism.
if ($this.hasClass('gap')) {
var gapView = Ember.View.views[$this.attr('id')];
if ($this.is(':first-child')) {
gapView.set('direction', 'up').load();
} else if ($this.is(':last-child')) {
gapView.set('direction', 'down').load();
}
}
// Check if this item is in the viewport, minus the distance we
// allow for load-ahead gaps. If we haven't yet stored a post's
// number, then this item must be the FIRST item in the viewport.
// Therefore, we'll grab its post number so we can update the
// controller's state later.
if (top + height > scrollTop && ! currentNumber) {
currentNumber = $this.data('number');
}
});
// Finally, we want to update the controller's state with regards to the
// current viewing position of the discussion. However, we don't want to
// do this on every single scroll event as it will slow things down. So,
// let's do it at a minimum of 250ms by clearing and setting a timeout.
var view = this;
clearTimeout(this.updateStateTimeout);
this.updateStateTimeout = setTimeout(function() {
view.sendAction('updateStart', currentNumber || 1);
}, 250);
}.observes('active'),
loadingNumber: function(number, noAnimation) {
// The post with this number is being loaded. We want to scroll to where
// we think it will appear. We may be scrolling to the edge of the page,
// but we don't want to trigger any terminal post gaps to load by doing
// that. So, we'll disable the window's scroll handler for now.
this.set('paused', true);
this.jumpToNumber(number, noAnimation);
},
loadedNumber: function(number, noAnimation) {
// The post with this number has been loaded. After we scroll to this
// post, we want to resume scroll events.
var view = this;
Ember.run.scheduleOnce('afterRender', function() {
view.jumpToNumber(number, noAnimation).done(function() {
view.set('paused', false);
});
});
},
loadingIndex: function(index, noAnimation) {
// The post at this index is being loaded. We want to scroll to where we
// think it will appear. We may be scrolling to the edge of the page,
// but we don't want to trigger any terminal post gaps to load by doing
// that. So, we'll disable the window's scroll handler for now.
this.set('paused', true);
this.jumpToIndex(index, noAnimation);
},
loadedIndex: function(index, noAnimation) {
// The post at this index has been loaded. After we scroll to this post,
// we want to resume scroll events.
var view = this;
Ember.run.scheduleOnce('afterRender', function() {
view.jumpToIndex(index, noAnimation).done(function() {
view.set('paused', false);
});
});
},
// Scroll down to a certain post by number (or the gap which we think the
// post is in) and highlight it.
jumpToNumber: function(number, noAnimation) {
// Clear the highlight class from all posts, and attempt to find and
// highlight a post with the specified number. However, we don't apply
// the highlight to the first post in the stream because it's pretty
// obvious that it's the top one.
var $item = this.$('.item').removeClass('highlight').filter('[data-number='+number+']');
if (number > 1) {
$item.addClass('highlight');
}
// If we didn't have any luck, then a post with this number either
// doesn't exist, or it hasn't been loaded yet. We'll find the item
// that's closest to the post with this number and scroll to that
// instead.
if (! $item.length) {
$item = this.findNearestToNumber(number);
}
return this.scrollToItem($item, noAnimation);
},
// Scroll down to a certain post by index (or the gap the post is in.)
jumpToIndex: function(index, noAnimation) {
var $item = this.findNearestToIndex(index);
return this.scrollToItem($item, noAnimation);
},
scrollToItem: function($item, noAnimation) {
var $container = $('html, body').stop(true);
if ($item.length) {
var marginTop = this.getMarginTop();
var scrollTop = $item.is(':first-child') ? 0 : $item.offset().top - marginTop;
if (noAnimation) {
$container.scrollTop(scrollTop);
} else if (scrollTop !== $(document).scrollTop()) {
$container.animate({scrollTop: scrollTop});
}
}
return $container.promise();
},
// Find the DOM element of the item that is nearest to a post with a certain
// number. This will either be another post (if the requested post doesn't
// exist,) or a gap presumed to contain the requested post.
findNearestToNumber: function(number) {
var $nearestItem = $();
this.$('.item').each(function() {
var $this = $(this);
if ($this.data('number') > number) {
return false;
}
$nearestItem = $this;
});
return $nearestItem;
},
findNearestToIndex: function(index) {
var $nearestItem = this.$('.item[data-start='+index+'][data-end='+index+']');
if (! $nearestItem.length) {
this.$('.item').each(function() {
$nearestItem = $(this);
if ($nearestItem.data('end') >= index) {
return false;
}
});
}
return $nearestItem;
},
// Get the distance from the top of the viewport to the point at which we
// would consider a post to be the first one visible.
getMarginTop: function() {
return $('#header').outerHeight() + parseInt(this.$().css('margin-top'));
},
actions: {
goToNumber: function(number, noAnimation) {
number = Math.max(number, 1);
// Let's start by telling our listeners that we're going to load
// posts near this number. Elsewhere we will listen and
// consequently scroll down to the appropriate position.
this.trigger('loadingNumber', number, noAnimation);
// Now we have to actually make sure the posts around this new start
// position are loaded. We will tell our listeners when they are.
// Again, a listener will scroll down to the appropriate post.
var controller = this;
this.get('stream').loadNearNumber(number).then(function() {
controller.trigger('loadedNumber', number, noAnimation);
});
},
goToIndex: function(index, backwards, noAnimation) {
// Let's start by telling our listeners that we're going to load
// posts at this index. Elsewhere we will listen and consequently
// scroll down to the appropriate position.
this.trigger('loadingIndex', index, noAnimation);
// Now we have to actually make sure the posts around this index
// are loaded. We will tell our listeners when they are. Again, a
// listener will scroll down to the appropriate post.
var controller = this;
this.get('stream').loadNearIndex(index, backwards).then(function() {
controller.trigger('loadedIndex', index, noAnimation);
});
},
goToFirst: function() {
this.send('goToIndex', 0);
},
goToLast: function() {
this.send('goToIndex', this.get('stream.count') - 1, true);
// If the post stream is loading some new posts, then after it's
// done we'll want to immediately scroll down to the bottom of the
// page.
if (! this.get('stream.lastLoaded')) {
this.get('stream').one('postsLoaded', function() {
Ember.run.scheduleOnce('afterRender', function() {
$('html, body').stop(true).scrollTop($('body').height());
});
});
}
},
loadRange: function(start, end, backwards) {
this.get('stream').loadRange(start, end, backwards);
}
}
});

View File

@@ -1,123 +0,0 @@
import Ember from 'ember';
var $ = Ember.$;
// A discussion 'item' represents one item in the post stream. In other words, a
// single item may represent a single post, or it may represent a gap of many
// posts which have not been loaded.
export default Ember.Component.extend({
classNames: ['item'],
classNameBindings: ['gap', 'loading', 'direction'],
attributeBindings: [
'start:data-start',
'end:data-end',
'time:data-time',
'number:data-number'
],
start: Ember.computed.alias('item.indexStart'),
end: Ember.computed.alias('item.indexEnd'),
time: function() {
var time = this.get('item.content.time');
return time ? time.toString() : null;
}.property('item.content.time'),
number: Ember.computed.alias('item.content.number'),
loading: Ember.computed.alias('item.loading'),
direction: Ember.computed.alias('item.direction'),
gap: Ember.computed.not('item.content'),
count: function() {
return this.get('end') - this.get('start') + 1;
}.property('start', 'end'),
loadingChanged: function() {
this.rerender();
}.observes('loading'),
render: function(buffer) {
if (this.get('item.content')) {
return this._super(buffer);
}
buffer.push('<span>');
if (this.get('loading')) {
buffer.push('&nbsp;');
} else {
buffer.push(this.get('count')+' more post'+(this.get('count') !== 1 ? 's' : ''));
}
buffer.push('</span>');
},
didInsertElement: function() {
if (! this.get('gap')) {
return;
}
if (this.get('loading')) {
var view = this;
Ember.run.scheduleOnce('afterRender', function() {
view.$().spin('small');
});
} else {
var self = this;
this.$().hover(function(e) {
if (! self.get('loading')) {
var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
self.set('direction', up ? 'up' : 'down');
}
});
}
},
load: function(relativeIndex) {
// If this item is not a gap, or if we're already loading its posts,
// then we don't need to do anything.
if (! this.get('gap') || this.get('loading')) {
return false;
}
// If new posts are being loaded in an upwards direction, then when
// they are rendered, the rest of the posts will be pushed down the
// page. If loaded in a downwards direction from the end of a
// discussion, the terminal gap will disappear and the page will
// scroll up a bit before the new posts are rendered. In order to
// maintain the current scroll position relative to the content
// before/after the gap, we need to find item directly after the gap
// and use it as an anchor.
var siblingFunc = this.get('direction') === 'up' ? 'nextAll' : 'prevAll';
var anchor = this.$()[siblingFunc]('.item:first');
// Immediately after the posts have been loaded (but before they
// have been rendered,) we want to grab the distance from the top of
// the viewport to the top of the anchor element.
this.get('stream').one('postsLoaded', function() {
if (anchor.length) {
var scrollOffset = anchor.offset().top - $(document).scrollTop();
}
// After they have been rendered, we scroll back to a position
// so that the distance from the top of the viewport to the top
// of the anchor element is the same as before. If there is no
// anchor (i.e. this gap is terminal,) then we'll scroll to the
// bottom of the document.
Ember.run.scheduleOnce('afterRender', function() {
$('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
});
});
// Tell the controller that we want to load the range of posts that this
// gap represents. We also specify which direction we want to load the
// posts from.
this.sendAction(
'loadRange',
this.get('start') + (relativeIndex || 0),
this.get('end'),
this.get('direction') === 'up'
);
},
click: function() {
this.load();
}
});

View File

@@ -1,395 +0,0 @@
import Ember from 'ember';
var $ = Ember.$;
export default Ember.Component.extend({
layoutName: 'components/discussions/stream-scrubber',
classNames: ['scrubber', 'stream-scrubber'],
classNameBindings: ['disabled'],
// The stream-content component to which this scrubber is linked.
streamContent: null,
stream: Ember.computed.alias('streamContent.stream'),
loaded: Ember.computed.alias('streamContent.loaded'),
count: Ember.computed.alias('stream.count'),
// The current index of the stream visible at the top of the viewport, and
// the number of items visible within the viewport. These aren't
// necessarily integers.
index: -1,
visible: 1,
// The integer index of the last item that is visible in the viewport. This
// is display on the scrubber (i.e. X of 100 posts).
visibleIndex: function() {
return Math.min(this.get('count'), Math.ceil(Math.max(0, this.get('index')) + this.get('visible')));
}.property('index', 'visible'),
// The description displayed alongside the index in the scrubber. This is
// set to the date of the first visible post in the scroll event.
description: '',
// Disable the scrubber if the stream's initial content isn't loaded, or
// if all of the posts in the discussion are visible in the viewport.
disabled: function() {
return ! this.get('loaded') || this.get('visible') >= this.get('count');
}.property('loaded', 'visible', 'count'),
// Whenever the stream object changes to a new one (i.e. when
// transitioning to a different discussion,) reset some properties and
// update the scrollbar to a neutral state.
refresh: function() {
this.set('index', -1);
this.set('visible', 1);
this.updateScrollbar();
}.observes('stream'),
didInsertElement: function() {
var view = this;
// When the stream-content component begins loading posts at a certain
// index, we want our scrubber scrollbar to jump to that position.
this.get('streamContent').on('loadingIndex', this, this.loadingIndex);
// Whenever the window is resized, adjust the height of the scrollbar
// so that it fills the height of the sidebar.
$(window).on('resize', {view: this}, this.windowWasResized).resize();
// Define a handler to update the state of the scrollbar to reflect the
// current scroll position of the page.
$(window).on('scroll', {view: this}, this.windowWasScrolled);
// When any part of the whole scrollbar is clicked, we want to jump to
// that position.
this.$('.scrubber-scrollbar')
.click(function(e) {
if (! view.get('streamContent.active')) {
return;
}
// Calculate the index which we want to jump to based on the
// click position.
// 1. Get the offset of the click from the top of the
// scrollbar, as a percentage of the scrollbar's height.
var $this = $(this),
offsetPixels = e.clientY - $this.offset().top + $('body').scrollTop(),
offsetPercent = offsetPixels / $this.outerHeight() * 100;
// 2. We want the handle of the scrollbar to end up centered
// on the click position. Thus, we calculate the height of
// the handle in percent and use that to find a new
// offset percentage.
offsetPercent = offsetPercent - parseFloat($this.find('.scrubber-slider')[0].style.height) / 2;
// 3. Now we can convert the percentage into an index, and
// tell the stream-content component to jump to that index.
var offsetIndex = offsetPercent / view.percentPerPost().index;
offsetIndex = Math.max(0, Math.min(view.get('count') - 1, offsetIndex));
view.get('streamContent').send('goToIndex', Math.floor(offsetIndex));
});
// Now we want to make the scrollbar handle draggable. Let's start by
// preventing default browser events from messing things up.
this.$('.scrubber-scrollbar')
.css({
cursor: 'pointer',
'user-select': 'none'
})
.bind('dragstart mousedown', function(e) {
e.preventDefault();
});
// When the mouse is pressed on the scrollbar handle, we capture some
// information about its current position. We will store this
// information in an object and pass it on to the document's
// mousemove/mouseup events later.
var dragData = {
view: this,
mouseStart: 0,
indexStart: 0,
handle: null
};
this.$('.scrubber-slider')
.css('cursor', 'move')
.mousedown(function(e) {
dragData.mouseStart = e.clientY;
dragData.indexStart = view.get('index');
dragData.handle = $(this);
view.set('streamContent.paused', true);
$('body').css('cursor', 'move');
})
// Exempt the scrollbar handle from the 'jump to' click event.
.click(function(e) {
e.stopPropagation();
});
// When the mouse moves and when it is released, we pass the
// information that we captured when the mouse was first pressed onto
// some event handlers. These handlers will move the scrollbar/stream-
// content as appropriate.
$(document)
.on('mousemove', dragData, this.mouseWasMoved)
.on('mouseup', dragData, this.mouseWasReleased);
// Finally, we'll just make sure the scrollbar is in the correct
// position according to the values of this.index/visible.
this.updateScrollbar(true);
},
willDestroyElement: function() {
this.get('streamContent').off('loadingIndex', this, this.loadingIndex);
$(window)
.off('resize', this.windowWasResized)
.off('scroll', this.windowWasScrolled);
$(document)
.off('mousemove', this.mouseWasMoved)
.off('mouseup', this.mouseWasReleased);
},
// When the stream-content component begins loading posts at a certain
// index, we want our scrubber scrollbar to jump to that position.
loadingIndex: function(index) {
this.set('index', index);
this.updateScrollbar(true);
},
windowWasResized: function(event) {
var view = event.data.view;
view.windowWasScrolled(event);
// Adjust the height of the scrollbar so that it fills the height of
// the sidebar and doesn't overlap the footer.
var scrollbar = view.$('.scrubber-scrollbar');
scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - $('#footer').outerHeight(true));
},
windowWasScrolled: function(event) {
var view = event.data.view;
if (view.get('streamContent.active')) {
view.update();
view.updateScrollbar();
}
},
mouseWasMoved: function(event) {
if (! event.data.handle) {
return;
}
var view = event.data.view;
// Work out how much the mouse has moved by - first in pixels, then
// convert it to a percentage of the scrollbar's height, and then
// finally convert it into an index. Add this delta index onto
// the index at which the drag was started, and then scroll there.
var deltaPixels = event.clientY - event.data.mouseStart,
deltaPercent = deltaPixels / view.$('.scrubber-scrollbar').outerHeight() * 100,
deltaIndex = deltaPercent / view.percentPerPost().index,
newIndex = Math.min(event.data.indexStart + deltaIndex, view.get('count') - 1);
view.set('index', Math.max(0, newIndex));
view.updateScrollbar();
view.scrollToIndex(newIndex);
},
mouseWasReleased: function(event) {
if (! event.data.handle) {
return;
}
event.data.mouseStart = 0;
event.data.indexStart = 0;
event.data.handle = null;
$('body').css('cursor', '');
var view = event.data.view;
// If the index we've landed on is in a gap, then tell the stream-
// content that we want to load those posts.
var intIndex = Math.floor(view.get('index'));
if (! view.get('stream').findNearestToIndex(intIndex).content) {
view.get('streamContent').send('goToIndex', intIndex);
} else {
view.set('streamContent.paused', false);
}
},
// When the stream-content component resumes being 'active' (for example,
// after a bunch of posts have been loaded), then we want to update the
// scrubber scrollbar according to the window's current scroll position.
resume: function() {
var scrubber = this;
Ember.run.scheduleOnce('afterRender', function() {
if (scrubber.get('streamContent.active')) {
scrubber.update();
scrubber.updateScrollbar(true);
}
});
}.observes('streamContent.active'),
// Update the index/visible/description properties according to the
// window's current scroll position.
update: function() {
if (! this.get('streamContent.active')) {
return;
}
var $window = $(window),
marginTop = this.get('streamContent').getMarginTop(),
scrollTop = $window.scrollTop() + marginTop,
windowHeight = $window.height() - marginTop;
// Before looping through all of the posts, we reset the scrollbar
// properties to a 'default' state. These values reflect what would be
// seen if the browser were scrolled right up to the top of the page,
// and the viewport had a height of 0.
var $items = this.get('streamContent').$().find('.item');
var index = $items.first().data('end') - 1;
var visible = 0;
var period = '';
// Now loop through each of the items in the discussion. An 'item' is
// either a single post or a 'gap' of one or more posts that haven't
// been loaded yet.
$items.each(function() {
var $this = $(this),
top = $this.offset().top,
height = $this.outerHeight(true);
// If this item is above the top of the viewport, skip to the next
// post. If it's below the bottom of the viewport, break out of the
// loop.
if (top + height < scrollTop) {
visible = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visible;
return;
}
if (top > scrollTop + windowHeight) {
return false;
}
// If the bottom half of this item is visible at the top of the
// viewport, then add the visible proportion to the visible
// counter, and set the scrollbar index to whatever the visible
// proportion represents. For example, if a gap represents indexes
// 0-9, and the bottom 50% of the gap is visible in the viewport,
// then the scrollbar index will be 5.
if (top <= scrollTop && top + height > scrollTop) {
visible = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visible;
}
// If the top half of this item is visible at the bottom of the
// viewport, then add the visible proportion to the visible
// counter.
else if (top + height >= scrollTop + windowHeight) {
visible += (scrollTop + windowHeight - top) / height;
}
// If the whole item is visible in the viewport, then increment the
// visible counter.
else {
visible++;
}
// If this item has a time associated with it, then set the
// scrollbar's current period to a formatted version of this time.
if ($this.data('time')) {
period = $this.data('time');
}
});
this.set('index', index);
this.set('visible', visible);
this.set('description', period ? moment(period).format('MMMM YYYY') : '');
},
// Update the scrollbar's position to reflect the current values of the
// index/visible properties.
updateScrollbar: function(animate) {
var percentPerPost = this.percentPerPost(),
index = this.get('index'),
count = this.get('count'),
visible = this.get('visible');
var heights = {};
heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible));
heights.slider = Math.min(100 - heights.before, percentPerPost.visible * visible);
heights.after = 100 - heights.before - heights.slider;
var $scrubber = this.$();
var func = animate ? 'animate' : 'css';
for (var part in heights) {
var $part = $scrubber.find('.scrubber-'+part);
$part.stop(true, true)[func]({height: heights[part]+'%'});
// jQuery likes to put overflow:hidden, but because the scrollbar
// handle has a negative margin-left, we need to override.
if (func === 'animate') {
$part.css('overflow', 'visible');
}
}
},
// Instantly scroll to a certain index in the discussion. The index doesn't
// have to be an integer; any fraction of a post will be scrolled to.
scrollToIndex: function(index) {
index = Math.min(index, this.get('count') - 1);
// Find the item for this index, whether it's a post corresponding to
// the index, or a gap which the index is within.
var indexFloor = Math.max(0, Math.floor(index)),
$nearestItem = this.get('streamContent').findNearestToIndex(indexFloor);
// Calculate the position of this item so that we can scroll to it. If
// the item is a gap, then we will mark it as 'active' to indicate to
// the user that it will expand if they release their mouse.
// Otherwise, we will add a proportion of the item's height onto the
// scroll position.
var pos = $nearestItem.offset().top - this.get('streamContent').getMarginTop();
if ($nearestItem.is('.gap')) {
$nearestItem.addClass('active');
} else {
if (index >= 0) {
pos += $nearestItem.outerHeight(true) * (index - indexFloor);
} else {
pos += $nearestItem.offset().top * index;
}
}
// Remove the 'active' class from other gaps.
this.get('streamContent').$().find('.gap').not($nearestItem).removeClass('active');
$('html, body').scrollTop(pos);
},
percentPerPost: function() {
var count = this.get('count') || 1,
visible = this.get('visible');
// To stop the slider of the scrollbar from getting too small when there
// are many posts, we define a minimum percentage height for the slider
// calculated from a 50 pixel limit. From this, we can calculate the
// minimum percentage per visible post. If this is greater than the
// actual percentage per post, then we need to adjust the 'before'
// percentage to account for it.
var minPercentVisible = 50 / this.$('.scrubber-scrollbar').outerHeight() * 100;
var percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
var percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
return {
index: percentPerPost,
visible: percentPerVisiblePost
};
},
actions: {
first: function() {
this.get('streamContent').send('goToFirst');
},
last: function() {
this.get('streamContent').send('goToLast');
}
}
});

View File

@@ -0,0 +1,87 @@
import Ember from 'ember';
import HasItemLists from 'flarum/mixins/has-item-lists';
import FadeIn from 'flarum/mixins/fade-in';
/**
Component for a discussion listing on the discussions index. It has `info`
and `controls` item lists for a bit of flexibility.
*/
export default Ember.Component.extend(FadeIn, HasItemLists, {
layoutName: 'components/index/discussion-listing',
tagName: 'li',
attributeBindings: ['discussionId:data-id'],
classNames: ['discussion-summary'],
classNameBindings: [
'discussion.isUnread:unread',
'active'
],
itemLists: ['info', 'controls'],
terminalPostType: 'last',
countType: 'unread',
discussionId: Ember.computed.alias('discussion.id'),
active: Ember.computed('childViews.@each.active', function() {
return this.get('childViews').anyBy('active');
}),
displayUnread: Ember.computed('countType', 'discussion.isUnread', function() {
return this.get('countType') === 'unread' && this.get('discussion.isUnread');
}),
countTitle: Ember.computed('discussion.isUnread', function() {
return this.get('discussion.isUnread') ? 'Mark as Read' : '';
}),
displayLastPost: Ember.computed('terminalPostType', 'discussion.repliesCount', function() {
return this.get('terminalPostType') === 'last' && this.get('discussion.repliesCount');
}),
start: Ember.computed('discussion.lastPostNumber', 'discussion.readNumber', function() {
return Math.min(this.get('discussion.lastPostNumber'), (this.get('discussion.readNumber') || 0) + 1);
}),
relevantPosts: Ember.computed('discussion.relevantPosts', 'discussion.startPost', 'discussion.lastPost', function() {
if (this.get('controller.show') !== 'posts') { return []; }
if (this.get('controller.searchQuery')) {
return this.get('discussion.relevantPosts');
} else if (this.get('controller.sort') === 'newest' || this.get('controller.sort') === 'oldest') {
return [this.get('discussion.startPost')];
} else {
return [this.get('discussion.lastPost')];
}
}),
populateControls: function(items) {
},
populateInfo: function(items) {
items.pushObjectWithTag(Ember.Component.extend({
classNames: ['terminal-post'],
layoutName: 'components/index/discussion-info/terminal-post',
discussion: Ember.computed.alias('parent.discussion'),
displayLastPost: Ember.computed.alias('parent.displayLastPost'),
}).create({parent: this}), 'terminalPost');
},
actions: {
// In the template, we render the "controls" dropdown with the contents of
// the `renderControls` property. This way, when a post is initially
// rendered, it doesn't have to go to the trouble of rendering the
// controls right away, which speeds things up. When the dropdown button
// is clicked, this will fill in the actual controls.
renderControls: function() {
this.set('renderControls', this.get('controls'));
},
markAsRead: function() {
if (this.get('discussion.isUnread')) {
discussion.set('readNumber', discussion.get('lastPostNumber'));
discussion.save();
}
}
}
});

View File

@@ -0,0 +1,19 @@
import Ember from 'ember';
/**
Component for the "welcome to this forum" hero on the discussions index.
*/
export default Ember.Component.extend({
layoutName: 'components/index/welcome-hero',
tagName: 'header',
classNames: ['hero', 'welcome-hero'],
title: '',
description: '',
actions: {
close: function() {
this.$().slideUp();
}
}
});

View File

@@ -0,0 +1,29 @@
import Ember from 'ember';
var precompileTemplate = Ember.Handlebars.compile;
/**
Button which sends an action when clicked.
*/
export default Ember.Component.extend({
tagName: 'a',
attributeBindings: ['href', 'title'],
classNameBindings: ['className'],
href: '#',
layout: precompileTemplate('{{#if icon}}{{fa-icon icon class="fa-fw icon-glyph"}} {{/if}}<span>{{label}}</span>'),
label: '',
icon: '',
className: '',
action: null,
click: function(e) {
e.preventDefault();
var action = this.get('action');
if (typeof action === 'string') {
this.sendAction('action');
} else if (typeof action === 'function') {
action();
}
}
});

View File

@@ -0,0 +1,53 @@
import Ember from 'ember';
import HasItemLists from 'flarum/mixins/has-item-lists';
import ActionButton from 'flarum/components/ui/action-button';
/**
An alert message. Has a message, a `controls` item list, and a dismiss
button.
*/
export default Ember.Component.extend(HasItemLists, {
layoutName: 'components/ui/alert-message',
classNames: ['alert'],
classNameBindings: ['classForType'],
itemLists: ['controls'],
message: '',
type: '',
dismissable: true,
buttons: [],
classForType: Ember.computed('type', function() {
return 'alert-'+this.get('type');
}),
populateControls: function(controls) {
var component = this;
this.get('buttons').forEach(function(button) {
controls.pushObject(ActionButton.create({
label: button.label,
action: function() {
component.send('dismiss');
button.action();
}
}));
});
if (this.get('dismissable')) {
var dismiss = ActionButton.create({
icon: 'times',
className: 'btn btn-icon btn-link',
action: function() { component.send('dismiss'); }
});
controls.pushObjectWithTag(dismiss, 'dismiss');
}
},
actions: {
dismiss: function() {
this.sendAction('dismiss', this);
}
}
});

View File

@@ -1,28 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
label: '',
icon: '',
className: '',
action: null,
divider: false,
active: false,
classNames: [],
tagName: 'a',
attributeBindings: ['href', 'title'],
classNameBindings: ['className'],
href: '#',
layout: Ember.Handlebars.compile('{{#if icon}}{{fa-icon icon class="fa-fw icon-glyph"}} {{/if}}<span>{{label}}</span>'),
click: function(e) {
e.preventDefault();
var action = this.get('action');
if (typeof action === 'string') {
this.sendAction('action');
} else if (typeof action === 'function') {
action();
}
}
});

View File

@@ -1,27 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
items: null, // TaggedArray
layoutName: 'components/ui/controls/dropdown-button',
classNames: ['dropdown', 'btn-group'],
classNameBindings: ['itemCountClass', 'class'],
label: 'Controls',
icon: 'ellipsis-v',
buttonClass: 'btn btn-default',
menuClass: '',
dropdownMenuClass: function() {
return 'dropdown-menu '+this.get('menuClass');
}.property('menuClass'),
itemCountClass: function() {
return 'item-count-'+this.get('items.length');
}.property('items.length'),
actions: {
buttonClick: function() {
this.sendAction('buttonClick');
}
}
});

View File

@@ -1,28 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
items: [],
layoutName: 'components/ui/controls/dropdown-select',
classNames: ['dropdown', 'dropdown-select', 'btn-group'],
classNameBindings: ['itemCountClass', 'class'],
buttonClass: 'btn btn-default',
menuClass: '',
icon: 'ellipsis-v',
mainButtonClass: function() {
return 'btn '+this.get('buttonClass');
}.property('buttonClass'),
dropdownMenuClass: function() {
return 'dropdown-menu '+this.get('menuClass');
}.property('menuClass'),
itemCountClass: function() {
return 'item-count-'+this.get('items.length');
}.property('items.length'),
activeItem: function() {
return this.get('menu.childViews').findBy('active');
}.property('menu.childViews.@each.active')
});

View File

@@ -1,15 +0,0 @@
import DropdownButton from './dropdown-button';
export default DropdownButton.extend({
layoutName: 'components/ui/controls/dropdown-split',
classNames: ['dropdown', 'dropdown-split', 'btn-group'],
menuClass: 'pull-right',
mainButtonClass: function() {
return 'btn '+this.get('buttonClass');
}.property('buttonClass'),
firstItem: function() {
return this.get('items').objectAt(0);
}.property('items.[]')
});

View File

@@ -1,22 +0,0 @@
import Ember from 'ember';
import ComponentItem from '../items/component-item';
export default Ember.Component.extend({
tagName: 'ul',
layoutName: 'components/ui/controls/item-list',
listItems: function() {
if (!Ember.isArray(this.get('items'))) {
return [];
}
var listItems = [];
this.get('items').forEach(function(item) {
if (item.get('tagName') !== 'li') {
item = ComponentItem.extend({component: item});
}
listItems.push(item);
});
return listItems;
}.property('items.[]')
});

View File

@@ -1,14 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['loading-indicator'],
layout: Ember.Handlebars.compile('&nbsp;'),
size: 'small',
didInsertElement: function() {
var size = this.get('size');
Ember.$.fn.spin.presets[size].zIndex = 'auto';
this.$().spin(size);
}
});

View File

@@ -1,40 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['search-input'],
classNameBindings: ['active', 'value:clearable'],
layoutName: 'components/ui/controls/search-input',
didInsertElement: function() {
var self = this;
this.$().find('input').on('keydown', function(e) {
if (e.which === 27) {
self.clear();
}
});
this.$().find('.clear').on('mousedown', function(e) {
e.preventDefault();
}).on('click', function(e) {
e.preventDefault();
self.clear();
});
},
clear: function() {
this.set('value', '');
this.send('search');
this.$().find('input').focus();
},
willDestroyElement: function() {
this.$().find('input').off('keydown');
this.$().find('.clear').off('mousedown click');
},
actions: {
search: function() {
this.get('action')(this.get('value'));
}
}
});

View File

@@ -1,9 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'span',
classNames: ['select-input'],
optionValuePath: 'content',
optionLabelPath: 'content',
layout: Ember.Handlebars.compile('{{view "select" content=view.content optionValuePath=view.optionValuePath optionLabelPath=view.optionLabelPath value=view.value class="form-control"}} {{fa-icon "sort"}}')
});

View File

@@ -1,39 +0,0 @@
import Ember from 'ember';
import TaggedArray from '../../../utils/tagged-array';
import ActionButton from './action-button';
export default Ember.Component.extend({
disabled: false,
classNames: ['text-editor'],
didInsertElement: function() {
var controlItems = TaggedArray.create();
this.trigger('populateControls', controlItems);
this.set('controlItems', controlItems);
var component = this;
this.$('textarea').bind('keydown', 'meta+return', function() {
component.send('submit');
});
},
populateControls: function(controls) {
var component = this;
var submit = ActionButton.create({
label: this.get('submitLabel'),
className: 'btn btn-primary',
action: function() {
component.send('submit');
}
});
controls.pushObjectWithTag(submit, 'submit');
},
actions: {
submit: function() {
this.sendAction('submit', this.get('value'));
}
}
});

View File

@@ -1,18 +0,0 @@
import Ember from 'ember';
export default Ember.TextField.extend({
didInsertElement: function() {
var component = this;
this.$().on('input', function() {
var empty = !$(this).val();
if (empty) {
$(this).val(component.get('placeholder'));
}
$(this).css('width', 0);
$(this).width($(this)[0].scrollWidth);
if (empty) {
$(this).val('');
}
});
}
});

View File

@@ -0,0 +1,30 @@
import Ember from 'ember';
/**
Button which has an attached dropdown menu containing an item list.
*/
export default Ember.Component.extend({
layoutName: 'components/ui/dropdown-button',
classNames: ['dropdown', 'btn-group'],
classNameBindings: ['itemCountClass', 'class'],
label: 'Controls',
icon: 'ellipsis-v',
buttonClass: 'btn btn-default',
menuClass: '',
items: null,
dropdownMenuClass: Ember.computed('menuClass', function() {
return 'dropdown-menu '+this.get('menuClass');
}),
itemCountClass: Ember.computed('items.length', function() {
return 'item-count-'+this.get('items.length');
}),
actions: {
buttonClick: function() {
this.sendAction('buttonClick');
}
}
});

View File

@@ -0,0 +1,32 @@
import Ember from 'ember';
/**
Button which has an attached dropdown menu containing an item list. The
currently-active item's label is displayed as the label of the button.
*/
export default Ember.Component.extend({
layoutName: 'components/ui/dropdown-select',
classNames: ['dropdown', 'dropdown-select', 'btn-group'],
classNameBindings: ['itemCountClass', 'class'],
buttonClass: 'btn btn-default',
menuClass: '',
icon: 'ellipsis-v',
items: [],
mainButtonClass: Ember.computed('buttonClass', function() {
return 'btn '+this.get('buttonClass');
}),
dropdownMenuClass: Ember.computed('menuClass', function() {
return 'dropdown-menu '+this.get('menuClass');
}),
itemCountClass: Ember.computed('items.length', function() {
return 'item-count-'+this.get('items.length');
}),
activeItem: Ember.computed('menu.childViews.@each.active', function() {
return this.get('menu.childViews').findBy('active');
})
});

View File

@@ -0,0 +1,22 @@
import Ember from 'ember';
import DropdownButton from 'flarum/components/ui/dropdown-button';
/**
Given a list of items, this component displays a split button: the left side
is the first item in the list, while the right side is a dropdown-toggle
which shows a dropdown menu containing all of the items.
*/
export default DropdownButton.extend({
layoutName: 'components/ui/dropdown-split',
classNames: ['dropdown', 'dropdown-split', 'btn-group'],
menuClass: 'pull-right',
mainButtonClass: Ember.computed('buttonClass', function() {
return 'btn '+this.get('buttonClass');
}),
firstItem: Ember.computed('items.[]', function() {
return this.get('items').objectAt(0);
})
});

View File

@@ -0,0 +1,21 @@
import Ember from 'ember';
/**
Output a list of components within a <ul>, making sure each one is contained
in an <li> element.
*/
export default Ember.Component.extend({
layoutName: 'components/ui/item-list',
tagName: 'ul',
listItems: Ember.computed('items.[]', function() {
var items = this.get('items');
if (!Ember.isArray(items)) {
return [];
}
items.forEach(function(item) {
item.set('isListItem', item.get('tagName') === 'li');
});
return items;
})
});

View File

@@ -1,6 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'li',
layoutName: 'components/ui/items/component-item'
});

View File

@@ -1,37 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
icon: '',
label: '',
action: null,
badge: '',
tagName: 'li',
classNameBindings: ['active'],
active: function() {
return !! this.get('childViews').anyBy('active');
}.property('childViews.@each.active'),
// init: function() {
// var params = this.params;
// if (params[params.length - 1].queryParams) {
// this.queryParamsObject = {values: params.pop().queryParams};
// }
// this._super();
// },
layout: function() {
return Ember.Handlebars.compile('{{#link-to '+this.get('linkTo')+'}}'+this.get('iconTemplate')+' {{label}} <span class="count">{{badge}}</span>{{/link-to}}');
}.property('linkTo', 'iconTemplate'),
iconTemplate: function() {
return '{{fa-icon icon}}';
}.property(),
actions: {
main: function() {
this.get('action')();
}
}
});

View File

@@ -1,6 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'li',
classNames: ['divider']
});

View File

@@ -0,0 +1,19 @@
import Ember from 'ember';
var precompileTemplate = Ember.Handlebars.compile;
/**
Loading spinner.
*/
export default Ember.Component.extend({
classNames: ['loading-indicator'],
layout: precompileTemplate('&nbsp;'),
size: 'small',
didInsertElement: function() {
var size = this.get('size');
Ember.$.fn.spin.presets[size].zIndex = 'auto';
this.$().spin(size);
}
});

View File

@@ -0,0 +1,22 @@
import Ember from 'ember';
var precompileTemplate = Ember.Handlebars.compile;
/**
A list item which contains a navigation link. The list item's `active`
property reflects whether or not the link is active.
*/
export default Ember.Component.extend({
layout: precompileTemplate('{{#link-to routeName}}{{fa-icon icon}} {{label}} <span class="count">{{badge}}</span>{{/link-to}}'),
tagName: 'li',
classNameBindings: ['active'],
icon: '',
label: '',
badge: '',
routeName: '',
active: Ember.computed('childViews.@each.active', function() {
return !!this.get('childViews').anyBy('active');
})
});

View File

@@ -0,0 +1,36 @@
import Ember from 'ember';
/**
A basic search input. Comes with the ability to be cleared by pressing
escape or with a button. Sends an action when enter is pressed.
*/
export default Ember.Component.extend({
layoutName: 'components/ui/search-input',
classNames: ['search-input'],
classNameBindings: ['active', 'value:clearable'],
didInsertElement: function() {
this.$('input').on('keydown', 'esc', function(e) {
self.clear();
});
var self = this;
this.$('.clear').on('mousedown click', function(e) {
e.preventDefault();
}).on('click', function(e) {
self.clear();
});
},
clear: function() {
this.set('value', '');
this.send('search');
this.$().find('input').focus();
},
actions: {
search: function() {
this.get('action')(this.get('value'));
}
}
});

View File

@@ -0,0 +1,16 @@
import Ember from 'ember';
var precompileTemplate = Ember.Handlebars.compile;
/**
A basic select input. Wraps Ember's select component with a span/icon so
that we can style it more fancily.
*/
export default Ember.Component.extend({
layout: precompileTemplate('{{view "select" content=view.content optionValuePath=view.optionValuePath optionLabelPath=view.optionLabelPath value=view.value class="form-control"}} {{fa-icon "sort"}}'),
tagName: 'span',
classNames: ['select-input'],
optionValuePath: 'content',
optionLabelPath: 'content'
});

View File

@@ -0,0 +1,9 @@
import Ember from 'ember';
/**
A simple separator list item for use in menus.
*/
export default Ember.Component.extend({
tagName: 'li',
classNames: ['divider']
});

View File

@@ -0,0 +1,33 @@
import Ember from 'ember';
import HasItemLists from 'flarum/mixins/has-item-lists';
import ActionButton from 'flarum/components/ui/action-button';
/**
A text editor. Contains a textarea and an item list of `controls`, including
a submit button.
*/
export default Ember.Component.extend(HasItemLists, {
classNames: ['text-editor'],
itemLists: ['controls'],
value: '',
disabled: false,
didInsertElement: function() {
var component = this;
this.$('textarea').bind('keydown', 'meta+return', function() {
component.send('submit');
});
},
populateControls: function(items) {
this.addActionItem(items, 'submit', this.get('submitLabel')).set('className', 'btn btn-primary');
},
actions: {
submit: function() {
this.sendAction('submit', this.get('value'));
}
}
});

View File

@@ -0,0 +1,26 @@
import Ember from 'ember';
/**
An extension of Ember's text field with an option to set up an auto-growing
text input.
*/
export default Ember.TextField.extend({
autoGrow: false,
didInsertElement: function() {
if (this.get('autoGrow')) {
var component = this;
this.$().on('input', function() {
var empty = !$(this).val();
if (empty) {
$(this).val(component.get('placeholder'));
}
$(this).css('width', 0);
$(this).width($(this)[0].scrollWidth);
if (empty) {
$(this).val('');
}
});
}
}
});

View File

@@ -1,14 +0,0 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'header',
classNames: ['hero', 'welcome-hero'],
actions: {
close: function() {
this.$().slideUp();
}
}
});

View File

@@ -27,7 +27,7 @@ export default Ember.Controller.extend(Ember.Evented, {
this.confirmExit().then(function() {
composer.set('content', null);
Ember.run.next(function() {
newContent.set('composer', composer);
newContent.composer = composer;
composer.set('content', newContent);
});
});

View File

@@ -1,11 +1,14 @@
import Ember from 'ember';
import ComposerReply from '../components/discussions/composer-reply';
import ActionButton from '../components/ui/controls/action-button';
import AlertMessage from '../components/alert-message';
import ComposerReply from 'flarum/components/composer/composer-reply';
import ActionButton from 'flarum/components/ui/action-button';
import AlertMessage from 'flarum/components/ui/alert-message';
import UseComposerMixin from 'flarum/mixins/use-composer';
export default Ember.Controller.extend(Ember.Evented, {
needs: ['application', 'alerts', 'composer'],
export default Ember.Controller.extend(Ember.Evented, UseComposerMixin, {
needs: ['application'],
composer: Ember.inject.controller('composer'),
alerts: Ember.inject.controller('alerts'),
queryParams: ['start'],
start: '1',
@@ -20,21 +23,14 @@ export default Ember.Controller.extend(Ember.Evented, {
// @todo break this down into bite-sized functions so that extensions can
// easily override where they please.
saveReply: function(discussion, data) {
var controller = this;
var composer = this.get('controllers.composer');
var stream = this.get('stream');
composer.set('content.loading', true);
controller.get('controllers.alerts').send('clearAlerts');
var post = this.store.createRecord('post', {
content: data.content,
discussion: discussion
});
return post.save().then(function(post) {
composer.send('hide');
var controller = this;
var stream = this.get('stream');
return this.saveAndDismissComposer(post).then(function(post) {
discussion.setProperties({
lastTime: post.get('time'),
lastUser: post.get('user'),
@@ -56,61 +52,52 @@ export default Ember.Controller.extend(Ember.Evented, {
// will transition to their new post when clicked.
var message = AlertMessage.create({
type: 'success',
message: 'Your reply was posted.'
});
message.on('populateControls', function(controls) {
controls.pushObjectWithTag(ActionButton.create({
message: 'Your reply was posted.',
buttons: [{
label: 'View',
action: function() {
controller.transitionToRoute('discussion', post.get('discussion'), {queryParams: {start: post.get('number')}});
message.send('dismiss');
}
}), 'view');
}]
});
controller.get('controllers.alerts').send('alert', message);
controller.get('alerts').send('alert', message);
}
},
function(reason) {
var errors = reason.errors;
for (var i in reason.errors) {
var message = AlertMessage.create({
type: 'warning',
message: reason.errors[i]
});
controller.get('controllers.alerts').send('alert', message);
}
})
.finally(function() {
composer.set('content.loading', false);
});
},
// Whenever we transition to a different discussion or the logged-in user
// changes, we'll need the composer content to refresh next time the reply
// button is clicked.
clearComposerContent: Ember.observer('model', 'session.user', function() {
this.set('composerContent', undefined);
}),
actions: {
reply: function() {
var controller = this;
var discussion = this.get('model');
var composer = this.get('controllers.composer');
// If the composer is already set up for this discussion, then we
// don't need to change its content - we can just show it.
if (!(composer.get('content') instanceof ComposerReply) || composer.get('content.discussion') !== discussion) {
composer.switchContent(ComposerReply.create({
var controller = this;
this.showComposer(function() {
return ComposerReply.create({
user: controller.get('session.user'),
discussion: discussion,
submit: function(data) {
controller.saveReply(discussion, data);
}
}));
}
composer.send('show');
});
});
},
// This action is called when the start position of the discussion
// currently being viewed changes (i.e. when the user scrolls up/down
// the post stream.)
updateStart: function(start) {
this.set('start', start);
positionChanged: function(startNumber, endNumber) {
this.set('start', startNumber);
var discussion = this.get('model');
if (endNumber > discussion.get('readNumber')) {
discussion.set('readNumber', endNumber);
discussion.save();
}
}
}
});

View File

@@ -1,48 +1,31 @@
import Ember from 'ember';
import DiscussionResult from '../models/discussion-result';
import PostResult from '../models/post-result';
import PaneableMixin from '../mixins/paneable';
import ComposerDiscussion from '../components/discussions/composer-discussion';
import AlertMessage from '../components/alert-message';
import DiscussionResult from 'flarum/models/discussion-result';
import PostResult from 'flarum/models/post-result';
import Paneable from 'flarum/mixins/paneable';
import ComposerDiscussion from 'flarum/components/composer/composer-discussion';
import AlertMessage from 'flarum/components/ui/alert-message';
import UseComposer from 'flarum/mixins/use-composer';
export default Ember.Controller.extend(Ember.Evented, PaneableMixin, {
needs: ['application', 'composer', 'alerts', 'index/index', 'discussion'],
export default Ember.Controller.extend(UseComposer, Paneable, {
needs: ['application', 'index/index', 'discussion'],
composer: Ember.inject.controller('composer'),
alerts: Ember.inject.controller('alerts'),
index: Ember.computed.alias('controllers.index/index'),
paneDisabled: Ember.computed.not('index.model.length'),
saveDiscussion: function(data) {
var controller = this;
var composer = this.get('controllers.composer');
var stream = this.get('stream');
composer.set('content.loading', true);
controller.get('controllers.alerts').send('clearAlerts');
var discussion = this.store.createRecord('discussion', {
title: data.title,
content: data.content
});
return discussion.save().then(function(discussion) {
composer.send('hide');
var controller = this;
return this.saveAndDismissComposer(discussion).then(function(discussion) {
controller.get('index').set('model', null).send('refresh');
controller.transitionToRoute('discussion', discussion);
},
function(reason) {
var errors = reason.errors;
for (var i in reason.errors) {
var message = AlertMessage.create({
type: 'warning',
message: reason.errors[i]
});
controller.get('controllers.alerts').send('alert', message);
}
})
.finally(function() {
composer.set('content.loading', false);
});
},
@@ -55,22 +38,22 @@ export default Ember.Controller.extend(Ember.Evented, PaneableMixin, {
this.get('index').send('loadMore');
},
markAllAsRead: function() {
var user = this.get('session.user');
user.set('readTime', new Date);
user.save();
},
newDiscussion: function() {
var controller = this;
var composer = this.get('controllers.composer');
// If the composer is already set up for starting a discussion, then we
// don't need to change its content - we can just show it.
if (!(composer.get('content') instanceof ComposerDiscussion)) {
composer.switchContent(ComposerDiscussion.create({
this.showComposer(function() {
return ComposerDiscussion.create({
user: controller.get('session.user'),
submit: function(data) {
controller.saveDiscussion(data);
}
}));
}
composer.send('show');
});
});
}
}
});

View File

@@ -1,7 +1,7 @@
import Ember from 'ember';
import DiscussionResult from '../../models/discussion-result';
import PostResult from '../../models/post-result';
import DiscussionResult from 'flarum/models/discussion-result';
import PostResult from 'flarum/models/post-result';
export default Ember.Controller.extend({
needs: ['application'],
@@ -22,17 +22,15 @@ export default Ember.Controller.extend({
{key: 'oldest', label: 'Oldest', sort: 'created'},
],
terminalPostType: function() {
terminalPostType: Ember.computed('sort', function() {
return ['newest', 'oldest'].indexOf(this.get('sort')) !== -1 ? 'start' : 'last';
}.property('sort'),
}),
countType: function() {
countType: Ember.computed('sort', function() {
return this.get('sort') === 'replies' ? 'replies' : 'unread';
}.property('sort'),
}),
moreResults: function() {
return !!this.get('meta.moreUrl');
}.property('meta.moreUrl'),
moreResults: Ember.computed.bool('meta.moreUrl'),
getResults: function(start) {
var searchQuery = this.get('searchQuery');
@@ -75,9 +73,12 @@ export default Ember.Controller.extend({
});
},
searchQueryDidChange: function() {
this.get('controllers.application').set('searchQuery', this.get('searchQuery'));
this.get('controllers.application').set('searchActive', !! this.get('searchQuery'));
searchQueryDidChange: Ember.observer('searchQuery', function() {
var searchQuery = this.get('searchQuery');
this.get('controllers.application').setProperties({
searchQuery: searchQuery,
searchActive: !!searchQuery
});
var sortOptions = this.get('sortOptions');
@@ -86,13 +87,13 @@ export default Ember.Controller.extend({
} else if (!this.get('searchQuery') && sortOptions[0].sort === 'relevance') {
sortOptions.shiftObject();
}
}.observes('searchQuery'),
}),
paramsDidChange: function() {
paramsDidChange: Ember.observer('sort', 'show', 'searchQuery', function() {
if (this.get('model')) {
this.send('refresh');
}
}.observes('sort', 'show', 'searchQuery'),
}),
actions: {
loadMore: function() {

View File

@@ -1,10 +1,9 @@
import Ember from 'ember';
import AuthenticationControllerMixin from 'simple-auth/mixins/authentication-controller-mixin';
import ModalControllerMixin from '../mixins/modal-controller';
export default Ember.Controller.extend(ModalControllerMixin, AuthenticationControllerMixin, {
import ModalController from 'flarum/mixins/modal-controller';
export default Ember.Controller.extend(ModalController, AuthenticationControllerMixin, {
authenticator: 'authenticator:flarum',
loading: false,
@@ -32,5 +31,4 @@ export default Ember.Controller.extend(ModalControllerMixin, AuthenticationContr
});
}
}
});

View File

@@ -1,11 +1,8 @@
import Ember from 'ember';
import AuthenticationControllerMixin from 'simple-auth/mixins/authentication-controller-mixin';
import ModalControllerMixin from '../mixins/modal-controller';
export default Ember.Controller.extend(ModalControllerMixin, AuthenticationControllerMixin, {
authenticator: 'authenticator:flarum',
import ModalController from 'flarum/mixins/modal-controller';
export default Ember.Controller.extend(ModalController, {
actions: {
submit: function() {
var data = this.getProperties('username', 'email', 'password');
@@ -29,5 +26,4 @@ export default Ember.Controller.extend(ModalControllerMixin, AuthenticationContr
});
}
}
});

View File

@@ -0,0 +1,9 @@
import Ember from 'ember';
export default Ember.Handlebars.makeBoundHelper(function(time) {
var m = moment(time);
var datetime = m.format();
var full = m.format('LLLL');
return new Ember.Handlebars.SafeString('<time pubdate datetime="'+datetime+'">'+full+'</time>');
});

View File

@@ -15,4 +15,3 @@ export default Ember.Handlebars.makeBoundHelper(function(text, phrase) {
}
return new Ember.Handlebars.SafeString(text);
});

View File

@@ -11,4 +11,3 @@ export default Ember.Handlebars.makeBoundHelper(function(time) {
return new Ember.Handlebars.SafeString('<time pubdate datetime="'+datetime+'" title="'+full+'" data-humantime>'+ago+'</time>');
});

View File

@@ -30,12 +30,12 @@ export default Ember.Handlebars.makeBoundHelper(function(user, options) {
return;
}
var number;
if (number = user.get('avatarNumber')) {
number = number + '';
var filename = number.length >= 3 ? number : new Array(3 - number.length + 1).join('0') + number;
return new Ember.Handlebars.SafeString('<img src="/packages/flarum/core/avatars/'+filename+'.jpg" class="avatar '+options.hash.class+'">');
}
// var number;
// if (number = user.get('avatarNumber')) {
// number = number + '';
// var filename = number.length >= 3 ? number : new Array(3 - number.length + 1).join('0') + number;
// return new Ember.Handlebars.SafeString('<img src="/packages/flarum/core/avatars/'+filename+'.jpg" class="avatar '+options.hash.class+'">');
// }
var username = user.get('username');
if (!username) {

View File

@@ -1,4 +1,4 @@
import FlarumAuthorizer from '../authorizers/flarum';
import FlarumAuthorizer from 'flarum/authorizers/flarum';
export default {
name: 'authentication',

View File

@@ -1,6 +1,6 @@
import Ember from 'ember';
import humanTime from '../utils/human-time';
import humanTime from 'flarum/utils/human-time';
var $ = Ember.$;
@@ -28,7 +28,7 @@ export default {
.removeData('livestamp');
timestamp = moment(timestamp);
if (timestamp.diff(moment()) < 60 * 60) {
if (timestamp.diff(moment(new Date())) < 60 * 60) {
return;
}
if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {

View File

@@ -1,7 +1,10 @@
export default {
name: 'inject-components',
initialize: function(container, application) {
application.inject('adapter', 'alerts', 'controller:alerts')
application.inject('component', 'alerts', 'controller:alerts')
application.inject('component', 'composer', 'controller:composer')
application.inject('model', 'session', 'simple-auth-session:main')
application.inject('component', 'session', 'simple-auth-session:main')
}
};

View File

@@ -0,0 +1,14 @@
import Ember from 'ember';
export default Ember.Mixin.create({
fadeIn: Ember.on('didInsertElement', function() {
var $this = this.$();
var targetOpacity = $this.css('opacity');
$this.css('opacity', 0);
setTimeout(function() {
$this.animate({opacity: targetOpacity}, 'fast', function() {
$this.css('opacity', '');
});
}, 100);
})
});

View File

@@ -0,0 +1,45 @@
import Ember from 'ember';
import TaggedArray from 'flarum/utils/tagged-array';
import ActionButton from 'flarum/components/ui/action-button';
export default Ember.Mixin.create({
itemLists: [],
initItemLists: Ember.on('didInsertElement', function() {
var self = this;
this.get('itemLists').forEach(function(name) {
self.initItemList(name);
});
}),
initItemList: function(name) {
this.set(name, this.populateItemList(name));
},
populateItemList: function(name) {
var items = TaggedArray.create();
this.trigger('populate'+name.charAt(0).toUpperCase()+name.slice(1), items);
return items;
},
addActionItem: function(items, tag, label, icon, conditionProperty, actionName, actionTarget) {
if (conditionProperty && !this.get(conditionProperty)) { return; }
var self = this;
actionTarget = actionTarget || self.get('controller');
var item = ActionButton.extend({
label: label,
icon: icon,
action: function() {
actionTarget.send(actionName || tag);
}
});
var itemInstance = item.create();
items.pushObjectWithTag(itemInstance, tag);
return itemInstance;
}
});

View File

@@ -1,16 +1,15 @@
import Ember from 'ember';
export default Ember.Mixin.create({
focusEventOn: function() {
focusEventOn: Ember.on('didInsertElement', function() {
this.get('controller').on('focus', this, this.focus);
}.on('didInsertElement'),
}),
focusEventOff: function() {
focusEventOff: Ember.on('willDestroyElement', function() {
this.get('controller').off('focus', this, this.focus);
}.on('willDestroyElement'),
}),
focus: function() {
focus: Ember.on('didInsertElement', function() {
this.$('input:first:visible:enabled').focus();
console.log('focus first')
}.on('didInsertElement')
})
});

View File

@@ -1,10 +1,12 @@
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.
/**
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'],
@@ -23,19 +25,15 @@ export default Ember.Mixin.create({
// paned, showing, or pinned.
paneDisabled: false,
paneIsShowing: function() {
return this.get('paned') && this.get('paneShowing') && !this.get('paneDisabled');
}.property('paned', 'paneShowing', 'paneDisabled'),
paneIsPinned: function() {
return this.get('paned') && this.get('panePinned') && !this.get('paneDisabled');
}.property('paned', 'panePinned', 'paneDisabled'),
paneEnabled: Ember.computed.not('paneDisabled'),
paneIsShowing: Ember.computed.and('paned', 'paneShowing', 'paneEnabled'),
paneIsPinned: Ember.computed.and('paned', 'panePinned', 'paneEnabled'),
// Tell the application controller when we pin/unpin the pane so that
// other parts of the interface can respond appropriately.
paneIsPinnedChanged: function() {
paneIsPinnedChanged: Ember.observer('paneIsPinned', function() {
this.set('controllers.application.panePinned', this.get('paneIsPinned'));
}.observes('paneIsPinned'),
}),
actions: {
showPane: function() {

View File

@@ -0,0 +1,37 @@
import Ember from 'ember';
export default Ember.Mixin.create({
showComposer: function(buildComposerContent) {
var composer = this.get('composer');
if (this.get('composerContent') !== composer.get('content')) {
this.set('composerContent', buildComposerContent());
composer.switchContent(this.get('composerContent'));
}
composer.send('show');
},
saveAndDismissComposer: function(model) {
var composer = this.get('composer');
composer.set('content.loading', true);
this.get('alerts').send('clearAlerts');
return model.save().then(function(model) {
composer.send('hide');
return model;
}, function(reason) {
controller.showErrorsAsAlertMessages(reason.errors);
}).finally(function() {
composer.set('content.loading', false);
});
},
showErrorsAsAlertMessages: function(errors) {
for (var i in errors) {
var message = AlertMessage.create({
type: 'warning',
message: errors[i]
});
this.get('alerts').send('alert', message);
}
}
})

View File

@@ -1,12 +1,7 @@
import Ember from 'ember';
var DiscussionResult = Ember.ObjectProxy.extend({
export default Ember.ObjectProxy.extend({
relevantPosts: null,
startPost: null,
lastPost: null
});
export default DiscussionResult;

View File

@@ -1,18 +1,11 @@
import Ember from 'ember';
import DS from 'ember-data';
var Discussion = DS.Model.extend({
export default DS.Model.extend({
title: DS.attr('string'),
content: DS.attr('string'), // only used to save a new discussion
slug: function() {
slug: Ember.computed('title', function() {
return this.get('title').toLowerCase().replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-');
}.property('title'),
canReply: DS.attr('boolean'),
canEdit: DS.attr('boolean'),
canDelete: DS.attr('boolean'),
}),
startTime: DS.attr('date'),
startUser: DS.belongsTo('user'),
@@ -23,26 +16,34 @@ var Discussion = DS.Model.extend({
lastPost: DS.belongsTo('post'),
lastPostNumber: DS.attr('number'),
relevantPosts: DS.hasMany('post'),
canReply: DS.attr('boolean'),
canEdit: DS.attr('boolean'),
canDelete: DS.attr('boolean'),
commentsCount: DS.attr('number'),
repliesCount: function() {
repliesCount: Ember.computed('commentsCount', function() {
return Math.max(0, this.get('commentsCount') - 1);
}.property('commentsCount'),
}),
// The API returns the `posts` relationship as a list of IDs. To hydrate a
// post-stream object, we're only interested in obtaining a list of IDs, so
// we make it a string and then split it by comma. Instead, we'll put a
// relationship on `loadedPosts`.
posts: DS.attr('string'),
postIds: function() {
postIds: Ember.computed('posts', function() {
var posts = this.get('posts') || '';
return posts.split(',');
}.property('posts'),
}),
loadedPosts: DS.hasMany('post'),
relevantPosts: DS.hasMany('post'),
readTime: DS.attr('date'),
readNumber: DS.attr('number'),
unreadCount: function() {
return this.get('lastPostNumber') - this.get('readNumber');
}.property('lastPostNumber', 'readNumber'),
isUnread: Ember.computed.bool('unreadCount')
});
unreadCount: Ember.computed('lastPostNumber', 'readNumber', 'session.user.readTime', function() {
return this.get('session.user.readTime') < this.get('lastTime') ? Math.max(0, this.get('lastPostNumber') - (this.get('readNumber') || 0)) : 0;
}),
isUnread: Ember.computed.bool('unreadCount'),
export default Discussion;
// Only used to save a new discussion
content: DS.attr('string')
});

View File

@@ -1,9 +1,6 @@
import DS from 'ember-data';
export default DS.Model.extend({
name: DS.attr('string'),
users: DS.hasMany('group'),
});

View File

@@ -1,8 +1,9 @@
import Ember from 'ember';
// The post stream is an object which represents the posts in a discussion as
// they are displayed on the discussion page, from top to bottom. ...
/**
The post stream is an object which represents the posts in a discussion as
they are displayed on the discussion page, from top to bottom. ...
*/
export default Ember.ArrayProxy.extend(Ember.Evented, {
// An array of all of the post IDs, in chronological order, in the discussion.
@@ -17,19 +18,19 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
count: Ember.computed.alias('ids.length'),
loadedCount: function() {
loadedCount: Ember.computed('content.@each', function() {
return this.get('content').filterBy('content').length;
}.property('content.@each'),
}),
firstLoaded: function() {
firstLoaded: Ember.computed('content.@each', function() {
var first = this.objectAt(0);
return first && first.content;
}.property('content.@each'),
}),
lastLoaded: function() {
lastLoaded: Ember.computed('content.@each', function() {
var last = this.objectAt(this.get('length') - 1);
return last && last.content;
}.property('content.@each'),
}),
init: function() {
this._super();
@@ -57,7 +58,7 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
// Find the appropriate gap objects in the post stream. When we find
// one, we will turn on its loading flag.
this.get('content').forEach(function(item) {
if (! item.content && ((item.indexStart >= start && item.indexStart <= end) || (item.indexEnd >= start && item.indexEnd <= end))) {
if (!item.content && ((item.indexStart >= start && item.indexStart <= end) || (item.indexEnd >= start && item.indexEnd <= end))) {
item.set('loading', true);
item.set('direction', backwards ? 'up' : 'down');
}
@@ -73,7 +74,7 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
},
loadPosts: function(ids) {
if (! ids.length) {
if (!ids.length) {
return Ember.RSVP.resolve();
}
@@ -175,7 +176,7 @@ export default Ember.ArrayProxy.extend(Ember.Evented, {
if (post) {
item.setProperties({
content: post,
component: 'discussions/post-'+post.get('type')
component: 'discussion/post-'+post.get('type')
});
}
return item;

View File

@@ -2,7 +2,6 @@ import Ember from 'ember';
import DS from 'ember-data';
export default DS.Model.extend({
discussion: DS.belongsTo('discussion', {inverse: 'loadedPosts'}),
number: DS.attr('number'),
@@ -16,11 +15,10 @@ export default DS.Model.extend({
editUser: DS.belongsTo('user'),
isEdited: Ember.computed.notEmpty('editTime'),
isHidden: DS.attr('boolean'),
deleteTime: DS.attr('date'),
deleteUser: DS.belongsTo('user'),
isDeleted: Ember.computed.notEmpty('deleteTime'),
canEdit: DS.attr('boolean'),
canDelete: DS.attr('boolean')
});

View File

@@ -1,23 +1,20 @@
import DS from 'ember-data';
export default DS.Model.extend({
username: DS.attr('string'),
email: DS.attr('string'),
password: DS.attr('string'),
avatarUrl: DS.attr('string'),
groups: DS.hasMany('group'),
joinTime: DS.attr('date'),
lastSeenTime: DS.attr('date'),
readTime: DS.attr('date'),
discussionsCount: DS.attr('number'),
postsCount: DS.attr('number'),
canEdit: DS.attr('boolean'),
canDelete: DS.attr('boolean'),
groups: DS.hasMany('group'),
email: DS.attr('string'),
password: DS.attr('string'),
avatarNumber: function() {
return Math.random() > 0.3 ? Math.floor(Math.random() * 19) + 1 : null;
}.property()
canDelete: DS.attr('boolean')
});

View File

@@ -2,7 +2,6 @@ import Ember from 'ember';
import ApplicationRouteMixin from 'simple-auth/mixins/application-route-mixin';
export default Ember.Route.extend(ApplicationRouteMixin, {
actions: {
login: function() {
this.controllerFor('login').set('error', null);

View File

@@ -1,6 +1,6 @@
import Ember from 'ember';
import PostStream from '../models/post-stream';
import PostStream from 'flarum/models/post-stream';
export default Ember.Route.extend({
queryParams: {
@@ -116,6 +116,6 @@ export default Ember.Route.extend({
.set('paned', true)
.set('paneShowing', false);
this.controllerFor('composer').send('minimize');
},
}
}
});

View File

@@ -1,8 +1,8 @@
import Ember from 'ember';
import AddCssClassToBodyMixin from '../../mixins/add-css-class-to-body';
import AddCssClassToBody from 'flarum/mixins/add-css-class-to-body';
export default Ember.Route.extend(AddCssClassToBodyMixin, {
export default Ember.Route.extend(AddCssClassToBody, {
cachedModel: null,
model: function() {
@@ -38,7 +38,11 @@ export default Ember.Route.extend(AddCssClassToBodyMixin, {
},
didTransition: function() {
// @todo only if it's not a new discussion
var application = this.controllerFor('application');
if (application.get('backButtonTarget') === this.controllerFor('index')) {
application.set('backButtonTarget', null);
}
this.controllerFor('composer').send('minimize');
this.controllerFor('index').set('paned', false);
this.controllerFor('index').set('paneShowing', false);

View File

@@ -12,7 +12,7 @@
@import "@{bootstrap-base}type.less";
@import "@{bootstrap-base}code.less";
@import "@{bootstrap-base}grid.less";
@import "@{bootstrap-base}tables.less";
// @import "@{bootstrap-base}tables.less";
@import "@{bootstrap-base}forms.less";
@import "@{bootstrap-base}buttons.less";
@@ -42,7 +42,7 @@
// Components w/ JavaScript
@import "@{bootstrap-base}modals.less";
@import "@{bootstrap-base}tooltip.less";
@import "@{bootstrap-base}popovers.less";
// @import "@{bootstrap-base}popovers.less";
// @import "@{bootstrap-base}carousel.less";
// Utility classes

View File

@@ -37,6 +37,11 @@
.avatar-size(24px);
}
}
.btn-more {
padding: 1px 3px;
border-radius: 2px;
line-height: 1;
}
// Redefine Bootstrap's mixin to make some general changes
.button-variant(@color; @background; @border) {

View File

@@ -96,12 +96,14 @@
.post {
padding-left: 90px;
padding-bottom: 1px;
transition: 0.2s box-shadow;
& .contextual-controls {
float: right;
margin: -2px 0 0 10px;
visibility: hidden;
z-index: 1;
}
&:hover .contextual-controls, & .contextual-controls.open {
visibility: visible;
@@ -110,7 +112,7 @@
.item.highlight .post {
border: 8px solid rgba(255, 255, 0, 0.2);
border-radius: 8px;
padding: 15px 15px 0 105px;
padding: 15px 15px 1px 105px;
margin: -23px -23px -8px -23px;
}
@@ -124,9 +126,13 @@
margin: 0;
& > li {
display: inline;
margin-right: 10px;
}
&, & a {
color: @fl-body-muted-color;
}
}
& .user {
& .post-user {
margin: 0;
display: inline;
font-weight: bold;
@@ -140,21 +146,49 @@
float: left;
.avatar-size(64px);
}
& .time {
margin-left: 10px;
&, & a {
color: @fl-body-muted-color;
}
}
}
.post-body {
font-size: 14px;
line-height: 1.6;
padding-bottom: 1px;
}
.post-edited {
margin-left: 10px;
font-size: 14px;
.post-icon {
float: left;
margin-top: -2px;
margin-left: -90px;
width: 64px;
text-align: center;
font-size: 22px;
}
.post.deleted {
& .post-user, & .post-header > ul, & .post-header > ul a:not(.btn) {
color: @fl-body-muted-more-color;
}
&:not(.reveal-content) {
& .post-body, & .post-footer, & .avatar {
display: none;
}
}
&.reveal-content {
& .post-body, & .post-footer, & .avatar {
opacity: 0.5;
}
}
}
.post-meta {
width: 400px;
padding: 10px;
color: @fl-body-muted-color;
& .number {
color: @fl-body-color;
font-weight: bold;
}
& .time {
margin-left: 5px;
}
& .permalink {
margin-top: 10px;
}
}
// ------------------------------------

View File

@@ -189,25 +189,21 @@
padding-right: 65px;
position: relative;
&, & a {
color: @fl-body-muted-color;
}
& .author {
float: left;
margin-left: -65px;
margin-top: 18px;
}
& .info {
& .main {
display: inline-block;
width: 100%;
margin-right: -65px;
color: @fl-body-muted-color;
padding: 20px 0;
margin-right: -65px;
&:hover, &:active, &.active, &:focus {
text-decoration: none;
& .title {
text-decoration: underline;
}
}
&.active .title {
&.active {
text-decoration: none;
}
}
@@ -215,13 +211,14 @@
margin: 0 0 5px;
font-size: 15px;
line-height: 1.3;
&, & a {
font-weight: normal;
color: @fl-body-muted-color;
}
.unread&, .unread& a {
font-weight: bold;
color: @fl-body-heading-color;
& .info {
list-style-type: none;
padding: 0;
margin: 0;
& > li {
display: inline-block;
}
}
& .count {
@@ -233,7 +230,6 @@
text-transform: uppercase;
color: @fl-body-muted-color;
font-size: 11px;
cursor: pointer;
text-decoration: none;
& strong {
@@ -241,6 +237,9 @@
display: block;
font-weight: 300;
}
.unread& {
cursor: pointer;
}
.unread&, .unread& strong {
color: @fl-body-heading-color;
font-weight: bold;

View File

@@ -131,6 +131,7 @@ body {
margin-top: -1px;
text-align: center;
padding: 30px 0;
font-size: 14px;
}
.hero .close {
float: right;

View File

@@ -2,7 +2,7 @@
<header id="header" class="global-header">
{{back-button target=backButtonTarget}}
{{application/back-button target=backButtonTarget}}
<div class="container">
@@ -17,11 +17,11 @@
{{/link-to}}
</h1>
{{ui/controls/item-list items=view.headerPrimaryItems class="header-controls"}}
{{ui/item-list items=view.headerPrimary class="header-controls"}}
</div>
<div class="header-secondary">
{{ui/controls/item-list items=view.headerSecondaryItems class="header-controls"}}
{{ui/item-list items=view.headerSecondary class="header-controls"}}
</div>
</div>
@@ -40,8 +40,8 @@
<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"}}
{{ui/item-list items=view.footerPrimary class="footer-primary"}}
{{ui/item-list items=view.footerSecondary class="footer-secondary"}}
</div>
</footer>

View File

@@ -1,2 +0,0 @@
<span class="alert-text">{{message}}</span>
{{ui/controls/item-list items=controls class="alert-controls"}}

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

@@ -0,0 +1,5 @@
<a href="#" {{bind-attr class=":dropdown-toggle buttonClass"}} data-toggle="dropdown" {{action "buttonClick"}}>
{{user-avatar user}}
<span class="label">{{label}}</span>
</a>
{{ui/item-list items=items class=dropdownMenuClass}}

View File

@@ -1,8 +0,0 @@
{{#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

@@ -0,0 +1,11 @@
{{user-avatar user class="composer-avatar"}}
<div class="composer-body">
{{ui/item-list items=controls class="composer-header list-inline"}}
<div class="composer-editor">
{{ui/text-editor submit="submit" value=content placeholder=placeholder submitLabel=submitLabel disabled=loading}}
</div>
</div>
{{ui/loading-indicator classNameBindings=":composer-loading loading:active"}}

View File

@@ -0,0 +1,20 @@
{{#if controls}}
{{ui/dropdown-button
items=renderControls
class="contextual-controls"
buttonClass="btn btn-default btn-icon btn-sm btn-naked"
buttonClick="renderControls"
menuClass="pull-right"}}
{{/if}}
<header class="post-header">
{{ui/item-list items=header}}
</header>
<div class="post-body">
{{{post.contentHtml}}}
</div>
<aside class="post-footer">
{{ui/item-list items=footer}}
</aside>

View File

@@ -0,0 +1,6 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown">{{human-time post.time}}</a>
<div class="dropdown-menu post-permalink">
<span class="number">Post #{{post.number}}</span>
<span class="time">{{full-time post.time}}</span>
<input {{bind-attr value=permalink}} class="form-control">
</div>

Some files were not shown because too many files have changed in this diff Show More