diff --git a/framework/core/ember/Brocfile.js b/framework/core/ember/Brocfile.js index 79dc7ace5..3b8d121a2 100644 --- a/framework/core/ember/Brocfile.js +++ b/framework/core/ember/Brocfile.js @@ -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'); diff --git a/framework/core/ember/README.md b/framework/core/ember/README.md new file mode 100644 index 000000000..09024920e --- /dev/null +++ b/framework/core/ember/README.md @@ -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 ` 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/) + diff --git a/framework/core/ember/app/adapters/application.js b/framework/core/ember/app/adapters/application.js index 431bb96ea..81365ba32 100644 --- a/framework/core/ember/app/adapters/application.js +++ b/framework/core/ember/app/adapters/application.js @@ -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 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; } - }); diff --git a/framework/core/ember/app/components/.gitkeep b/framework/core/ember/app/components/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/framework/core/ember/app/components/alert-message.js b/framework/core/ember/app/components/alert-message.js deleted file mode 100755 index 7da7232db..000000000 --- a/framework/core/ember/app/components/alert-message.js +++ /dev/null @@ -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); - } - } -}); diff --git a/framework/core/ember/app/components/application/back-button.js b/framework/core/ember/app/components/application/back-button.js new file mode 100755 index 000000000..47c633ee9 --- /dev/null +++ b/framework/core/ember/app/components/application/back-button.js @@ -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'); + } + } +}); diff --git a/framework/core/ember/app/components/application/forum-statistic.js b/framework/core/ember/app/components/application/forum-statistic.js new file mode 100644 index 000000000..e640ec544 --- /dev/null +++ b/framework/core/ember/app/components/application/forum-statistic.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +var precompileTemplate = Ember.Handlebars.compile; + +export default Ember.Component.extend({ + layout: precompileTemplate('{{number}} {{label}}') +}); diff --git a/framework/core/ember/app/components/application/go-to-top.js b/framework/core/ember/app/components/application/go-to-top.js new file mode 100644 index 000000000..eb5e0fdc9 --- /dev/null +++ b/framework/core/ember/app/components/application/go-to-top.js @@ -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}); + } +}) diff --git a/framework/core/ember/app/components/application/powered-by.js b/framework/core/ember/app/components/application/powered-by.js new file mode 100644 index 000000000..0acba3e34 --- /dev/null +++ b/framework/core/ember/app/components/application/powered-by.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; + +var precompileTemplate = Ember.Handlebars.compile; + +export default Ember.Component.extend({ + layout: precompileTemplate('Powered by Flarum') +}); diff --git a/framework/core/ember/app/components/application/user-dropdown.js b/framework/core/ember/app/components/application/user-dropdown.js new file mode 100644 index 000000000..32164d8d1 --- /dev/null +++ b/framework/core/ember/app/components/application/user-dropdown.js @@ -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(); + } + } +}) diff --git a/framework/core/ember/app/components/back-button.js b/framework/core/ember/app/components/back-button.js deleted file mode 100755 index 4be9abef0..000000000 --- a/framework/core/ember/app/components/back-button.js +++ /dev/null @@ -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'); - } - } -}); diff --git a/framework/core/ember/app/components/composer/composer-body.js b/framework/core/ember/app/components/composer/composer-body.js new file mode 100644 index 000000000..9dc246a17 --- /dev/null +++ b/framework/core/ember/app/components/composer/composer-body.js @@ -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(); + } + } + } +}); diff --git a/framework/core/ember/app/components/composer/composer-discussion.js b/framework/core/ember/app/components/composer/composer-discussion.js new file mode 100644 index 000000000..a3698b8aa --- /dev/null +++ b/framework/core/ember/app/components/composer/composer-discussion.js @@ -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(); + } + } + } +}); diff --git a/framework/core/ember/app/components/composer/composer-edit.js b/framework/core/ember/app/components/composer/composer-edit.js new file mode 100644 index 000000000..8cf2c8f58 --- /dev/null +++ b/framework/core/ember/app/components/composer/composer-edit.js @@ -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 {{discussion.title}}'), + discussion: this.get('post.discussion'), + component: this + }); + controls.pushObjectWithTag(title, 'title'); + } +}); diff --git a/framework/core/ember/app/components/composer/composer-reply.js b/framework/core/ember/app/components/composer/composer-reply.js new file mode 100644 index 000000000..d681589d4 --- /dev/null +++ b/framework/core/ember/app/components/composer/composer-reply.js @@ -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 {{component.discussion.title}}'), + component: this + }); + items.pushObjectWithTag(title, 'title'); + } +}); diff --git a/framework/core/ember/app/components/discussion/post-comment.js b/framework/core/ember/app/components/discussion/post-comment.js new file mode 100644 index 000000000..deea9dac8 --- /dev/null +++ b/framework/core/ember/app/components/discussion/post-comment.js @@ -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(); + } + } +}); diff --git a/framework/core/ember/app/components/discussion/post-header/edited.js b/framework/core/ember/app/components/discussion/post-header/edited.js new file mode 100644 index 000000000..e28f1b147 --- /dev/null +++ b/framework/core/ember/app/components/discussion/post-header/edited.js @@ -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'); + }); + }) +}); diff --git a/framework/core/ember/app/components/discussion/post-header/meta.js b/framework/core/ember/app/components/discussion/post-header/meta.js new file mode 100644 index 000000000..e29dfe23f --- /dev/null +++ b/framework/core/ember/app/components/discussion/post-header/meta.js @@ -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(); + }); + } +}); diff --git a/framework/core/ember/app/components/discussion/post-header/toggle.js b/framework/core/ember/app/components/discussion/post-header/toggle.js new file mode 100644 index 000000000..cdef15650 --- /dev/null +++ b/framework/core/ember/app/components/discussion/post-header/toggle.js @@ -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('{{fa-icon "ellipsis-h"}}'), + + hideItem: Ember.computed.not('post.isHidden'), + + actions: { + toggle: function() { + this.toggleProperty('parent.revealContent'); + } + } +}); diff --git a/framework/core/ember/app/components/discussion/post-header/user.js b/framework/core/ember/app/components/discussion/post-header/user.js new file mode 100644 index 000000000..627fc0435 --- /dev/null +++ b/framework/core/ember/app/components/discussion/post-header/user.js @@ -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}}') +}); diff --git a/framework/core/ember/app/components/discussion/stream-content.js b/framework/core/ember/app/components/discussion/stream-content.js new file mode 100644 index 000000000..6218fe458 --- /dev/null +++ b/framework/core/ember/app/components/discussion/stream-content.js @@ -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); + } + } +}); diff --git a/framework/core/ember/app/components/discussion/stream-item.js b/framework/core/ember/app/components/discussion/stream-item.js new file mode 100644 index 000000000..7cac1419d --- /dev/null +++ b/framework/core/ember/app/components/discussion/stream-item.js @@ -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(''); + if (this.get('loading')) { + buffer.push(' '); + } else { + buffer.push(this.get('count')+' more post'+(this.get('count') !== 1 ? 's' : '')); + } + buffer.push(''); + }, + + 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(); + } +}); diff --git a/framework/core/ember/app/components/discussion/stream-scrubber.js b/framework/core/ember/app/components/discussion/stream-scrubber.js new file mode 100644 index 000000000..48d39c657 --- /dev/null +++ b/framework/core/ember/app/components/discussion/stream-scrubber.js @@ -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'); + } + } +}); diff --git a/framework/core/ember/app/components/discussions/composer-discussion.js b/framework/core/ember/app/components/discussions/composer-discussion.js deleted file mode 100644 index 4f4780125..000000000 --- a/framework/core/ember/app/components/discussions/composer-discussion.js +++ /dev/null @@ -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', ''); - } - } -}); diff --git a/framework/core/ember/app/components/discussions/composer-edit.js b/framework/core/ember/app/components/discussions/composer-edit.js deleted file mode 100644 index d738e67c0..000000000 --- a/framework/core/ember/app/components/discussions/composer-edit.js +++ /dev/null @@ -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 {{discussion.title}}'), - 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(); - } - } - } -}); diff --git a/framework/core/ember/app/components/discussions/composer-reply.js b/framework/core/ember/app/components/discussions/composer-reply.js deleted file mode 100644 index 272e441c3..000000000 --- a/framework/core/ember/app/components/discussions/composer-reply.js +++ /dev/null @@ -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 {{component.discussion.title}}'), - 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', ''); - } - } -}); diff --git a/framework/core/ember/app/components/discussions/discussion-listing.js b/framework/core/ember/app/components/discussions/discussion-listing.js deleted file mode 100755 index 9f78325c4..000000000 --- a/framework/core/ember/app/components/discussions/discussion-listing.js +++ /dev/null @@ -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')); - } - } - } - -}); diff --git a/framework/core/ember/app/components/discussions/post-comment.js b/framework/core/ember/app/components/discussions/post-comment.js deleted file mode 100644 index b6f0b3c7f..000000000 --- a/framework/core/ember/app/components/discussions/post-comment.js +++ /dev/null @@ -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('{{fa-icon "pencil"}}'), - 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'); - } - } -}); diff --git a/framework/core/ember/app/components/discussions/stream-content.js b/framework/core/ember/app/components/discussions/stream-content.js deleted file mode 100644 index 7d6b11efb..000000000 --- a/framework/core/ember/app/components/discussions/stream-content.js +++ /dev/null @@ -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); - } - } -}); diff --git a/framework/core/ember/app/components/discussions/stream-item.js b/framework/core/ember/app/components/discussions/stream-item.js deleted file mode 100644 index 8971d8462..000000000 --- a/framework/core/ember/app/components/discussions/stream-item.js +++ /dev/null @@ -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(''); - if (this.get('loading')) { - buffer.push(' '); - } else { - buffer.push(this.get('count')+' more post'+(this.get('count') !== 1 ? 's' : '')); - } - buffer.push(''); - }, - - 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(); - } -}); diff --git a/framework/core/ember/app/components/discussions/stream-scrubber.js b/framework/core/ember/app/components/discussions/stream-scrubber.js deleted file mode 100644 index 95c855e4d..000000000 --- a/framework/core/ember/app/components/discussions/stream-scrubber.js +++ /dev/null @@ -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'); - } - } -}); diff --git a/framework/core/ember/app/components/index/discussion-listing.js b/framework/core/ember/app/components/index/discussion-listing.js new file mode 100755 index 000000000..438c6177b --- /dev/null +++ b/framework/core/ember/app/components/index/discussion-listing.js @@ -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(); + } + } + } +}); diff --git a/framework/core/ember/app/components/index/welcome-hero.js b/framework/core/ember/app/components/index/welcome-hero.js new file mode 100644 index 000000000..f1e3c8ab8 --- /dev/null +++ b/framework/core/ember/app/components/index/welcome-hero.js @@ -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(); + } + } +}); diff --git a/framework/core/ember/app/components/ui/action-button.js b/framework/core/ember/app/components/ui/action-button.js new file mode 100644 index 000000000..dc4ad971c --- /dev/null +++ b/framework/core/ember/app/components/ui/action-button.js @@ -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}}{{label}}'), + + 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(); + } + } +}); diff --git a/framework/core/ember/app/components/ui/alert-message.js b/framework/core/ember/app/components/ui/alert-message.js new file mode 100755 index 000000000..09f9d279d --- /dev/null +++ b/framework/core/ember/app/components/ui/alert-message.js @@ -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); + } + } +}); diff --git a/framework/core/ember/app/components/ui/controls/action-button.js b/framework/core/ember/app/components/ui/controls/action-button.js deleted file mode 100644 index 94dc384df..000000000 --- a/framework/core/ember/app/components/ui/controls/action-button.js +++ /dev/null @@ -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}}{{label}}'), - - click: function(e) { - e.preventDefault(); - var action = this.get('action'); - if (typeof action === 'string') { - this.sendAction('action'); - } else if (typeof action === 'function') { - action(); - } - } -}); \ No newline at end of file diff --git a/framework/core/ember/app/components/ui/controls/dropdown-button.js b/framework/core/ember/app/components/ui/controls/dropdown-button.js deleted file mode 100644 index 6d29357c9..000000000 --- a/framework/core/ember/app/components/ui/controls/dropdown-button.js +++ /dev/null @@ -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'); - } - } -}); diff --git a/framework/core/ember/app/components/ui/controls/dropdown-select.js b/framework/core/ember/app/components/ui/controls/dropdown-select.js deleted file mode 100644 index eead02e6c..000000000 --- a/framework/core/ember/app/components/ui/controls/dropdown-select.js +++ /dev/null @@ -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') -}); \ No newline at end of file diff --git a/framework/core/ember/app/components/ui/controls/dropdown-split.js b/framework/core/ember/app/components/ui/controls/dropdown-split.js deleted file mode 100644 index ef1f9468b..000000000 --- a/framework/core/ember/app/components/ui/controls/dropdown-split.js +++ /dev/null @@ -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.[]') -}); diff --git a/framework/core/ember/app/components/ui/controls/item-list.js b/framework/core/ember/app/components/ui/controls/item-list.js deleted file mode 100644 index e50f9fbc4..000000000 --- a/framework/core/ember/app/components/ui/controls/item-list.js +++ /dev/null @@ -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.[]') -}); diff --git a/framework/core/ember/app/components/ui/controls/loading-indicator.js b/framework/core/ember/app/components/ui/controls/loading-indicator.js deleted file mode 100644 index b1c2b3313..000000000 --- a/framework/core/ember/app/components/ui/controls/loading-indicator.js +++ /dev/null @@ -1,14 +0,0 @@ -import Ember from 'ember'; - -export default Ember.Component.extend({ - classNames: ['loading-indicator'], - - layout: Ember.Handlebars.compile(' '), - size: 'small', - - didInsertElement: function() { - var size = this.get('size'); - Ember.$.fn.spin.presets[size].zIndex = 'auto'; - this.$().spin(size); - } -}); diff --git a/framework/core/ember/app/components/ui/controls/search-input.js b/framework/core/ember/app/components/ui/controls/search-input.js deleted file mode 100644 index 9f105c6d4..000000000 --- a/framework/core/ember/app/components/ui/controls/search-input.js +++ /dev/null @@ -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')); - } - } -}); diff --git a/framework/core/ember/app/components/ui/controls/select-input.js b/framework/core/ember/app/components/ui/controls/select-input.js deleted file mode 100644 index 020d6955f..000000000 --- a/framework/core/ember/app/components/ui/controls/select-input.js +++ /dev/null @@ -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"}}') -}); diff --git a/framework/core/ember/app/components/ui/controls/text-editor.js b/framework/core/ember/app/components/ui/controls/text-editor.js deleted file mode 100644 index b787cc6e1..000000000 --- a/framework/core/ember/app/components/ui/controls/text-editor.js +++ /dev/null @@ -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')); - } - } -}); \ No newline at end of file diff --git a/framework/core/ember/app/components/ui/controls/text-input.js b/framework/core/ember/app/components/ui/controls/text-input.js deleted file mode 100644 index 7934f3e5f..000000000 --- a/framework/core/ember/app/components/ui/controls/text-input.js +++ /dev/null @@ -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(''); - } - }); - } -}); diff --git a/framework/core/ember/app/components/ui/dropdown-button.js b/framework/core/ember/app/components/ui/dropdown-button.js new file mode 100644 index 000000000..69d71e1c0 --- /dev/null +++ b/framework/core/ember/app/components/ui/dropdown-button.js @@ -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'); + } + } +}); diff --git a/framework/core/ember/app/components/ui/dropdown-select.js b/framework/core/ember/app/components/ui/dropdown-select.js new file mode 100644 index 000000000..5d5781d06 --- /dev/null +++ b/framework/core/ember/app/components/ui/dropdown-select.js @@ -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'); + }) +}); diff --git a/framework/core/ember/app/components/ui/dropdown-split.js b/framework/core/ember/app/components/ui/dropdown-split.js new file mode 100644 index 000000000..435718500 --- /dev/null +++ b/framework/core/ember/app/components/ui/dropdown-split.js @@ -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); + }) +}); diff --git a/framework/core/ember/app/components/ui/item-list.js b/framework/core/ember/app/components/ui/item-list.js new file mode 100644 index 000000000..75d119451 --- /dev/null +++ b/framework/core/ember/app/components/ui/item-list.js @@ -0,0 +1,21 @@ +import Ember from 'ember'; + +/** + Output a list of components within a