1
0
mirror of https://github.com/flarum/core.git synced 2025-01-18 06:38:25 +01:00

Upgrade to Ember 1.11-beta.1

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

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

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

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

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

View File

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

53
ember/README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,22 +2,22 @@ import Ember from 'ember';
export default Ember.Controller.extend({
// The title of the forum.
// TODO: Preload this value in the index.html payload from Laravel config.
forumTitle: 'Flarum Demo Forum',
// The title of the forum.
// TODO: Preload this value in the index.html payload from Laravel config.
forumTitle: 'Flarum Demo Forum',
// The title of the current page. This should be set as appropriate in
// controllers/views.
pageTitle: '',
// The title of the current page. This should be set as appropriate in
// controllers/views.
pageTitle: '',
backButtonTarget: null,
backButtonTarget: null,
searchQuery: '',
searchActive: false,
searchQuery: '',
searchActive: false,
actions: {
search: function(query) {
this.transitionToRoute('index', {queryParams: {searchQuery: query, sort: query ? 'relevance' : 'recent'}});
}
}
actions: {
search: function(query) {
this.transitionToRoute('index', {queryParams: {searchQuery: query, sort: query ? 'relevance' : 'recent'}});
}
}
});

View File

@ -1,99 +1,99 @@
import Ember from 'ember';
export var PositionEnum = {
HIDDEN: 'hidden',
NORMAL: 'normal',
MINIMIZED: 'minimized',
FULLSCREEN: 'fullscreen'
HIDDEN: 'hidden',
NORMAL: 'normal',
MINIMIZED: 'minimized',
FULLSCREEN: 'fullscreen'
};
export default Ember.Controller.extend(Ember.Evented, {
content: null,
position: PositionEnum.HIDDEN,
content: null,
position: PositionEnum.HIDDEN,
visible: Ember.computed.or('normal', 'minimized', 'fullscreen'),
normal: Ember.computed.equal('position', PositionEnum.NORMAL),
minimized: Ember.computed.equal('position', PositionEnum.MINIMIZED),
fullscreen: Ember.computed.equal('position', PositionEnum.FULLSCREEN),
visible: Ember.computed.or('normal', 'minimized', 'fullscreen'),
normal: Ember.computed.equal('position', PositionEnum.NORMAL),
minimized: Ember.computed.equal('position', PositionEnum.MINIMIZED),
fullscreen: Ember.computed.equal('position', PositionEnum.FULLSCREEN),
// Switch out the composer's content for a new component. The old
// component will be given the opportunity to abort the switch. Note:
// there appears to be a bug in Ember where the content binding won't
// update in the view if we switch the value out immediately. As a
// workaround, we set it to null, and then set it to its new value in the
// next run loop iteration.
switchContent: function(newContent) {
var composer = this;
this.confirmExit().then(function() {
composer.set('content', null);
Ember.run.next(function() {
newContent.set('composer', composer);
composer.set('content', newContent);
});
});
// Switch out the composer's content for a new component. The old
// component will be given the opportunity to abort the switch. Note:
// there appears to be a bug in Ember where the content binding won't
// update in the view if we switch the value out immediately. As a
// workaround, we set it to null, and then set it to its new value in the
// next run loop iteration.
switchContent: function(newContent) {
var composer = this;
this.confirmExit().then(function() {
composer.set('content', null);
Ember.run.next(function() {
newContent.composer = composer;
composer.set('content', newContent);
});
});
},
// Ask the content component if it's OK to close it, and give it the
// opportunity to abort. The content component must respond to the
// `willExit(abort)` action, and call `abort()` if we should not proceed.
confirmExit: function() {
var composer = this;
var promise = new Ember.RSVP.Promise(function(resolve, reject) {
var content = composer.get('content');
if (content) {
content.send('willExit', reject);
}
resolve();
});
return promise;
},
actions: {
show: function() {
var composer = this;
// We do this in the next run loop because we need to wait for new
// content to be switched in. See `switchContent` above.
Ember.run.next(function() {
composer.set('position', PositionEnum.NORMAL);
composer.trigger('focus');
});
},
// Ask the content component if it's OK to close it, and give it the
// opportunity to abort. The content component must respond to the
// `willExit(abort)` action, and call `abort()` if we should not proceed.
confirmExit: function() {
var composer = this;
var promise = new Ember.RSVP.Promise(function(resolve, reject) {
var content = composer.get('content');
if (content) {
content.send('willExit', reject);
}
resolve();
});
return promise;
hide: function() {
this.set('position', PositionEnum.HIDDEN);
},
actions: {
show: function() {
var composer = this;
clearContent: function() {
this.set('content', null);
},
// We do this in the next run loop because we need to wait for new
// content to be switched in. See `switchContent` above.
Ember.run.next(function() {
composer.set('position', PositionEnum.NORMAL);
composer.trigger('focus');
});
},
close: function() {
var composer = this;
this.confirmExit().then(function() {
composer.send('hide');
});
},
hide: function() {
this.set('position', PositionEnum.HIDDEN);
},
minimize: function() {
if (this.get('position') !== PositionEnum.HIDDEN) {
this.set('position', PositionEnum.MINIMIZED);
}
},
clearContent: function() {
this.set('content', null);
},
fullscreen: function() {
if (this.get('position') !== PositionEnum.HIDDEN) {
this.set('position', PositionEnum.FULLSCREEN);
this.trigger('focus');
}
},
close: function() {
var composer = this;
this.confirmExit().then(function() {
composer.send('hide');
});
},
minimize: function() {
if (this.get('position') !== PositionEnum.HIDDEN) {
this.set('position', PositionEnum.MINIMIZED);
}
},
fullscreen: function() {
if (this.get('position') !== PositionEnum.HIDDEN) {
this.set('position', PositionEnum.FULLSCREEN);
this.trigger('focus');
}
},
exitFullscreen: function() {
if (this.get('position') === PositionEnum.FULLSCREEN) {
this.set('position', PositionEnum.NORMAL);
this.trigger('focus');
}
}
exitFullscreen: function() {
if (this.get('position') === PositionEnum.FULLSCREEN) {
this.set('position', PositionEnum.NORMAL);
this.trigger('focus');
}
}
}
});

View File

@ -1,116 +1,103 @@
import Ember from 'ember';
import ComposerReply from '../components/discussions/composer-reply';
import ActionButton from '../components/ui/controls/action-button';
import AlertMessage from '../components/alert-message';
import ComposerReply from 'flarum/components/composer/composer-reply';
import ActionButton from 'flarum/components/ui/action-button';
import AlertMessage from 'flarum/components/ui/alert-message';
import UseComposerMixin from 'flarum/mixins/use-composer';
export default Ember.Controller.extend(Ember.Evented, {
needs: ['application', 'alerts', 'composer'],
queryParams: ['start'],
start: '1',
searchQuery: '',
export default Ember.Controller.extend(Ember.Evented, UseComposerMixin, {
needs: ['application'],
composer: Ember.inject.controller('composer'),
alerts: Ember.inject.controller('alerts'),
loaded: false,
stream: null,
queryParams: ['start'],
start: '1',
searchQuery: '',
// Save a reply. This may be called by a composer-reply component that was
// set up on a different discussion, so we require a discussion model to
// be explicitly passed rather than using the controller's implicit one.
// @todo break this down into bite-sized functions so that extensions can
// easily override where they please.
saveReply: function(discussion, data) {
var controller = this;
var composer = this.get('controllers.composer');
var stream = this.get('stream');
loaded: false,
stream: null,
composer.set('content.loading', true);
controller.get('controllers.alerts').send('clearAlerts');
// Save a reply. This may be called by a composer-reply component that was
// set up on a different discussion, so we require a discussion model to
// be explicitly passed rather than using the controller's implicit one.
// @todo break this down into bite-sized functions so that extensions can
// easily override where they please.
saveReply: function(discussion, data) {
var post = this.store.createRecord('post', {
content: data.content,
discussion: discussion
});
var post = this.store.createRecord('post', {
content: data.content,
discussion: discussion
});
var controller = this;
var stream = this.get('stream');
return this.saveAndDismissComposer(post).then(function(post) {
discussion.setProperties({
lastTime: post.get('time'),
lastUser: post.get('user'),
lastPost: post,
lastPostNumber: post.get('number'),
commentsCount: discussion.get('commentsCount') + 1,
readTime: post.get('time'),
readNumber: post.get('number')
});
return post.save().then(function(post) {
composer.send('hide');
discussion.setProperties({
lastTime: post.get('time'),
lastUser: post.get('user'),
lastPost: post,
lastPostNumber: post.get('number'),
commentsCount: discussion.get('commentsCount') + 1,
readTime: post.get('time'),
readNumber: post.get('number')
});
// If we're currently viewing the discussion which this reply was
// made in, then we can add the post to the end of the post
// stream.
if (discussion == controller.get('model') && stream) {
stream.addPostToEnd(post);
} else {
// Otherwise, we'll create an alert message to inform the user
// that their reply has been posted, containing a button which
// will transition to their new post when clicked.
var message = AlertMessage.create({
type: 'success',
message: 'Your reply was posted.'
});
message.on('populateControls', function(controls) {
controls.pushObjectWithTag(ActionButton.create({
label: 'View',
action: function() {
controller.transitionToRoute('discussion', post.get('discussion'), {queryParams: {start: post.get('number')}});
message.send('dismiss');
}
}), 'view');
});
controller.get('controllers.alerts').send('alert', message);
// If we're currently viewing the discussion which this reply was
// made in, then we can add the post to the end of the post
// stream.
if (discussion == controller.get('model') && stream) {
stream.addPostToEnd(post);
} else {
// Otherwise, we'll create an alert message to inform the user
// that their reply has been posted, containing a button which
// will transition to their new post when clicked.
var message = AlertMessage.create({
type: 'success',
message: 'Your reply was posted.',
buttons: [{
label: 'View',
action: function() {
controller.transitionToRoute('discussion', post.get('discussion'), {queryParams: {start: post.get('number')}});
}
},
function(reason) {
var errors = reason.errors;
for (var i in reason.errors) {
var message = AlertMessage.create({
type: 'warning',
message: reason.errors[i]
});
controller.get('controllers.alerts').send('alert', message);
}
})
.finally(function() {
composer.set('content.loading', false);
}]
});
controller.get('alerts').send('alert', message);
}
});
},
// Whenever we transition to a different discussion or the logged-in user
// changes, we'll need the composer content to refresh next time the reply
// button is clicked.
clearComposerContent: Ember.observer('model', 'session.user', function() {
this.set('composerContent', undefined);
}),
actions: {
reply: function() {
var discussion = this.get('model');
var controller = this;
this.showComposer(function() {
return ComposerReply.create({
user: controller.get('session.user'),
discussion: discussion,
submit: function(data) {
controller.saveReply(discussion, data);
}
});
});
},
actions: {
reply: function() {
var controller = this;
var discussion = this.get('model');
var composer = this.get('controllers.composer');
// This action is called when the start position of the discussion
// currently being viewed changes (i.e. when the user scrolls up/down
// the post stream.)
positionChanged: function(startNumber, endNumber) {
this.set('start', startNumber);
// If the composer is already set up for this discussion, then we
// don't need to change its content - we can just show it.
if (!(composer.get('content') instanceof ComposerReply) || composer.get('content.discussion') !== discussion) {
composer.switchContent(ComposerReply.create({
user: controller.get('session.user'),
discussion: discussion,
submit: function(data) {
controller.saveReply(discussion, data);
}
}));
}
composer.send('show');
},
// This action is called when the start position of the discussion
// currently being viewed changes (i.e. when the user scrolls up/down
// the post stream.)
updateStart: function(start) {
this.set('start', start);
}
var discussion = this.get('model');
if (endNumber > discussion.get('readNumber')) {
discussion.set('readNumber', endNumber);
discussion.save();
}
}
}
});

View File

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

View File

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

View File

@ -1,36 +1,34 @@
import Ember from 'ember';
import AuthenticationControllerMixin from 'simple-auth/mixins/authentication-controller-mixin';
import ModalControllerMixin from '../mixins/modal-controller';
export default Ember.Controller.extend(ModalControllerMixin, AuthenticationControllerMixin, {
import ModalController from 'flarum/mixins/modal-controller';
export default Ember.Controller.extend(ModalController, AuthenticationControllerMixin, {
authenticator: 'authenticator:flarum',
loading: false,
actions: {
authenticate: function() {
var data = this.getProperties('identification', 'password');
var controller = this;
this.set('error', null);
this.set('loading', true);
return this._super(data).then(function() {
controller.send("sessionChanged");
controller.send("closeModal");
}, function(errors) {
switch(errors[0].code) {
case 'invalidLogin':
controller.set('error', 'Your login details are incorrect.');
break;
var data = this.getProperties('identification', 'password');
var controller = this;
this.set('error', null);
this.set('loading', true);
return this._super(data).then(function() {
controller.send("sessionChanged");
controller.send("closeModal");
}, function(errors) {
switch(errors[0].code) {
case 'invalidLogin':
controller.set('error', 'Your login details are incorrect.');
break;
default:
controller.set('error', 'Something went wrong. (Error code: '+errors[0].code+')');
}
controller.trigger('refocus');
}).finally(function() {
controller.set('loading', false);
});
}
}
default:
controller.set('error', 'Something went wrong. (Error code: '+errors[0].code+')');
}
controller.trigger('refocus');
}).finally(function() {
controller.set('loading', false);
});
}
}
});

View File

@ -1,33 +1,29 @@
import Ember from 'ember';
import AuthenticationControllerMixin from 'simple-auth/mixins/authentication-controller-mixin';
import ModalControllerMixin from '../mixins/modal-controller';
import ModalController from 'flarum/mixins/modal-controller';
export default Ember.Controller.extend(ModalControllerMixin, AuthenticationControllerMixin, {
authenticator: 'authenticator:flarum',
export default Ember.Controller.extend(ModalController, {
actions: {
submit: function() {
var data = this.getProperties('username', 'email', 'password');
var controller = this;
this.set('error', null);
this.set('loading', true);
actions: {
submit: function() {
var data = this.getProperties('username', 'email', 'password');
var controller = this;
this.set('error', null);
this.set('loading', true);
var user = this.store.createRecord('user', data);
var user = this.store.createRecord('user', data);
return user.save().then(function() {
controller.get('session').authenticate('authenticator:flarum', {
identification: data.email,
password: data.password
}).then(function() {
controller.send('closeModal');
controller.send('sessionChanged');
controller.set('loading', false);
});
}, function(reason) {
controller.set('loading', false);
});
}
}
return user.save().then(function() {
controller.get('session').authenticate('authenticator:flarum', {
identification: data.email,
password: data.password
}).then(function() {
controller.send('closeModal');
controller.send('sessionChanged');
controller.set('loading', false);
});
}, function(reason) {
controller.set('loading', false);
});
}
}
});

View File

@ -1,6 +1,6 @@
import Ember from 'ember';
export default Ember.Handlebars.makeBoundHelper(function(number) {
return new Ember.Handlebars.SafeString(''+number);
return new Ember.Handlebars.SafeString(''+number);
});

View File

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

View File

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

View File

@ -1,18 +1,17 @@
import Ember from 'ember';
export default Ember.Handlebars.makeBoundHelper(function(text, phrase) {
if (phrase) {
var words = phrase.split(' ');
var replacement = function(matched) {
return '<span class="highlight-keyword">'+matched+'</span>';
};
words.forEach(function(word) {
text = text.replace(
new RegExp("\\b"+word+"\\b", 'gi'),
replacement
);
});
}
return new Ember.Handlebars.SafeString(text);
if (phrase) {
var words = phrase.split(' ');
var replacement = function(matched) {
return '<span class="highlight-keyword">'+matched+'</span>';
};
words.forEach(function(word) {
text = text.replace(
new RegExp("\\b"+word+"\\b", 'gi'),
replacement
);
});
}
return new Ember.Handlebars.SafeString(text);
});

View File

@ -3,12 +3,11 @@ import Ember from 'ember';
import humanTime from '../utils/human-time';
export default Ember.Handlebars.makeBoundHelper(function(time) {
var m = moment(time);
var datetime = m.format();
var full = m.format('LLLL');
var m = moment(time);
var datetime = m.format();
var full = m.format('LLLL');
var ago = humanTime(m);
var ago = humanTime(m);
return new Ember.Handlebars.SafeString('<time pubdate datetime="'+datetime+'" title="'+full+'" data-humantime>'+ago+'</time>');
return new Ember.Handlebars.SafeString('<time pubdate datetime="'+datetime+'" title="'+full+'" data-humantime>'+ago+'</time>');
});

View File

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

View File

@ -1,4 +1,4 @@
import FlarumAuthorizer from '../authorizers/flarum';
import FlarumAuthorizer from 'flarum/authorizers/flarum';
export default {
name: 'authentication',
@ -6,4 +6,4 @@ export default {
initialize: function(container) {
container.register('authorizer:flarum', FlarumAuthorizer);
}
};
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import Ember from 'ember';
export default Ember.Mixin.create(Ember.Evented, {
actions: {
focus: function() {
this.trigger('focus');
}
}
actions: {
focus: function() {
this.trigger('focus');
}
}
});

View File

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

View File

@ -1,59 +1,57 @@
import Ember from 'ember';
// This mixin defines a "paneable" controller - this is, one that has a
// portion of its interface that can be turned into a pane which slides out
// from the side of the screen. This is useful, for instance, when you have
// nested routes (index > discussion) and want to have the parent
// route's interface transform into a side pane when entering the child route.
/**
This mixin defines a "paneable" controller - this is, one that has a portion
of its interface that can be turned into a pane which slides out from the
side of the screen. This is useful, for instance, when you have nested
routes (index > discussion) and want to have the parent route's interface
transform into a side pane when entering the child route.
*/
export default Ember.Mixin.create({
needs: ['application'],
needs: ['application'],
// Whether or not the "paneable" interface element is paned.
paned: false,
// Whether or not the "paneable" interface element is paned.
paned: false,
// Whether or not the pane should be visible on screen.
paneShowing: false,
paneHideTimeout: null,
// Whether or not the pane should be visible on screen.
paneShowing: false,
paneHideTimeout: null,
// Whether or not the pane is always visible on screen, even when the
// mouse is taken away.
panePinned: localStorage.getItem('panePinned'),
// Whether or not the pane is always visible on screen, even when the
// mouse is taken away.
panePinned: localStorage.getItem('panePinned'),
// Disable the paneable behaviour completely, regardless of if it is
// paned, showing, or pinned.
paneDisabled: false,
// Disable the paneable behaviour completely, regardless of if it is
// paned, showing, or pinned.
paneDisabled: false,
paneIsShowing: function() {
return this.get('paned') && this.get('paneShowing') && !this.get('paneDisabled');
}.property('paned', 'paneShowing', 'paneDisabled'),
paneEnabled: Ember.computed.not('paneDisabled'),
paneIsShowing: Ember.computed.and('paned', 'paneShowing', 'paneEnabled'),
paneIsPinned: Ember.computed.and('paned', 'panePinned', 'paneEnabled'),
paneIsPinned: function() {
return this.get('paned') && this.get('panePinned') && !this.get('paneDisabled');
}.property('paned', 'panePinned', 'paneDisabled'),
// Tell the application controller when we pin/unpin the pane so that
// other parts of the interface can respond appropriately.
paneIsPinnedChanged: Ember.observer('paneIsPinned', function() {
this.set('controllers.application.panePinned', this.get('paneIsPinned'));
}),
// Tell the application controller when we pin/unpin the pane so that
// other parts of the interface can respond appropriately.
paneIsPinnedChanged: function() {
this.set('controllers.application.panePinned', this.get('paneIsPinned'));
}.observes('paneIsPinned'),
actions: {
showPane: function() {
if (this.get('paned')) {
clearTimeout(this.get('paneHideTimeout'));
this.set('paneShowing', true);
}
},
actions: {
showPane: function() {
if (this.get('paned')) {
clearTimeout(this.get('paneHideTimeout'));
this.set('paneShowing', true);
}
},
hidePane: function(delay) {
var controller = this;
controller.set('paneHideTimeout', setTimeout(function() {
controller.set('paneShowing', false);
}, delay || 250));
},
hidePane: function(delay) {
var controller = this;
controller.set('paneHideTimeout', setTimeout(function() {
controller.set('paneShowing', false);
}, delay || 250));
},
togglePinned: function() {
localStorage.setItem('panePinned', this.toggleProperty('panePinned') || '');
}
}
togglePinned: function() {
localStorage.setItem('panePinned', this.toggleProperty('panePinned') || '');
}
}
});

View File

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

View File

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

View File

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

View File

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

View File

@ -1,202 +1,203 @@
import Ember from 'ember';
// The post stream is an object which represents the posts in a discussion as
// they are displayed on the discussion page, from top to bottom. ...
/**
The post stream is an object which represents the posts in a discussion as
they are displayed on the discussion page, from top to bottom. ...
*/
export default Ember.ArrayProxy.extend(Ember.Evented, {
// An array of all of the post IDs, in chronological order, in the discussion.
ids: null,
// An array of all of the post IDs, in chronological order, in the discussion.
ids: null,
content: null,
content: null,
store: null,
discussion: null,
store: null,
discussion: null,
postLoadCount: 20,
postLoadCount: 20,
count: Ember.computed.alias('ids.length'),
count: Ember.computed.alias('ids.length'),
loadedCount: function() {
return this.get('content').filterBy('content').length;
}.property('content.@each'),
loadedCount: Ember.computed('content.@each', function() {
return this.get('content').filterBy('content').length;
}),
firstLoaded: function() {
var first = this.objectAt(0);
return first && first.content;
}.property('content.@each'),
firstLoaded: Ember.computed('content.@each', function() {
var first = this.objectAt(0);
return first && first.content;
}),
lastLoaded: function() {
var last = this.objectAt(this.get('length') - 1);
return last && last.content;
}.property('content.@each'),
lastLoaded: Ember.computed('content.@each', function() {
var last = this.objectAt(this.get('length') - 1);
return last && last.content;
}),
init: function() {
this._super();
this.set('ids', Ember.A());
this.clear();
},
init: function() {
this._super();
this.set('ids', Ember.A());
this.clear();
},
setup: function(ids) {
// Set our ids to the array provided and reset the content of the
// stream to a big gap that covers the amount of posts we now have.
this.set('ids', ids);
this.clear();
},
setup: function(ids) {
// Set our ids to the array provided and reset the content of the
// stream to a big gap that covers the amount of posts we now have.
this.set('ids', ids);
this.clear();
},
// Clear the contents of the post stream, resetting it to one big gap.
clear: function() {
var content = Ember.A();
content.clear().pushObject(this.makeItem(0, this.get('count') - 1).set('loading', true));
this.set('content', content);
},
// Clear the contents of the post stream, resetting it to one big gap.
clear: function() {
var content = Ember.A();
content.clear().pushObject(this.makeItem(0, this.get('count') - 1).set('loading', true));
this.set('content', content);
},
loadRange: function(start, end, backwards) {
var limit = this.get('postLoadCount');
loadRange: function(start, end, backwards) {
var limit = this.get('postLoadCount');
// Find the appropriate gap objects in the post stream. When we find
// one, we will turn on its loading flag.
this.get('content').forEach(function(item) {
if (! item.content && ((item.indexStart >= start && item.indexStart <= end) || (item.indexEnd >= start && item.indexEnd <= end))) {
item.set('loading', true);
item.set('direction', backwards ? 'up' : 'down');
}
});
// Find the appropriate gap objects in the post stream. When we find
// one, we will turn on its loading flag.
this.get('content').forEach(function(item) {
if (!item.content && ((item.indexStart >= start && item.indexStart <= end) || (item.indexEnd >= start && item.indexEnd <= end))) {
item.set('loading', true);
item.set('direction', backwards ? 'up' : 'down');
}
});
// Get a list of post numbers that we'll want to retrieve. If there are
// more post IDs than the number of posts we want to load, then take a
// slice of the array in the appropriate direction.
var ids = this.get('ids').slice(start, end + 1);
ids = backwards ? ids.slice(-limit) : ids.slice(0, limit);
// Get a list of post numbers that we'll want to retrieve. If there are
// more post IDs than the number of posts we want to load, then take a
// slice of the array in the appropriate direction.
var ids = this.get('ids').slice(start, end + 1);
ids = backwards ? ids.slice(-limit) : ids.slice(0, limit);
return this.loadPosts(ids);
},
return this.loadPosts(ids);
},
loadPosts: function(ids) {
if (! ids.length) {
return Ember.RSVP.resolve();
}
var stream = this;
return this.store.find('post', {ids: ids}).then(function(posts) {
stream.addPosts(posts);
});
},
loadNearNumber: function(number) {
// Find the item in the post stream which is nearest to this number. If
// it turns out the be the actual post we're trying to load, then we can
// return a resolved promise (i.e. we don't need to make an API
// request.) Or, if it's a gap, we'll switch on its loading flag.
var item = this.findNearestToNumber(number);
if (item) {
if (item.get('content.number') == number) {
return Ember.RSVP.resolve([item.get('content')]);
} else if (! item.content) {
item.set('direction', 'down').set('loading', true);
}
}
var stream = this;
return this.store.find('post', {
discussions: this.get('discussion.id'),
near: number,
count: this.get('postLoadCount')
}).then(function(posts) {
stream.addPosts(posts);
});
},
loadNearIndex: function(index, backwards) {
// Find the item in the post stream which is nearest to this index. If
// it turns out the be the actual post we're trying to load, then we can
// return a resolved promise (i.e. we don't need to make an API
// request.) Or, if it's a gap, we'll switch on its loading flag.
var item = this.findNearestToIndex(index);
if (item) {
if (item.content) {
return Ember.RSVP.resolve([item.get('content')]);
}
return this.loadRange(Math.max(item.indexStart, index - this.get('postLoadCount') / 2), item.indexEnd, backwards);
}
return Ember.RSVP.reject();
},
addPosts: function(posts) {
this.trigger('postsLoaded', posts);
var stream = this;
var content = this.get('content');
content.beginPropertyChanges();
posts.forEach(function(post) {
stream.addPost(post);
});
content.endPropertyChanges();
this.trigger('postsAdded');
},
addPost: function(post) {
var index = this.get('ids').indexOf(post.get('id'));
var content = this.get('content');
var makeItem = this.makeItem;
// Here we loop through each item in the post stream, and find the gap
// in which this post should be situated. When we find it, we can replace
// it with the post, and new gaps either side if appropriate.
content.some(function(item, i) {
if (item.indexStart <= index && item.indexEnd >= index) {
var newItems = [];
if (item.indexStart < index) {
newItems.push(makeItem(item.indexStart, index - 1));
}
newItems.push(makeItem(index, index, post));
if (item.indexEnd > index) {
newItems.push(makeItem(index + 1, item.indexEnd));
}
content.replace(i, 1, newItems);
return true;
}
});
},
addPostToEnd: function(post) {
this.get('ids').pushObject(post.get('id'));
var index = this.get('count') - 1;
this.get('content').pushObject(this.makeItem(index, index, post));
},
makeItem: function(indexStart, indexEnd, post) {
var item = Ember.Object.create({
indexStart: indexStart,
indexEnd: indexEnd
});
if (post) {
item.setProperties({
content: post,
component: 'discussions/post-'+post.get('type')
});
}
return item;
},
findNearestTo: function(index, property) {
var nearestItem;
this.get('content').some(function(item) {
if (item.get(property) > index) {
return true;
}
nearestItem = item;
});
return nearestItem;
},
findNearestToNumber: function(number) {
return this.findNearestTo(number, 'content.number');
},
findNearestToIndex: function(index) {
return this.findNearestTo(index, 'indexStart');
loadPosts: function(ids) {
if (!ids.length) {
return Ember.RSVP.resolve();
}
var stream = this;
return this.store.find('post', {ids: ids}).then(function(posts) {
stream.addPosts(posts);
});
},
loadNearNumber: function(number) {
// Find the item in the post stream which is nearest to this number. If
// it turns out the be the actual post we're trying to load, then we can
// return a resolved promise (i.e. we don't need to make an API
// request.) Or, if it's a gap, we'll switch on its loading flag.
var item = this.findNearestToNumber(number);
if (item) {
if (item.get('content.number') == number) {
return Ember.RSVP.resolve([item.get('content')]);
} else if (! item.content) {
item.set('direction', 'down').set('loading', true);
}
}
var stream = this;
return this.store.find('post', {
discussions: this.get('discussion.id'),
near: number,
count: this.get('postLoadCount')
}).then(function(posts) {
stream.addPosts(posts);
});
},
loadNearIndex: function(index, backwards) {
// Find the item in the post stream which is nearest to this index. If
// it turns out the be the actual post we're trying to load, then we can
// return a resolved promise (i.e. we don't need to make an API
// request.) Or, if it's a gap, we'll switch on its loading flag.
var item = this.findNearestToIndex(index);
if (item) {
if (item.content) {
return Ember.RSVP.resolve([item.get('content')]);
}
return this.loadRange(Math.max(item.indexStart, index - this.get('postLoadCount') / 2), item.indexEnd, backwards);
}
return Ember.RSVP.reject();
},
addPosts: function(posts) {
this.trigger('postsLoaded', posts);
var stream = this;
var content = this.get('content');
content.beginPropertyChanges();
posts.forEach(function(post) {
stream.addPost(post);
});
content.endPropertyChanges();
this.trigger('postsAdded');
},
addPost: function(post) {
var index = this.get('ids').indexOf(post.get('id'));
var content = this.get('content');
var makeItem = this.makeItem;
// Here we loop through each item in the post stream, and find the gap
// in which this post should be situated. When we find it, we can replace
// it with the post, and new gaps either side if appropriate.
content.some(function(item, i) {
if (item.indexStart <= index && item.indexEnd >= index) {
var newItems = [];
if (item.indexStart < index) {
newItems.push(makeItem(item.indexStart, index - 1));
}
newItems.push(makeItem(index, index, post));
if (item.indexEnd > index) {
newItems.push(makeItem(index + 1, item.indexEnd));
}
content.replace(i, 1, newItems);
return true;
}
});
},
addPostToEnd: function(post) {
this.get('ids').pushObject(post.get('id'));
var index = this.get('count') - 1;
this.get('content').pushObject(this.makeItem(index, index, post));
},
makeItem: function(indexStart, indexEnd, post) {
var item = Ember.Object.create({
indexStart: indexStart,
indexEnd: indexEnd
});
if (post) {
item.setProperties({
content: post,
component: 'discussion/post-'+post.get('type')
});
}
return item;
},
findNearestTo: function(index, property) {
var nearestItem;
this.get('content').some(function(item) {
if (item.get(property) > index) {
return true;
}
nearestItem = item;
});
return nearestItem;
},
findNearestToNumber: function(number) {
return this.findNearestTo(number, 'content.number');
},
findNearestToIndex: function(index) {
return this.findNearestTo(index, 'indexStart');
}
});

View File

@ -2,25 +2,23 @@ import Ember from 'ember';
import DS from 'ember-data';
export default DS.Model.extend({
discussion: DS.belongsTo('discussion', {inverse: 'loadedPosts'}),
number: DS.attr('number'),
discussion: DS.belongsTo('discussion', {inverse: 'loadedPosts'}),
number: DS.attr('number'),
time: DS.attr('date'),
user: DS.belongsTo('user'),
type: DS.attr('string'),
content: DS.attr('string'),
contentHtml: DS.attr('string'),
time: DS.attr('date'),
user: DS.belongsTo('user'),
type: DS.attr('string'),
content: DS.attr('string'),
contentHtml: DS.attr('string'),
editTime: DS.attr('date'),
editUser: DS.belongsTo('user'),
isEdited: Ember.computed.notEmpty('editTime'),
editTime: DS.attr('date'),
editUser: DS.belongsTo('user'),
isEdited: Ember.computed.notEmpty('editTime'),
deleteTime: DS.attr('date'),
deleteUser: DS.belongsTo('user'),
isDeleted: Ember.computed.notEmpty('deleteTime'),
canEdit: DS.attr('boolean'),
canDelete: DS.attr('boolean')
isHidden: DS.attr('boolean'),
deleteTime: DS.attr('date'),
deleteUser: DS.belongsTo('user'),
canEdit: DS.attr('boolean'),
canDelete: DS.attr('boolean')
});

View File

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

View File

@ -7,17 +7,17 @@ var Router = Ember.Router.extend({
Router.map(function() {
this.resource('index', {path: '/'}, function() {
this.resource('discussion', {path: '/:id/:slug'}, function() {
this.route('near', {path: '/:near'});
});
this.resource('index', {path: '/'}, function() {
this.resource('discussion', {path: '/:id/:slug'}, function() {
this.route('near', {path: '/:near'});
});
});
this.resource('user', {path: '/u/:username'}, function() {
this.route('activity');
this.route('posts');
this.resource('preferences');
});
this.resource('user', {path: '/u/:username'}, function() {
this.route('activity');
this.route('posts');
this.resource('preferences');
});
});

View File

@ -2,39 +2,38 @@ import Ember from 'ember';
import ApplicationRouteMixin from 'simple-auth/mixins/application-route-mixin';
export default Ember.Route.extend(ApplicationRouteMixin, {
actions: {
login: function() {
this.controllerFor('login').set('error', null);
this.send('showModal', 'login');
},
actions: {
login: function() {
this.controllerFor('login').set('error', null);
this.send('showModal', 'login');
},
signup: function() {
this.controllerFor('signup').set('error', null);
this.send('showModal', 'signup');
},
signup: function() {
this.controllerFor('signup').set('error', null);
this.send('showModal', 'signup');
},
showModal: function(name) {
this.render(name, {
into: 'application',
outlet: 'modal'
});
this.controllerFor('application').set('modalController', this.controllerFor(name));
},
showModal: function(name) {
this.render(name, {
into: 'application',
outlet: 'modal'
});
this.controllerFor('application').set('modalController', this.controllerFor(name));
},
closeModal: function() {
this.controllerFor('application').set('modalController', null);
},
closeModal: function() {
this.controllerFor('application').set('modalController', null);
},
destroyModal: function() {
this.disconnectOutlet({
outlet: 'modal',
parentView: 'application'
});
},
destroyModal: function() {
this.disconnectOutlet({
outlet: 'modal',
parentView: 'application'
});
},
sessionChanged: function() {
this.refresh();
}
}
sessionChanged: function() {
this.refresh();
}
}
});

View File

@ -1,121 +1,121 @@
import Ember from 'ember';
import PostStream from '../models/post-stream';
import PostStream from 'flarum/models/post-stream';
export default Ember.Route.extend({
queryParams: {
start: {replace: true}
},
queryParams: {
start: {replace: true}
},
model: function(params) {
return this.store.findQueryOne('discussion', params.id, {
include: 'posts',
near: params.start
});
},
model: function(params) {
return this.store.findQueryOne('discussion', params.id, {
include: 'posts',
near: params.start
});
},
resetController: function(controller) {
// Whenever we exit the discussion view, or transition to a different
// discussion, we want to reset the query params so that they don't stick.
controller.set('start', '1');
controller.set('searchQuery', '');
controller.set('loaded', false);
controller.set('stream', null);
},
resetController: function(controller) {
// Whenever we exit the discussion view, or transition to a different
// discussion, we want to reset the query params so that they don't stick.
controller.set('start', '1');
controller.set('searchQuery', '');
controller.set('loaded', false);
controller.set('stream', null);
},
setupController: function(controller, discussion) {
controller.set('model', discussion);
this.controllerFor('index/index').set('lastDiscussion', discussion);
setupController: function(controller, discussion) {
controller.set('model', discussion);
this.controllerFor('index/index').set('lastDiscussion', discussion);
// Set up the post stream object. It needs to know about the discussion
// it's representing the posts for, and we also need to inject the Ember
// Data store.
var stream = PostStream.create({
discussion: discussion,
store: this.store
// Set up the post stream object. It needs to know about the discussion
// it's representing the posts for, and we also need to inject the Ember
// Data store.
var stream = PostStream.create({
discussion: discussion,
store: this.store
});
controller.set('stream', stream);
// Next, we need to make sure we have a list of the discussion's post
// IDs. If we don't already have this information, we'll need to
// reload the discussion model.
var promise = discussion.get('posts') ? Ember.RSVP.resolve(discussion) : this.model({
id: discussion.get('id'),
start: controller.get('start')
});
// When we know we have the post IDs, we can set up the post stream with
// them. Then we will tell the view that we have finished loading so that
// it can scroll down to the appropriate post.
promise.then(function(discussion) {
var postIds = discussion.get('postIds');
stream.setup(postIds);
// A page of posts will have been returned as linked data by this
// request, and automatically loaded into the store. In turn, we
// want to load them into the stream. However, since there is no
// way to access them directly, we need to retrieve them based on
// the requested start number. This code finds the post for that
// number, gets its index, slices an array of surrounding post
// IDs, and finally adds these posts to the stream.
var posts = discussion.get('loadedPosts');
var startPost = posts.findBy('number', parseInt(controller.get('start')));
if (startPost) {
var startIndex = postIds.indexOf(startPost.get('id'));
var count = stream.get('postLoadCount');
startIndex = Math.max(0, startIndex - count / 2);
var loadIds = postIds.slice(startIndex, startIndex + count);
stream.addPosts(posts.filter(function(item) {
return loadIds.indexOf(item.get('id')) !== -1;
}));
}
// Clear the list of post IDs for this discussion (without
// dirtying the record), so that next time we load the discussion,
// the discussion details and post IDs will be refreshed.
controller.store.push('discussion', {id: discussion.get('id'), posts: ''});
// It's possible for this promise to have resolved but the user
// has clicked away to a different discussion. So only if we're
// still on the original one, we will tell the view that we're
// done loading.
if (controller.get('model') === discussion) {
controller.set('loaded', true);
Ember.run.scheduleOnce('afterRender', function() {
controller.trigger('loaded');
});
controller.set('stream', stream);
}
});
},
// Next, we need to make sure we have a list of the discussion's post
// IDs. If we don't already have this information, we'll need to
// reload the discussion model.
var promise = discussion.get('posts') ? Ember.RSVP.resolve(discussion) : this.model({
id: discussion.get('id'),
start: controller.get('start')
});
actions: {
queryParamsDidChange: function(params) {
// If the ?start param has changed, we want to tell the view to
// tell the streamContent component to jump to this start point.
// We postpone running this code until the next run loop because
// when transitioning directly from one discussion to another,
// queryParamsDidChange is fired before the controller is reset.
// Thus, controller.loaded would still be true and the
// startWasChanged event would be triggered inappropriately.
var newStart = parseInt(params.start) || 1;
var controller = this.controllerFor('discussion');
var oldStart = parseInt(controller.get('start'));
Ember.run.next(function() {
if (controller.get('loaded') && newStart !== oldStart) {
controller.trigger('startWasChanged', newStart);
}
});
},
// When we know we have the post IDs, we can set up the post stream with
// them. Then we will tell the view that we have finished loading so that
// it can scroll down to the appropriate post.
promise.then(function(discussion) {
var postIds = discussion.get('postIds');
stream.setup(postIds);
// A page of posts will have been returned as linked data by this
// request, and automatically loaded into the store. In turn, we
// want to load them into the stream. However, since there is no
// way to access them directly, we need to retrieve them based on
// the requested start number. This code finds the post for that
// number, gets its index, slices an array of surrounding post
// IDs, and finally adds these posts to the stream.
var posts = discussion.get('loadedPosts');
var startPost = posts.findBy('number', parseInt(controller.get('start')));
if (startPost) {
var startIndex = postIds.indexOf(startPost.get('id'));
var count = stream.get('postLoadCount');
startIndex = Math.max(0, startIndex - count / 2);
var loadIds = postIds.slice(startIndex, startIndex + count);
stream.addPosts(posts.filter(function(item) {
return loadIds.indexOf(item.get('id')) !== -1;
}));
}
// Clear the list of post IDs for this discussion (without
// dirtying the record), so that next time we load the discussion,
// the discussion details and post IDs will be refreshed.
controller.store.push('discussion', {id: discussion.get('id'), posts: ''});
// It's possible for this promise to have resolved but the user
// has clicked away to a different discussion. So only if we're
// still on the original one, we will tell the view that we're
// done loading.
if (controller.get('model') === discussion) {
controller.set('loaded', true);
Ember.run.scheduleOnce('afterRender', function() {
controller.trigger('loaded');
});
}
});
},
actions: {
queryParamsDidChange: function(params) {
// If the ?start param has changed, we want to tell the view to
// tell the streamContent component to jump to this start point.
// We postpone running this code until the next run loop because
// when transitioning directly from one discussion to another,
// queryParamsDidChange is fired before the controller is reset.
// Thus, controller.loaded would still be true and the
// startWasChanged event would be triggered inappropriately.
var newStart = parseInt(params.start) || 1;
var controller = this.controllerFor('discussion');
var oldStart = parseInt(controller.get('start'));
Ember.run.next(function() {
if (controller.get('loaded') && newStart !== oldStart) {
controller.trigger('startWasChanged', newStart);
}
});
},
didTransition: function() {
// When we transition into a new discussion, we want to hide the
// discussions list pane. This means that when the user selects a
// different discussion within the pane, the pane will slide away.
// We also minimize the composer.
this.controllerFor('index')
.set('paned', true)
.set('paneShowing', false);
this.controllerFor('composer').send('minimize');
},
}
didTransition: function() {
// When we transition into a new discussion, we want to hide the
// discussions list pane. This means that when the user selects a
// different discussion within the pane, the pane will slide away.
// We also minimize the composer.
this.controllerFor('index')
.set('paned', true)
.set('paneShowing', false);
this.controllerFor('composer').send('minimize');
}
}
});

View File

@ -1,47 +1,51 @@
import Ember from 'ember';
import AddCssClassToBodyMixin from '../../mixins/add-css-class-to-body';
import AddCssClassToBody from 'flarum/mixins/add-css-class-to-body';
export default Ember.Route.extend(AddCssClassToBodyMixin, {
cachedModel: null,
export default Ember.Route.extend(AddCssClassToBody, {
cachedModel: null,
model: function() {
if (!this.get('cachedModel')) {
this.set('cachedModel', Ember.ArrayProxy.create());
}
return Ember.RSVP.resolve(this.get('cachedModel'));
},
model: function() {
if (!this.get('cachedModel')) {
this.set('cachedModel', Ember.ArrayProxy.create());
}
return Ember.RSVP.resolve(this.get('cachedModel'));
},
setupController: function(controller, model) {
controller.set('model', model);
setupController: function(controller, model) {
controller.set('model', model);
if (!model.get('length')) {
controller.set('resultsLoading', true);
controller.getResults().then(function(results) {
controller
.set('resultsLoading', false)
.set('meta', results.get('meta'))
.set('model.content', results);
});
}
},
if (!model.get('length')) {
controller.set('resultsLoading', true);
controller.getResults().then(function(results) {
controller
.set('resultsLoading', false)
.set('meta', results.get('meta'))
.set('model.content', results);
});
}
},
deactivate: function() {
this._super();
this.controllerFor('application').set('backButtonTarget', this.controllerFor('index'));
},
actions: {
refresh: function() {
this.set('cachedModel', null);
this.refresh();
},
deactivate: function() {
this._super();
this.controllerFor('application').set('backButtonTarget', this.controllerFor('index'));
},
didTransition: function() {
// @todo only if it's not a new discussion
this.controllerFor('composer').send('minimize');
this.controllerFor('index').set('paned', false);
this.controllerFor('index').set('paneShowing', false);
}
}
actions: {
refresh: function() {
this.set('cachedModel', null);
this.refresh();
},
didTransition: function() {
var application = this.controllerFor('application');
if (application.get('backButtonTarget') === this.controllerFor('index')) {
application.set('backButtonTarget', null);
}
this.controllerFor('composer').send('minimize');
this.controllerFor('index').set('paned', false);
this.controllerFor('index').set('paneShowing', false);
}
}
});

View File

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

View File

@ -8,30 +8,30 @@
.define-body-variables(@fl-dark-body);
.define-body-variables(false) {
@fl-body-bg: #fff;
@fl-body-color: #444;
@fl-body-muted-color: hsv(hue(@fl-secondary-color), max(0, saturation(@fl-secondary-color) - 20%), 65%); // todo
@fl-body-muted-more-color: #bbb;
@fl-body-heading-color: @fl-body-color;
@fl-body-bg: #fff;
@fl-body-color: #444;
@fl-body-muted-color: hsv(hue(@fl-secondary-color), max(0, saturation(@fl-secondary-color) - 20%), 65%); // todo
@fl-body-muted-more-color: #bbb;
@fl-body-heading-color: @fl-body-color;
@fl-body-control-bg: @fl-secondary-color;
@fl-body-control-color: @fl-body-muted-color;
@fl-body-control-bg: @fl-secondary-color;
@fl-body-control-color: @fl-body-muted-color;
@fl-body-control-primary-bg: @fl-primary-color;
@fl-body-control-primary-color: #fff;
@fl-body-control-primary-bg: @fl-primary-color;
@fl-body-control-primary-color: #fff;
}
.define-body-variables(true) {
@fl-body-bg: #333;
@fl-body-color: #ccc;
@fl-body-muted-color: hsv(hue(@fl-secondary-color), max(0, saturation(@fl-secondary-color) - 20%), 65%); // todo
@fl-body-muted-more-color: #bbb;
@fl-body-heading-color: @fl-body-color;
@fl-body-bg: #333;
@fl-body-color: #ccc;
@fl-body-muted-color: hsv(hue(@fl-secondary-color), max(0, saturation(@fl-secondary-color) - 20%), 65%); // todo
@fl-body-muted-more-color: #bbb;
@fl-body-heading-color: @fl-body-color;
@fl-body-control-bg: @fl-secondary-color;
@fl-body-control-color: @fl-body-muted-color;
@fl-body-control-bg: @fl-secondary-color;
@fl-body-control-color: @fl-body-muted-color;
@fl-body-control-primary-bg: @fl-primary-color;
@fl-body-control-primary-color: #fff;
@fl-body-control-primary-bg: @fl-primary-color;
@fl-body-control-primary-color: #fff;
}
// ---------------------------------
@ -39,22 +39,22 @@
.define-hdr-variables(@fl-dark-hdr);
.define-hdr-variables(false) {
@fl-hdr-bg: @fl-body-bg;
@fl-hdr-color: @fl-primary-color;
@fl-hdr-muted-color: @fl-body-muted-color;
@fl-hdr-control-bg: @fl-body-control-bg;
@fl-hdr-control-color: @fl-body-control-color;
@fl-hdr-bg: @fl-body-bg;
@fl-hdr-color: @fl-primary-color;
@fl-hdr-muted-color: @fl-body-muted-color;
@fl-hdr-control-bg: @fl-body-control-bg;
@fl-hdr-control-color: @fl-body-control-color;
@fl-body-hero-bg: @fl-primary-color;
@fl-body-hero-color: #fff;
@fl-body-hero-bg: @fl-primary-color;
@fl-body-hero-color: #fff;
}
.define-hdr-variables(true) {
@fl-hdr-bg: @fl-primary-color;
@fl-hdr-color: #fff;
@fl-hdr-muted-color: fade(#fff, 50%);
@fl-hdr-control-bg: fade(#000, 10%);
@fl-hdr-control-color: #fff;
@fl-hdr-bg: @fl-primary-color;
@fl-hdr-color: #fff;
@fl-hdr-muted-color: fade(#fff, 50%);
@fl-hdr-control-bg: fade(#000, 10%);
@fl-hdr-control-color: #fff;
@fl-body-hero-bg: @fl-secondary-color;
@fl-body-hero-color: @fl-body-muted-color;
@fl-body-hero-bg: @fl-secondary-color;
@fl-body-hero-color: @fl-body-muted-color;
}

View File

@ -1,58 +1,58 @@
.alerts {
position: fixed;
bottom: 20px;
left: 20px;
z-index: @zindex-alerts;
position: fixed;
bottom: 20px;
left: 20px;
z-index: @zindex-alerts;
& .alert {
display: inline-block;
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.3));
margin-top: 20px;
}
& .alert {
display: inline-block;
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.3));
margin-top: 20px;
}
}
.alert {
padding: 12px 16px;
border-radius: @border-radius-base;
background: #FFF2AE;
&, & a, & a:hover {
color: #AD6C00;
}
padding: 12px 16px;
border-radius: @border-radius-base;
background: #FFF2AE;
&, & a, & a:hover {
color: #AD6C00;
}
}
.alert-warning {
background: #D83E3E;
&, & a, & a:hover {
color: #fff;
}
background: #D83E3E;
&, & a, & a:hover {
color: #fff;
}
}
.alert-controls {
list-style-type: none;
padding: 0;
margin: 0 -8px 0 8px;
display: inline-block;
list-style-type: none;
padding: 0;
margin: 0 -8px 0 8px;
display: inline-block;
& li {
display: inline-block;
margin: 0 5px;
}
& a {
text-transform: uppercase;
font-size: 12px;
font-weight: bold;
}
& .btn {
margin: -10px;
}
& li {
display: inline-block;
margin: 0 5px;
}
& a {
text-transform: uppercase;
font-size: 12px;
font-weight: bold;
}
& .btn {
margin: -10px;
}
}
.form-group { // probably move this elsewhere
position: relative;
position: relative;
}
.form-alert {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 12px;
& .alert {
display: inline-block;
}
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 12px;
& .alert {
display: inline-block;
}
}

View File

@ -2,237 +2,242 @@
// Buttons
.btn {
border: 0;
.box-shadow(none);
line-height: 20px;
border: 0;
.box-shadow(none);
line-height: 20px;
& .fa {
font-size: 14px;
}
& .fa {
font-size: 14px;
}
}
.btn-group .btn + .btn {
margin-left: 1px;
margin-left: 1px;
}
.btn-icon {
padding-left: 9px;
padding-right: 9px;
padding-left: 9px;
padding-right: 9px;
}
.btn-link {
color: @fl-body-muted-color;
color: @fl-body-muted-color;
&:hover,
&:hover,
&:focus {
text-decoration: none;
}
text-decoration: none;
}
}
.btn-primary {
font-weight: bold;
& .icon-glyph {
display: none;
}
font-weight: bold;
& .icon-glyph {
display: none;
}
}
.btn-user {
& .avatar {
margin: -2px 5px -2px -5px;
.avatar-size(24px);
}
& .avatar {
margin: -2px 5px -2px -5px;
.avatar-size(24px);
}
}
.btn-more {
padding: 1px 3px;
border-radius: 2px;
line-height: 1;
}
// Redefine Bootstrap's mixin to make some general changes
.button-variant(@color; @background; @border) {
&:hover,
&:focus,
&.focus,
&:active,
&.active,
.open > .dropdown-toggle& {
background-color: darken(@background, 5%);
}
&.active {
.box-shadow(none);
}
&:hover,
&:focus,
&.focus,
&:active,
&.active,
.open > .dropdown-toggle& {
background-color: darken(@background, 5%);
}
&.active {
.box-shadow(none);
}
}
// Little round icon buttons
.btn-icon.btn-sm {
border-radius: 12px;
height: 24px;
width: 24px;
text-align: center;
padding: 3px 0;
border-radius: 12px;
height: 24px;
width: 24px;
text-align: center;
padding: 3px 0;
& .label, & .icon-caret {
display: none;
}
& .fa-ellipsis-v {
font-size: 17px;
vertical-align: middle;
}
& .label, & .icon-caret {
display: none;
}
& .fa-ellipsis-v {
font-size: 17px;
vertical-align: middle;
}
}
// Buttons that blend into the background
.btn-naked {
background: transparent;
&:hover {
background: @fl-body-control-bg;
}
background: transparent;
&:hover {
background: @fl-body-control-bg;
}
}
.btn-rounded {
border-radius: 18px;
border-radius: 18px;
}
// ------------------------------------
// Form Controls
.form-group {
margin-bottom: 12px;
margin-bottom: 12px;
}
.form-control {
.box-shadow(none);
&:focus,
&.focus {
background-color: #fff;
color: @fl-body-color;
.box-shadow(none);
}
.box-shadow(none);
&:focus,
&.focus {
background-color: #fff;
color: @fl-body-color;
.box-shadow(none);
}
}
// Search inputs
// @todo Extract some of this into header-specific definitions
.search-input {
margin-right: 10px;
&:before {
.fa();
content: @fa-var-search;
float: left;
margin-right: -36px;
width: 36px;
font-size: 14px;
text-align: center;
color: @fl-body-muted-color;
position: relative;
padding: @padding-base-vertical - 1 0;
line-height: @line-height-base;
pointer-events: none;
}
margin-right: 10px;
&:before {
.fa();
content: @fa-var-search;
float: left;
margin-right: -36px;
width: 36px;
font-size: 14px;
text-align: center;
color: @fl-body-muted-color;
position: relative;
padding: @padding-base-vertical - 1 0;
line-height: @line-height-base;
pointer-events: none;
}
}
.search-input .form-control {
float: left;
width: 225px;
padding-left: 36px;
padding-right: 36px;
.transition(~"all 0.4s");
float: left;
width: 225px;
padding-left: 36px;
padding-right: 36px;
.transition(~"all 0.4s");
&:focus {
width: 400px;
}
&:focus {
width: 400px;
}
}
.search-input .clear {
float: left;
margin-left: -36px;
vertical-align: top;
.scale(0.001);
.transition(~"transform 0.15s");
float: left;
margin-left: -36px;
vertical-align: top;
.scale(0.001);
.transition(~"transform 0.15s");
}
.search-input.clearable .clear {
.scale(1);
.scale(1);
}
// Select inputs
.select-input {
display: inline-block;
vertical-align: middle;
display: inline-block;
vertical-align: middle;
}
.select-input select {
display: inline-block;
width: auto;
-webkit-appearance: none;
padding-right: @padding-base-horizontal + 16;
cursor: pointer;
display: inline-block;
width: auto;
-webkit-appearance: none;
padding-right: @padding-base-horizontal + 16;
cursor: pointer;
}
.select-input .fa {
margin-left: -@padding-base-horizontal - 16;
pointer-events: none;
color: @fl-body-muted-color;
margin-left: -@padding-base-horizontal - 16;
pointer-events: none;
color: @fl-body-muted-color;
}
// ------------------------------------
// Dropdown Menus
.dropdown-menu {
border: 0;
padding: 8px 0;
margin-top: 7px;
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
border: 0;
padding: 8px 0;
margin-top: 7px;
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
& > li > a {
padding: 8px 15px;
color: @fl-body-color;
&:hover, &:focus {
color: @fl-body-color;
background-color: @fl-secondary-color;
}
& .fa {
margin-right: 5px;
font-size: 14px;
}
}
& .divider {
margin: 10px 0;
background-color: darken(@fl-secondary-color, 2%);
}
& > li > a {
padding: 8px 15px;
color: @fl-body-color;
&:hover, &:focus {
color: @fl-body-color;
background-color: @fl-secondary-color;
}
& .fa {
margin-right: 5px;
font-size: 14px;
}
}
& .divider {
margin: 10px 0;
background-color: darken(@fl-secondary-color, 2%);
}
}
.dropdown-split.item-count-1 {
& .btn {
border-radius: @border-radius-base !important;
}
& .dropdown-toggle {
display: none;
}
& .btn {
border-radius: @border-radius-base !important;
}
& .dropdown-toggle {
display: none;
}
}
// ------------------------------------
// Tooltips
.tooltip-inner {
padding: 5px 10px;
padding: 5px 10px;
}
// ------------------------------------
// Loading Indicators
.loading-indicator {
position: relative;
color: @fl-primary-color;
position: relative;
color: @fl-primary-color;
}
.loading-indicator-block {
height: 100px;
height: 100px;
}
// ------------------------------------
// Avatars
.avatar-size(@size) {
width: @size;
height: @size;
border-radius: @size / 2;
font-size: @size / 2;
line-height: @size;
width: @size;
height: @size;
border-radius: @size / 2;
font-size: @size / 2;
line-height: @size;
}
.avatar {
display: inline-block;
color: #fff;
font-weight: 300;
text-align: center;
vertical-align: top;
.avatar-size(48px);
display: inline-block;
color: #fff;
font-weight: 300;
text-align: center;
vertical-align: top;
.avatar-size(48px);
& img {
display: inline-block;
width: 100%;
height: 100%;
border-radius: 100%;
vertical-align: top;
}
& img {
display: inline-block;
width: 100%;
height: 100%;
border-radius: 100%;
vertical-align: top;
}
}

View File

@ -2,163 +2,163 @@
// Composer
.composer-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: @zindex-composer;
pointer-events: none;
.transition(left 0.2s);
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: @zindex-composer;
pointer-events: none;
.transition(left 0.2s);
.with-pane & {
left: @index-pane-width;
}
.with-pane & {
left: @index-pane-width;
}
}
.composer {
pointer-events: auto;
margin-left: -20px;
margin-right: 180px;
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
border-radius: @border-radius-base @border-radius-base 0 0;
background: rgba(255, 255, 255, 0.9);
transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
position: relative;
height: 300px;
.transition(~"background 0.2s");
pointer-events: auto;
margin-left: -20px;
margin-right: 180px;
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
border-radius: @border-radius-base @border-radius-base 0 0;
background: rgba(255, 255, 255, 0.9);
transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
position: relative;
height: 300px;
.transition(~"background 0.2s");
.index-index & {
margin-left: 205px;
margin-right: -20px;
}
&.active, &.fullscreen {
background: #fff;
}
&.minimized {
height: 50px;
cursor: pointer;
}
&.fullscreen {
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: 0;
margin: 0;
height: auto;
}
.index-index & {
margin-left: 205px;
margin-right: -20px;
}
&.active, &.fullscreen {
background: #fff;
}
&.minimized {
height: 50px;
cursor: pointer;
}
&.fullscreen {
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: 0;
margin: 0;
height: auto;
}
}
.composer-content {
padding: 20px 20px 15px;
padding: 20px 20px 15px;
.minimized & {
padding: 10px 20px;
}
.fullscreen & {
max-width: 900px;
margin: 0 auto;
padding: 30px;
}
.minimized & {
padding: 10px 20px;
}
.fullscreen & {
max-width: 900px;
margin: 0 auto;
padding: 30px;
}
}
.composer-handle {
height: 20px;
margin-bottom: -20px;
position: relative;
height: 20px;
margin-bottom: -20px;
position: relative;
.minimized &, .fullscreen & {
display: none;
}
.minimized &, .fullscreen & {
display: none;
}
}
.composer-controls {
position: absolute;
right: 10px;
top: 10px;
list-style-type: none;
padding: 0;
margin: 0;
position: absolute;
right: 10px;
top: 10px;
list-style-type: none;
padding: 0;
margin: 0;
& li {
display: inline-block;
}
.minimized & {
top: 7px;
}
& li {
display: inline-block;
}
.minimized & {
top: 7px;
}
}
.fa-minus.minimize {
vertical-align: -5px;
vertical-align: -5px;
}
.composer-avatar {
float: left;
.avatar-size(64px);
float: left;
.avatar-size(64px);
.minimized & {
display: none;
}
.minimized & {
display: none;
}
}
.composer-body {
margin-left: 90px;
& h3 {
margin: 5px 0 10px;
&, & input {
color: @fl-body-muted-color;
font-size: 16px;
font-weight: normal;
}
& input {
background: none;
border: 0;
padding: 0;
height: auto;
}
}
margin-left: 90px;
.minimized & {
margin-left: 0;
}
& h3 {
margin: 5px 0 10px;
&, & input {
color: @fl-body-muted-color;
font-size: 16px;
font-weight: normal;
}
& input {
background: none;
border: 0;
padding: 0;
height: auto;
}
}
.minimized & {
margin-left: 0;
}
}
.composer-editor {
.minimized & {
visibility: hidden;
}
.minimized & {
visibility: hidden;
}
}
.text-editor {
& textarea {
border-radius: 0;
padding: 0;
border: 0;
resize: none;
color: @fl-body-color;
font-size: 14px;
line-height: 1.6;
& textarea {
border-radius: 0;
padding: 0;
border: 0;
resize: none;
color: @fl-body-color;
font-size: 14px;
line-height: 1.6;
&, &:focus, &[disabled] {
background: none;
}
}
&, &:focus, &[disabled] {
background: none;
}
}
}
.text-editor-controls {
margin: 10px 0 0;
padding: 0;
list-style-type: none;
& li {
display: inline-block;
}
margin: 10px 0 0;
padding: 0;
list-style-type: none;
& li {
display: inline-block;
}
}
.composer-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
opacity: 0;
pointer-events: none;
border-radius: @border-radius-base @border-radius-base 0 0;
.transition(opacity 0.2s);
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
opacity: 0;
pointer-events: none;
border-radius: @border-radius-base @border-radius-base 0 0;
.transition(opacity 0.2s);
&.active {
opacity: 1;
pointer-events: auto;
}
&.active {
opacity: 1;
pointer-events: auto;
}
}

View File

@ -2,239 +2,273 @@
// Sidebar
.discussion-nav {
float: right;
float: right;
&, & > ul {
width: 150px;
}
& > ul {
position: fixed;
margin: 30px 0 0;
padding: 0;
list-style-type: none;
&, & > ul {
width: 150px;
}
& > ul {
position: fixed;
margin: 30px 0 0;
padding: 0;
list-style-type: none;
& > li {
margin-bottom: 10px;
}
}
& .btn-group, & .btn {
width: 100%;
}
& .btn-group:not(.item-count-1) {
& .btn {
width: 80%;
}
& .dropdown-toggle {
width: 19%;
}
}
& > li {
margin-bottom: 10px;
}
}
& .btn-group, & .btn {
width: 100%;
}
& .btn-group:not(.item-count-1) {
& .btn {
width: 80%;
}
& .dropdown-toggle {
width: 19%;
}
}
}
// ------------------------------------
// Stream
.discussion-posts {
margin-top: 40px;
margin-right: 200px;
& .item {
margin-bottom: 40px;
}
margin-top: 40px;
margin-right: 200px;
& .item {
margin-bottom: 40px;
}
}
.gap {
padding: 30px 0;
text-align: center;
color: #aaa;
cursor: pointer;
border: 2px dashed @fl-body-bg;
background: #f2f2f2;
text-transform: uppercase;
font-size: 12px;
font-weight: bold;
overflow: hidden;
position: relative;
.transition(padding 0.2s);
padding: 30px 0;
text-align: center;
color: #aaa;
cursor: pointer;
border: 2px dashed @fl-body-bg;
background: #f2f2f2;
text-transform: uppercase;
font-size: 12px;
font-weight: bold;
overflow: hidden;
position: relative;
.transition(padding 0.2s);
&:hover, &.loading, &.active {
padding: 50px 0;
&.up:before, &.down:after {
opacity: 1;
}
}
&.loading {
.transition(none);
}
&:before, &:after {
font-family: 'FontAwesome';
display: block;
opacity: 0;
transition: opacity 0.2s;
height: 15px;
color: #aaa;
}
&.up:before {
content: '\f077';
margin-top: -25px;
margin-bottom: 10px;
}
&.down:after {
content: '\f078';
margin-bottom: -25px;
margin-top: 10px;
}
&:only-child {
background: none;
border: 0;
color: @fl-primary-color;
&:before, &:after {
display: none;
}
}
&:hover, &.loading, &.active {
padding: 50px 0;
&.up:before, &.down:after {
opacity: 1;
}
}
&.loading {
.transition(none);
}
&:before, &:after {
font-family: 'FontAwesome';
display: block;
opacity: 0;
transition: opacity 0.2s;
height: 15px;
color: #aaa;
}
&.up:before {
content: '\f077';
margin-top: -25px;
margin-bottom: 10px;
}
&.down:after {
content: '\f078';
margin-bottom: -25px;
margin-top: 10px;
}
&:only-child {
background: none;
border: 0;
color: @fl-primary-color;
&:before, &:after {
display: none;
}
}
}
// ------------------------------------
// Posts
.post {
padding-left: 90px;
transition: 0.2s box-shadow;
padding-left: 90px;
padding-bottom: 1px;
transition: 0.2s box-shadow;
& .contextual-controls {
float: right;
margin: -2px 0 0 10px;
visibility: hidden;
}
&:hover .contextual-controls, & .contextual-controls.open {
visibility: visible;
}
& .contextual-controls {
float: right;
margin: -2px 0 0 10px;
visibility: hidden;
z-index: 1;
}
&:hover .contextual-controls, & .contextual-controls.open {
visibility: visible;
}
}
.item.highlight .post {
border: 8px solid rgba(255, 255, 0, 0.2);
border-radius: 8px;
padding: 15px 15px 0 105px;
margin: -23px -23px -8px -23px;
border: 8px solid rgba(255, 255, 0, 0.2);
border-radius: 8px;
padding: 15px 15px 1px 105px;
margin: -23px -23px -8px -23px;
}
.post-header {
margin-bottom: 10px;
color: @fl-body-muted-color;
margin-bottom: 10px;
color: @fl-body-muted-color;
& > ul {
list-style-type: none;
padding: 0;
margin: 0;
& > li {
display: inline;
}
}
& .user {
margin: 0;
display: inline;
font-weight: bold;
font-size: 15px;
&, & a {
color: @fl-body-heading-color;
}
}
& .avatar {
margin-left: -90px;
float: left;
.avatar-size(64px);
}
& .time {
margin-left: 10px;
&, & a {
color: @fl-body-muted-color;
}
}
& > ul {
list-style-type: none;
padding: 0;
margin: 0;
& > li {
display: inline;
margin-right: 10px;
}
&, & a {
color: @fl-body-muted-color;
}
}
& .post-user {
margin: 0;
display: inline;
font-weight: bold;
font-size: 15px;
&, & a {
color: @fl-body-heading-color;
}
}
& .avatar {
margin-left: -90px;
float: left;
.avatar-size(64px);
}
}
.post-body {
font-size: 14px;
line-height: 1.6;
padding-bottom: 1px;
font-size: 14px;
line-height: 1.6;
}
.post-edited {
margin-left: 10px;
font-size: 14px;
.post-icon {
float: left;
margin-top: -2px;
margin-left: -90px;
width: 64px;
text-align: center;
font-size: 22px;
}
.post.deleted {
& .post-user, & .post-header > ul, & .post-header > ul a:not(.btn) {
color: @fl-body-muted-more-color;
}
&:not(.reveal-content) {
& .post-body, & .post-footer, & .avatar {
display: none;
}
}
&.reveal-content {
& .post-body, & .post-footer, & .avatar {
opacity: 0.5;
}
}
}
.post-meta {
width: 400px;
padding: 10px;
color: @fl-body-muted-color;
& .number {
color: @fl-body-color;
font-weight: bold;
}
& .time {
margin-left: 5px;
}
& .permalink {
margin-top: 10px;
}
}
// ------------------------------------
// Scrubber
@media (min-width: @screen-md-min) {
.stream-scrubber {
margin: 30px 0 0 0;
}
.scrubber {
& a {
margin-left: -5px;
color: @fl-body-muted-color;
& .fa {
font-size: 14px;
margin-right: 2px;
}
&:hover, &:focus {
text-decoration: none;
color: @link-hover-color;
}
}
}
.scrubber-scrollbar {
margin: 8px 0 8px 3px;
height: 300px;
min-height: 50px; // JavaScript sets a max-height
position: relative;
}
.scrubber-before, .scrubber-after {
border-left: 1px solid darken(@fl-secondary-color, 2%);
}
.scrubber-slider {
position: relative;
width: 100%;
padding: 5px 0;
}
.scrubber-handle {
height: 100%;
width: 5px;
background: @fl-primary-color;
border-radius: 4px;
float: left;
margin-left: -2px;
transition: background 0.2s;
.disabled & {
background: @fl-secondary-color;
}
}
.scrubber-info {
height: (2em * @line-height-base);
margin-top: (-1em * @line-height-base);
position: absolute;
top: 50%;
width: 100%;
left: 15px;
& strong {
display: block;
}
& .description {
color: @fl-body-muted-color;
}
}
.scrubber-highlights {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
list-style-type: none;
pointer-events: none;
}
.scrubber-highlights li {
position: absolute;
right: -6px;
background: #fc0;
height: 8px;
width: 13px;
border-radius: 4px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15), inset 0 0 0 1px rgba(255, 255, 255, 0.5);
opacity: 0.99;
}
.stream-scrubber {
margin: 30px 0 0 0;
}
.scrubber {
& a {
margin-left: -5px;
color: @fl-body-muted-color;
& .fa {
font-size: 14px;
margin-right: 2px;
}
&:hover, &:focus {
text-decoration: none;
color: @link-hover-color;
}
}
}
.scrubber-scrollbar {
margin: 8px 0 8px 3px;
height: 300px;
min-height: 50px; // JavaScript sets a max-height
position: relative;
}
.scrubber-before, .scrubber-after {
border-left: 1px solid darken(@fl-secondary-color, 2%);
}
.scrubber-slider {
position: relative;
width: 100%;
padding: 5px 0;
}
.scrubber-handle {
height: 100%;
width: 5px;
background: @fl-primary-color;
border-radius: 4px;
float: left;
margin-left: -2px;
transition: background 0.2s;
.disabled & {
background: @fl-secondary-color;
}
}
.scrubber-info {
height: (2em * @line-height-base);
margin-top: (-1em * @line-height-base);
position: absolute;
top: 50%;
width: 100%;
left: 15px;
& strong {
display: block;
}
& .description {
color: @fl-body-muted-color;
}
}
.scrubber-highlights {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
list-style-type: none;
pointer-events: none;
}
.scrubber-highlights li {
position: absolute;
right: -6px;
background: #fc0;
height: 8px;
width: 13px;
border-radius: 4px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15), inset 0 0 0 1px rgba(255, 255, 255, 0.5);
opacity: 0.99;
}
}

View File

@ -2,89 +2,89 @@
// Sidebar
.index-nav {
float: left;
float: left;
&, & > ul {
width: 175px;
}
& > ul {
margin: 30px 0 0;
padding: 0;
list-style-type: none;
&, & > ul {
width: 175px;
}
& > ul {
margin: 30px 0 0;
padding: 0;
list-style-type: none;
&.affix {
top: 56px;
}
& > li {
margin-bottom: 10px;
}
}
& .new-discussion {
display: block;
margin-bottom: 20px;
}
&.affix {
top: 56px;
}
& > li {
margin-bottom: 10px;
}
}
& .new-discussion {
display: block;
margin-bottom: 20px;
}
// Expand the dropdown-select component into a normal nav list
// @todo Extract this into a mixin as we'll need to do it elsewhere.
& .dropdown-select {
display: block;
// Expand the dropdown-select component into a normal nav list
// @todo Extract this into a mixin as we'll need to do it elsewhere.
& .dropdown-select {
display: block;
& .dropdown-toggle {
display: none;
}
& .dropdown-menu {
display: block;
border: 0;
width: auto;
margin: 0;
padding: 0;
min-width: 0;
float: none;
position: static;
background: none;
.box-shadow(none);
& > li > a {
padding: 8px 0;
color: @fl-body-muted-color;
& .fa {
margin-right: 8px;
font-size: 15px;
}
&:hover {
background: none;
color: @link-hover-color;
}
}
& > li.active > a {
background: none;
color: @fl-primary-color;
font-weight: bold;
}
}
}
& .dropdown-toggle {
display: none;
}
& .dropdown-menu {
display: block;
border: 0;
width: auto;
margin: 0;
padding: 0;
min-width: 0;
float: none;
position: static;
background: none;
.box-shadow(none);
& > li > a {
padding: 8px 0;
color: @fl-body-muted-color;
& .fa {
margin-right: 8px;
font-size: 15px;
}
&:hover {
background: none;
color: @link-hover-color;
}
}
& > li.active > a {
background: none;
color: @fl-primary-color;
font-weight: bold;
}
}
}
}
// ------------------------------------
// Results
.index-results {
margin-top: 30px;
margin-left: 225px;
& .loading-indicator {
height: 46px;
}
margin-top: 30px;
margin-left: 225px;
& .loading-indicator {
height: 46px;
}
}
.index-toolbar {
margin-bottom: 15px;
margin-bottom: 15px;
}
.index-toolbar-view {
display: inline-block;
& .control-show {
margin-right: 10px;
}
display: inline-block;
& .control-show {
margin-right: 10px;
}
}
.index-toolbar-action {
float: right;
float: right;
}
// ------------------------------------
@ -93,162 +93,161 @@
@index-pane-width: 400px;
.index-area {
left: -@index-pane-width;
width: 100%;
left: -@index-pane-width;
width: 100%;
&.paned {
position: fixed;
z-index: @zindex-pane;
overflow: auto;
top: 56px;
bottom: 0;
width: @index-pane-width;
background: #fff;
padding-bottom: 200px;
.box-shadow(2px 2px 6px -2px rgba(0, 0, 0, 0.25));
.transition(left 0.2s);
&.paned {
position: fixed;
z-index: @zindex-pane;
overflow: auto;
top: 56px;
bottom: 0;
width: @index-pane-width;
background: #fff;
padding-bottom: 200px;
.box-shadow(2px 2px 6px -2px rgba(0, 0, 0, 0.25));
.transition(left 0.2s);
&.showing, .with-pane & {
left: 0;
}
.with-pane & {
z-index: @zindex-composer - 1;
.transition(none);
}
& .container {
width: auto;
margin: 0;
padding: 0 !important;
}
& .index-results {
margin: 0;
}
& .hero, & .index-nav, & .index-toolbar {
display: none;
}
& .discussions-list > li {
margin: 0;
padding-left: 65px + 15px;
padding-right: 65px + 15px;
&.active {
background: @fl-secondary-color;
}
}
& .discussion-summary {
& .title {
font-size: 14px;
}
& .count strong {
font-size: 18px;
}
}
}
&.showing, .with-pane & {
left: 0;
}
.with-pane & {
z-index: @zindex-composer - 1;
.transition(none);
}
& .container {
width: auto;
margin: 0;
padding: 0 !important;
}
& .index-results {
margin: 0;
}
& .hero, & .index-nav, & .index-toolbar {
display: none;
}
& .discussions-list > li {
margin: 0;
padding-left: 65px + 15px;
padding-right: 65px + 15px;
&.active {
background: @fl-secondary-color;
}
}
& .discussion-summary {
& .title {
font-size: 14px;
}
& .count strong {
font-size: 18px;
}
}
}
}
// When the pane is pinned, move the other page content inwards
.global-main, .global-footer {
.with-pane & {
margin-left: @index-pane-width;
& .container {
max-width: 100%;
padding: 0 30px;
}
}
.with-pane & {
margin-left: @index-pane-width;
& .container {
max-width: 100%;
padding: 0 30px;
}
}
}
.global-header .container {
.with-pane & {
width: 100%;
}
.with-pane & {
width: 100%;
}
}
// ------------------------------------
// Discussions List
.discussions-list {
margin: 0;
padding: 0;
list-style-type: none;
position: relative;
margin: 0;
padding: 0;
list-style-type: none;
position: relative;
& > li {
margin-right: -25px;
padding-right: 65px + 25px;
& .contextual-controls {
position: absolute;
right: 0;
top: 18px;
visibility: hidden;
}
&:hover .contextual-controls, & .contextual-controls.open {
visibility: visible;
}
}
& > li {
margin-right: -25px;
padding-right: 65px + 25px;
& .contextual-controls {
position: absolute;
right: 0;
top: 18px;
visibility: hidden;
}
&:hover .contextual-controls, & .contextual-controls.open {
visibility: visible;
}
}
}
.discussion-summary {
padding-left: 65px;
padding-right: 65px;
position: relative;
padding-left: 65px;
padding-right: 65px;
position: relative;
& .author {
float: left;
margin-left: -65px;
margin-top: 18px;
}
& .info {
display: inline-block;
width: 100%;
margin-right: -65px;
color: @fl-body-muted-color;
padding: 20px 0;
&, & a {
color: @fl-body-muted-color;
}
& .author {
float: left;
margin-left: -65px;
margin-top: 18px;
}
& .main {
display: inline-block;
width: 100%;
padding: 20px 0;
margin-right: -65px;
&:hover, &:active, &.active, &:focus {
text-decoration: none;
& .title {
text-decoration: underline;
}
}
&.active .title {
text-decoration: none;
}
}
& .title {
margin: 0 0 5px;
font-size: 15px;
line-height: 1.3;
&, & a {
font-weight: normal;
color: @fl-body-muted-color;
}
.unread&, .unread& a {
font-weight: bold;
color: @fl-body-heading-color;
}
}
& .count {
float: right;
margin-top: 18px;
margin-right: -65px;
width: 60px;
text-align: center;
text-transform: uppercase;
color: @fl-body-muted-color;
font-size: 11px;
cursor: pointer;
text-decoration: none;
&.active {
text-decoration: none;
}
}
& .title {
margin: 0 0 5px;
font-size: 15px;
line-height: 1.3;
}
& .info {
list-style-type: none;
padding: 0;
margin: 0;
& strong {
font-size: 20px;
display: block;
font-weight: 300;
}
.unread&, .unread& strong {
color: @fl-body-heading-color;
font-weight: bold;
}
}
& > li {
display: inline-block;
}
}
& .count {
float: right;
margin-top: 18px;
margin-right: -65px;
width: 60px;
text-align: center;
text-transform: uppercase;
color: @fl-body-muted-color;
font-size: 11px;
text-decoration: none;
& strong {
font-size: 20px;
display: block;
font-weight: 300;
}
.unread& {
cursor: pointer;
}
.unread&, .unread& strong {
color: @fl-body-heading-color;
font-weight: bold;
}
}
}
.load-more {
text-align: center;
margin-top: 10px;
text-align: center;
margin-top: 10px;
}

View File

@ -1,191 +1,192 @@
body {
background: @fl-body-bg;
color: @fl-body-color;
padding-top: 56px;
background: @fl-body-bg;
color: @fl-body-color;
padding-top: 56px;
}
.container-narrow {
max-width: 500px;
margin: 0 auto;
max-width: 500px;
margin: 0 auto;
}
// ------------------------------------
// Header
.global-header {
background: fade(@fl-hdr-bg, 98%);
transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
padding: 10px;
height: 56px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: @zindex-navbar-fixed;
.clearfix();
.transition(box-shadow 0.2s);
background: fade(@fl-hdr-bg, 98%);
transform: translateZ(0); // Fix for Chrome bug where a transparent white background is actually gray
padding: 10px;
height: 56px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: @zindex-navbar-fixed;
.clearfix();
.transition(box-shadow 0.2s);
.scrolled & {
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
}
.scrolled & {
.box-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
}
& when (@fl-dark-hdr = true) {
&, & .btn-link {
color: @fl-hdr-control-color;
}
& .form-control {
background: @fl-hdr-control-bg;
border: 0;
color: @fl-hdr-control-color;
.placeholder(@fl-hdr-control-color);
& when (@fl-dark-hdr = true) {
&, & .btn-link {
color: @fl-hdr-control-color;
}
& .form-control {
background: @fl-hdr-control-bg;
border: 0;
color: @fl-hdr-control-color;
.placeholder(@fl-hdr-control-color);
&:focus {
background: fadein(@fl-hdr-control-bg, 5%);
}
}
& .search-input:before {
color: @fl-hdr-control-color;
}
& .btn-default, & .btn-default:hover {
background: @fl-hdr-control-bg;
color: @fl-hdr-control-color;
}
& .btn-default.active, .open > .dropdown-toggle.btn-default {
background: fadein(@fl-hdr-control-bg, 5%);
}
& .btn-naked {
background: transparent;
}
}
&:focus {
background: fadein(@fl-hdr-control-bg, 5%);
}
}
& .search-input:before {
color: @fl-hdr-control-color;
}
& .btn-default, & .btn-default:hover {
background: @fl-hdr-control-bg;
color: @fl-hdr-control-color;
}
& .btn-default.active, .open > .dropdown-toggle.btn-default {
background: fadein(@fl-hdr-control-bg, 5%);
}
& .btn-naked {
background: transparent;
}
}
}
.header-controls {
margin: 0;
padding: 0;
list-style-type: none;
&, & > li {
display: inline-block;
vertical-align: top;
}
margin: 0;
padding: 0;
list-style-type: none;
&, & > li {
display: inline-block;
vertical-align: top;
}
}
.header-primary {
float: left;
& h1 {
display: inline-block;
vertical-align: top;
}
float: left;
& h1 {
display: inline-block;
vertical-align: top;
}
}
.header-title {
font-size: 18px;
font-weight: normal;
margin: 0;
line-height: 36px;
&, & a {
color: @fl-hdr-color;
}
font-size: 18px;
font-weight: normal;
margin: 0;
line-height: 36px;
&, & a {
color: @fl-hdr-color;
}
}
.header-secondary {
float: right;
float: right;
}
// Back button
// @todo Lots of !importants in here, could we be more specific?
.back-button {
float: left;
margin-right: 25px;
float: left;
margin-right: 25px;
& .back {
z-index: 3 !important; // z-index of an active .btn-group .btn is 2
border-radius: @border-radius-base !important;
.transition(border-radius 0.2s);
}
& .pin {
opacity: 0;
margin-left: -36px !important;
.transition(~"opacity 0.2s, margin-left 0.2s");
& .back {
z-index: 3 !important; // z-index of an active .btn-group .btn is 2
border-radius: @border-radius-base !important;
.transition(border-radius 0.2s);
}
& .pin {
opacity: 0;
margin-left: -36px !important;
.transition(~"opacity 0.2s, margin-left 0.2s");
&:not(.active) .fa {
.rotate(45deg);
}
}
&.active {
& .back {
border-radius: @border-radius-base 0 0 @border-radius-base !important;
}
& .pin {
opacity: 1;
margin-left: 1px !important;
}
}
&:not(.active) .fa {
.rotate(45deg);
}
}
&.active {
& .back {
border-radius: @border-radius-base 0 0 @border-radius-base !important;
}
& .pin {
opacity: 1;
margin-left: 1px !important;
}
}
}
// ------------------------------------
// Main
.global-main, .paned {
border-top: 1px solid @fl-secondary-color;
border-top: 1px solid @fl-secondary-color;
}
// Hero
.hero {
background: @fl-body-hero-bg;
color: @fl-body-hero-color;
margin-top: -1px;
text-align: center;
padding: 30px 0;
background: @fl-body-hero-bg;
color: @fl-body-hero-color;
margin-top: -1px;
text-align: center;
padding: 30px 0;
font-size: 14px;
}
.hero .close {
float: right;
margin-top: -10px;
color: #fff;
opacity: 0.5;
float: right;
margin-top: -10px;
color: #fff;
opacity: 0.5;
}
.hero h2 {
margin: 0;
font-size: 22px;
font-weight: normal;
margin: 0;
font-size: 22px;
font-weight: normal;
& when (@fl-dark-hdr = true) {
color: @fl-body-color;
}
& when (@fl-dark-hdr = true) {
color: @fl-body-color;
}
}
.hero p {
margin: 10px 0 0;
margin: 10px 0 0;
}
// ------------------------------------
// Footer
.global-footer {
margin: 100px 0 20px;
color: @fl-body-muted-more-color;
.clearfix();
margin: 100px 0 20px;
color: @fl-body-muted-more-color;
.clearfix();
}
.footer-primary, .footer-secondary {
margin: 0;
padding: 0;
list-style-type: none;
margin: 0;
padding: 0;
list-style-type: none;
& > li {
display: inline-block;
vertical-align: middle;
}
& a {
color: @fl-body-muted-more-color;
&:hover,
&:focus {
text-decoration: none;
color: @link-hover-color;
}
}
& > li {
display: inline-block;
vertical-align: middle;
}
& a {
color: @fl-body-muted-more-color;
&:hover,
&:focus {
text-decoration: none;
color: @link-hover-color;
}
}
}
.footer-primary {
display: inline-block;
& > li {
margin-right: 15px;
}
display: inline-block;
& > li {
margin-right: 15px;
}
}
.footer-secondary {
float: right;
& > li {
margin-left: 15px;
}
float: right;
& > li {
margin-left: 15px;
}
}

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