diff --git a/framework/core/ember/admin/.bowerrc b/framework/core/ember/admin/.bowerrc
deleted file mode 100644
index 959e1696e..000000000
--- a/framework/core/ember/admin/.bowerrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "directory": "bower_components",
- "analytics": false
-}
diff --git a/framework/core/ember/admin/.editorconfig b/framework/core/ember/admin/.editorconfig
deleted file mode 100644
index 2fe4874a0..000000000
--- a/framework/core/ember/admin/.editorconfig
+++ /dev/null
@@ -1,33 +0,0 @@
-# EditorConfig helps developers define and maintain consistent
-# coding styles between different editors and IDEs
-# editorconfig.org
-
-root = true
-
-
-[*]
-end_of_line = lf
-charset = utf-8
-trim_trailing_whitespace = true
-insert_final_newline = true
-indent_style = space
-indent_size = 2
-
-[*.js]
-indent_style = space
-indent_size = 2
-
-[*.hbs]
-indent_style = space
-indent_size = 2
-
-[*.css]
-indent_style = space
-indent_size = 2
-
-[*.html]
-indent_style = space
-indent_size = 2
-
-[*.{diff,md}]
-trim_trailing_whitespace = false
diff --git a/framework/core/ember/admin/.gitignore b/framework/core/ember/admin/.gitignore
deleted file mode 100644
index 86fceae7a..000000000
--- a/framework/core/ember/admin/.gitignore
+++ /dev/null
@@ -1,17 +0,0 @@
-# See http://help.github.com/ignore-files/ for more about ignoring files.
-
-# compiled output
-/dist
-/tmp
-
-# dependencies
-/node_modules
-/bower_components
-
-# misc
-/.sass-cache
-/connect.lock
-/coverage/*
-/libpeerconnection.log
-npm-debug.log
-testem.log
diff --git a/framework/core/ember/admin/.jshintrc b/framework/core/ember/admin/.jshintrc
deleted file mode 100644
index e75f71963..000000000
--- a/framework/core/ember/admin/.jshintrc
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "predef": [
- "document",
- "window",
- "-Promise",
- "moment"
- ],
- "browser": true,
- "boss": true,
- "curly": true,
- "debug": false,
- "devel": true,
- "eqeqeq": true,
- "evil": true,
- "forin": false,
- "immed": false,
- "laxbreak": false,
- "newcap": true,
- "noarg": true,
- "noempty": false,
- "nonew": false,
- "nomen": false,
- "onevar": false,
- "plusplus": false,
- "regexp": false,
- "undef": true,
- "sub": true,
- "strict": false,
- "white": false,
- "eqnull": true,
- "esnext": true,
- "unused": true
-}
diff --git a/framework/core/ember/admin/.travis.yml b/framework/core/ember/admin/.travis.yml
deleted file mode 100644
index cf23938b7..000000000
--- a/framework/core/ember/admin/.travis.yml
+++ /dev/null
@@ -1,20 +0,0 @@
----
-language: node_js
-
-sudo: false
-
-cache:
- directories:
- - node_modules
-
-before_install:
- - "npm config set spin false"
- - "npm install -g npm@^2"
-
-install:
- - npm install -g bower
- - npm install
- - bower install
-
-script:
- - npm test
diff --git a/framework/core/ember/admin/Brocfile.js b/framework/core/ember/admin/Brocfile.js
deleted file mode 100644
index 0e4a4a415..000000000
--- a/framework/core/ember/admin/Brocfile.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/* global require, module */
-
-var EmberApp = require('ember-cli/lib/broccoli/ember-app');
-
-var app = new EmberApp();
-
-app.import('bower_components/bootstrap/dist/js/bootstrap.js');
-app.import('bower_components/spin.js/spin.js');
-app.import('bower_components/spin.js/jquery.spin.js');
-app.import('bower_components/moment/moment.js');
-app.import('bower_components/jquery.hotkeys/jquery.hotkeys.js');
-app.import('bower_components/blurjs/dist/jquery.blur.js');
-
-app.import('bower_components/font-awesome/fonts/fontawesome-webfont.eot');
-app.import('bower_components/font-awesome/fonts/fontawesome-webfont.svg');
-app.import('bower_components/font-awesome/fonts/fontawesome-webfont.ttf');
-app.import('bower_components/font-awesome/fonts/fontawesome-webfont.woff');
-app.import('bower_components/font-awesome/fonts/FontAwesome.otf');
-
-module.exports = app.toTree();
diff --git a/framework/core/ember/admin/app/app.js b/framework/core/ember/admin/app/app.js
deleted file mode 100644
index a4a0917c5..000000000
--- a/framework/core/ember/admin/app/app.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Ember from 'ember';
-import Resolver from 'ember/resolver';
-import loadInitializers from 'ember/load-initializers';
-import config from './config/environment';
-
-Ember.MODEL_FACTORY_INJECTIONS = true;
-
-var App = Ember.Application.extend({
- modulePrefix: config.modulePrefix,
- podModulePrefix: config.podModulePrefix,
- Resolver: Resolver
-});
-
-loadInitializers(App, config.modulePrefix);
-
-Ember.$('#assets-loading').remove();
-
-export default App;
diff --git a/framework/core/ember/admin/app/components/ui/admin-nav-item.js b/framework/core/ember/admin/app/components/ui/admin-nav-item.js
deleted file mode 100644
index 7658dadc9..000000000
--- a/framework/core/ember/admin/app/components/ui/admin-nav-item.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import Ember from 'ember';
-import NavItem from './nav-item';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-export default NavItem.extend({
- layout: precompileTemplate('{{#link-to routeName}}{{fa-icon icon class="icon"}} {{label}}
{{description}}
{{/link-to}}')
-});
diff --git a/framework/core/ember/admin/app/components/user-dropdown.js b/framework/core/ember/admin/app/components/user-dropdown.js
deleted file mode 100644
index fad9b0ed6..000000000
--- a/framework/core/ember/admin/app/components/user-dropdown.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from '../mixins/has-item-lists';
-import DropdownButton from './ui/dropdown-button';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-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) {
- var self = this;
-
- this.addActionItem(items, 'logout', 'Log Out', 'sign-out', null, function() {
- self.get('parentController').send('invalidateSession');
- });
- }
-})
diff --git a/framework/core/ember/admin/app/controllers/.gitkeep b/framework/core/ember/admin/app/controllers/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/admin/app/controllers/application.js b/framework/core/ember/admin/app/controllers/application.js
deleted file mode 100644
index 5387efb7b..000000000
--- a/framework/core/ember/admin/app/controllers/application.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Controller.extend({
- actions: {
- toggleDrawer: function() {
- this.toggleProperty('drawerShowing');
- }
- }
-});
diff --git a/framework/core/ember/admin/app/index.html b/framework/core/ember/admin/app/index.html
deleted file mode 100644
index 5bfcca9ec..000000000
--- a/framework/core/ember/admin/app/index.html
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
- Flarum
-
-
-
- {{content-for 'head'}}
-
-
-
-
- {{content-for 'head-footer'}}
-
-
- {{content-for 'body'}}
-
-
-
-
- {{content-for 'body-footer'}}
-
-
diff --git a/framework/core/ember/admin/app/router.js b/framework/core/ember/admin/app/router.js
deleted file mode 100644
index b8bc6caab..000000000
--- a/framework/core/ember/admin/app/router.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Ember from 'ember';
-import config from './config/environment';
-
-var Router = Ember.Router.extend({
- location: config.locationType
-});
-
-Router.map(function() {
- this.resource('dashboard', {path: '/'});
- this.resource('basics');
- this.resource('permissions');
- this.resource('appearance');
- this.resource('extensions');
-});
-
-export default Router;
diff --git a/framework/core/ember/admin/app/routes/.gitkeep b/framework/core/ember/admin/app/routes/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/admin/app/styles/.gitkeep b/framework/core/ember/admin/app/styles/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/admin/app/styles/app.css b/framework/core/ember/admin/app/styles/app.css
deleted file mode 100644
index 65fd7e93e..000000000
--- a/framework/core/ember/admin/app/styles/app.css
+++ /dev/null
@@ -1,2 +0,0 @@
-// Flarum styles are stored in the top-level `less` directory. This remains
-// here as a placeholder file to prevent ember-cli from crashing.
diff --git a/framework/core/ember/admin/app/templates/.gitkeep b/framework/core/ember/admin/app/templates/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/admin/app/templates/appearance.hbs b/framework/core/ember/admin/app/templates/appearance.hbs
deleted file mode 100644
index 6db9a2630..000000000
--- a/framework/core/ember/admin/app/templates/appearance.hbs
+++ /dev/null
@@ -1 +0,0 @@
-Appearance
diff --git a/framework/core/ember/admin/app/templates/application.hbs b/framework/core/ember/admin/app/templates/application.hbs
deleted file mode 100644
index ad5e01d76..000000000
--- a/framework/core/ember/admin/app/templates/application.hbs
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
- {{application/back-button className="back-control" toggleDrawer="toggleDrawer" goBack="goBack" canGoBack=false}}
-
-
-
-
-
-
-
-
- {{ui/dropdown-select items=view.adminNav}}
-
-
- {{outlet}}
-
-
-
-
-
-
-
- {{outlet "modal"}}
-
-
-{{render "alerts"}}
diff --git a/framework/core/ember/admin/app/templates/basics.hbs b/framework/core/ember/admin/app/templates/basics.hbs
deleted file mode 100644
index 4a80312cf..000000000
--- a/framework/core/ember/admin/app/templates/basics.hbs
+++ /dev/null
@@ -1 +0,0 @@
-Basics
diff --git a/framework/core/ember/admin/app/templates/dashboard.hbs b/framework/core/ember/admin/app/templates/dashboard.hbs
deleted file mode 100644
index dcabf8fdf..000000000
--- a/framework/core/ember/admin/app/templates/dashboard.hbs
+++ /dev/null
@@ -1 +0,0 @@
-Dashboard
diff --git a/framework/core/ember/admin/app/templates/extensions.hbs b/framework/core/ember/admin/app/templates/extensions.hbs
deleted file mode 100644
index 26c0a2e8b..000000000
--- a/framework/core/ember/admin/app/templates/extensions.hbs
+++ /dev/null
@@ -1 +0,0 @@
-Extensions
diff --git a/framework/core/ember/admin/app/templates/loading.hbs b/framework/core/ember/admin/app/templates/loading.hbs
deleted file mode 100644
index ce3ce25fe..000000000
--- a/framework/core/ember/admin/app/templates/loading.hbs
+++ /dev/null
@@ -1 +0,0 @@
-{{ui/loading-indicator class="loading-indicator-block"}}
diff --git a/framework/core/ember/admin/app/templates/permissions.hbs b/framework/core/ember/admin/app/templates/permissions.hbs
deleted file mode 100644
index ca206fbdd..000000000
--- a/framework/core/ember/admin/app/templates/permissions.hbs
+++ /dev/null
@@ -1 +0,0 @@
-Permissions
diff --git a/framework/core/ember/admin/app/views/.gitkeep b/framework/core/ember/admin/app/views/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/admin/app/views/application.js b/framework/core/ember/admin/app/views/application.js
deleted file mode 100644
index a07d71a4c..000000000
--- a/framework/core/ember/admin/app/views/application.js
+++ /dev/null
@@ -1,79 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from '../mixins/has-item-lists';
-import AdminNavItem from '../components/ui/admin-nav-item';
-import SearchInput from '../components/ui/search-input';
-import UserDropdown from '../components/user-dropdown';
-
-export default Ember.View.extend(HasItemLists, {
- itemLists: ['headerPrimary', 'headerSecondary', 'adminNav'],
-
- drawerShowingChanged: Ember.observer('controller.drawerShowing', function() {
- Ember.run.scheduleOnce('afterRender', this, function() {
- $('body').toggleClass('drawer-open', this.get('controller.drawerShowing'));
- });
- }),
-
- didInsertElement: function() {
- this.$('.global-content').click(function(e) {
- if (view.get('controller.drawerShowing')) {
- e.preventDefault();
- view.set('controller.drawerShowing', false);
- }
- });
- },
-
- populateHeaderSecondary: function(items) {
- var controller = this.get('controller');
-
- items.pushObjectWithTag(SearchInput.extend({
- placeholder: 'Search Forum',
- controller: controller,
- valueBinding: Ember.Binding.oneWay('controller.searchQuery'),
- activeBinding: Ember.Binding.oneWay('controller.searchActive'),
- action: function(value) { controller.send('search', value); }
- }), 'search');
-
- items.pushObjectWithTag(UserDropdown.extend({
- user: this.get('controller.session.user'),
- parentController: controller
- }), 'user');
- },
-
- populateAdminNav: function(items) {
- items.pushObjectWithTag(AdminNavItem.extend({
- routeName: 'dashboard',
- icon: 'bar-chart',
- label: 'Dashboard',
- description: 'Your forum at a glance.'
- }), 'dashboard');
-
- items.pushObjectWithTag(AdminNavItem.extend({
- routeName: 'basics',
- icon: 'pencil',
- label: 'Basics',
- description: 'Set your forum title, language, and other basic settings.'
- }), 'basics');
-
- items.pushObjectWithTag(AdminNavItem.extend({
- routeName: 'permissions',
- icon: 'key',
- label: 'Permissions',
- description: 'Configure who can see and do what.'
- }), 'permissions');
-
- items.pushObjectWithTag(AdminNavItem.extend({
- routeName: 'appearance',
- icon: 'paint-brush',
- label: 'Appearance',
- description: 'Customize your forum\'s colors, logos, and other variables.'
- }), 'appearance');
-
- items.pushObjectWithTag(AdminNavItem.extend({
- routeName: 'extensions',
- icon: 'puzzle-piece',
- label: 'Extensions',
- description: 'Add extra functionality to your forum and make it your own.'
- }), 'extensions');
- }
-});
diff --git a/framework/core/ember/admin/bower.json b/framework/core/ember/admin/bower.json
deleted file mode 100644
index da9977507..000000000
--- a/framework/core/ember/admin/bower.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "name": "flarum-admin",
- "dependencies": {
- "jquery": "2.1.3",
- "ember": "1.11.0-beta.3",
- "ember-data": "1.0.0-beta.16.1",
- "ember-resolver": "~0.1.11",
- "loader.js": "ember-cli/loader.js#1.0.1",
- "ember-cli-shims": "ember-cli/ember-cli-shims#0.0.3",
- "ember-cli-test-loader": "0.1.3",
- "ember-load-initializers": "ember-cli/ember-load-initializers#0.0.2",
- "ember-qunit": "0.2.8",
- "ember-qunit-notifications": "0.0.7",
- "qunit": "~1.17.1",
- "bootstrap": "~3.3.2",
- "font-awesome": "~4",
- "spin.js": "~2.0.1",
- "moment": "~2.8.4",
- "ember-simple-auth": "0.7.2",
- "jquery.hotkeys": "jeresig/jquery.hotkeys#0.2.0",
- "blurjs": ""
- },
- "resolutions": {
- "ember-cli-test-loader": "0.1.3",
- "ember-qunit": "0.2.8",
- "ember-qunit-notifications": "0.0.7"
- }
-}
diff --git a/framework/core/ember/admin/config/environment.js b/framework/core/ember/admin/config/environment.js
deleted file mode 100644
index c33eccada..000000000
--- a/framework/core/ember/admin/config/environment.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/* jshint node: true */
-
-module.exports = function(environment) {
- var ENV = {
- modulePrefix: 'flarum-admin',
- environment: environment,
- baseURL: '/',
- apiURL: '/api',
- locationType: 'hash',
- EmberENV: {
- FEATURES: {
- // Here you can enable experimental features on an ember canary build
- // e.g. 'with-controller': true
- }
- },
-
- APP: {
- // Here you can pass flags/options to your application instance
- // when it is created
- }
- };
-
- ENV['simple-auth'] = {
- authorizer: 'authorizer:flarum'
- };
-
- if (environment === 'development') {
- // ENV.APP.LOG_RESOLVER = true;
- // ENV.APP.LOG_ACTIVE_GENERATION = true;
- // ENV.APP.LOG_TRANSITIONS = true;
- // ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
- // ENV.APP.LOG_VIEW_LOOKUPS = true;
- }
-
- if (environment === 'test') {
- // Testem prefers this...
- ENV.baseURL = '/';
- ENV.locationType = 'none';
- ENV.apiURL = 'http://flarum.dev/api',
-
- // keep test console output quieter
- ENV.APP.LOG_ACTIVE_GENERATION = false;
- ENV.APP.LOG_VIEW_LOOKUPS = false;
-
- ENV.APP.rootElement = '#ember-testing';
- }
-
- if (environment === 'production') {
-
- }
-
- return ENV;
-};
diff --git a/framework/core/ember/admin/package.json b/framework/core/ember/admin/package.json
deleted file mode 100644
index 5bab82f72..000000000
--- a/framework/core/ember/admin/package.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "name": "flarum-admin",
- "version": "0.0.0",
- "private": true,
- "directories": {
- "doc": "doc",
- "test": "tests"
- },
- "scripts": {
- "start": "ember server",
- "build": "ember build",
- "test": "ember test",
- "preinstall": "sudo npm link ../common"
- },
- "repository": "",
- "engines": {
- "node": ">= 0.10.0"
- },
- "author": "",
- "license": "MIT",
- "devDependencies": {
- "ember-cli": "^0.2.0-beta.1",
- "ember-cli-app-version": "0.3.1",
- "ember-cli-babel": "^4.1.0",
- "ember-cli-content-security-policy": "0.3.0",
- "ember-cli-dependency-checker": "0.0.7",
- "ember-cli-htmlbars": "^0.7.4",
- "ember-cli-ic-ajax": "0.1.1",
- "ember-cli-inject-live-reload": "^1.3.0",
- "ember-cli-qunit": "^0.3.8",
- "ember-cli-simple-auth": "^0.7.2",
- "ember-cli-uglify": "1.0.1",
- "ember-data": "1.0.0-beta.16.1",
- "ember-export-application-global": "^1.0.2",
- "ember-json-api": "eneuhauser/ember-json-api",
- "broccoli-ember-inline-template-compiler": "tobscure/broccoli-ember-inline-template-compiler#f884d11",
- "express": "^4.8.5",
- "glob": "^4.0.5",
- "flarum-common": "*"
- }
-}
diff --git a/framework/core/ember/admin/testem.json b/framework/core/ember/admin/testem.json
deleted file mode 100644
index 42a4ddb22..000000000
--- a/framework/core/ember/admin/testem.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "framework": "qunit",
- "test_page": "tests/index.html?hidepassed",
- "launch_in_ci": [
- "PhantomJS"
- ],
- "launch_in_dev": [
- "PhantomJS",
- "Chrome"
- ]
-}
diff --git a/framework/core/ember/admin/tests/.jshintrc b/framework/core/ember/admin/tests/.jshintrc
deleted file mode 100644
index 6ebf71a02..000000000
--- a/framework/core/ember/admin/tests/.jshintrc
+++ /dev/null
@@ -1,74 +0,0 @@
-{
- "predef": [
- "document",
- "window",
- "location",
- "setTimeout",
- "$",
- "-Promise",
- "QUnit",
- "define",
- "console",
- "equal",
- "notEqual",
- "notStrictEqual",
- "test",
- "asyncTest",
- "testBoth",
- "testWithDefault",
- "raises",
- "throws",
- "deepEqual",
- "start",
- "stop",
- "ok",
- "strictEqual",
- "module",
- "moduleFor",
- "moduleForComponent",
- "moduleForModel",
- "process",
- "expect",
- "visit",
- "exists",
- "fillIn",
- "click",
- "keyEvent",
- "triggerEvent",
- "find",
- "findWithAssert",
- "wait",
- "DS",
- "isolatedContainer",
- "startApp",
- "andThen",
- "currentURL",
- "currentPath",
- "currentRouteName"
- ],
- "node": false,
- "browser": false,
- "boss": true,
- "curly": false,
- "debug": false,
- "devel": false,
- "eqeqeq": true,
- "evil": true,
- "forin": false,
- "immed": false,
- "laxbreak": false,
- "newcap": true,
- "noarg": true,
- "noempty": false,
- "nonew": false,
- "nomen": false,
- "onevar": false,
- "plusplus": false,
- "regexp": false,
- "undef": true,
- "sub": true,
- "strict": false,
- "white": false,
- "eqnull": true,
- "esnext": true
-}
diff --git a/framework/core/ember/admin/tests/helpers/resolver.js b/framework/core/ember/admin/tests/helpers/resolver.js
deleted file mode 100644
index 28f4ece46..000000000
--- a/framework/core/ember/admin/tests/helpers/resolver.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import Resolver from 'ember/resolver';
-import config from '../../config/environment';
-
-var resolver = Resolver.create();
-
-resolver.namespace = {
- modulePrefix: config.modulePrefix,
- podModulePrefix: config.podModulePrefix
-};
-
-export default resolver;
diff --git a/framework/core/ember/admin/tests/helpers/start-app.js b/framework/core/ember/admin/tests/helpers/start-app.js
deleted file mode 100644
index 16cc7c398..000000000
--- a/framework/core/ember/admin/tests/helpers/start-app.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import Ember from 'ember';
-import Application from '../../app';
-import Router from '../../router';
-import config from '../../config/environment';
-
-export default function startApp(attrs) {
- var application;
-
- var attributes = Ember.merge({}, config.APP);
- attributes = Ember.merge(attributes, attrs); // use defaults, but you can override;
-
- Ember.run(function() {
- application = Application.create(attributes);
- application.setupForTesting();
- application.injectTestHelpers();
- });
-
- return application;
-}
diff --git a/framework/core/ember/admin/tests/index.html b/framework/core/ember/admin/tests/index.html
deleted file mode 100644
index efd95f7ec..000000000
--- a/framework/core/ember/admin/tests/index.html
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
- Flarum Tests
-
-
-
- {{content-for 'head'}}
- {{content-for 'test-head'}}
-
-
-
-
-
- {{content-for 'head-footer'}}
- {{content-for 'test-head-footer'}}
-
-
-
- {{content-for 'body'}}
- {{content-for 'test-body'}}
-
-
-
-
-
-
- {{content-for 'body-footer'}}
- {{content-for 'test-body-footer'}}
-
-
diff --git a/framework/core/ember/admin/tests/integration/index-test.js b/framework/core/ember/admin/tests/integration/index-test.js
deleted file mode 100644
index 859e5657d..000000000
--- a/framework/core/ember/admin/tests/integration/index-test.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import Ember from "ember";
-import { test } from 'ember-qunit';
-import startApp from '../helpers/start-app';
-var App;
-
-module('Index', {
- setup: function() {
- App = startApp();
- },
- teardown: function() {
- Ember.run(App, App.destroy);
- }
-});
-
-test('Discussion list loading', function() {
- expect(3);
- visit('/').then(function() {
- equal(find('.discussions-list').length, 1, 'Page contains list of discussions');
- equal(find('.discussions-list li').length, 20, 'There are 20 discussions in the list');
-
- click('.control-loadMore').then(function() {
- equal(find('.discussions-list li').length, 40, 'There are 40 discussions in the list');
- });
- });
-});
-
-test('Discussion list sorting', function() {
- expect(1);
- visit('/').then(function() {
- fillIn('.control-sort select', 'replies').then(function() {
- var discussions = find('.discussions-list li');
- var good = true;
- var getCount = function(item) {
- return parseInt(item.find('.count strong').text());
- };
- var previousCount = getCount(discussions.eq(0));
- for (var i = 1; i < discussions.length; i++) {
- var count = getCount(discussions.eq(i));
- if (count > previousCount) {
- good = false;
- break;
- }
- previousCount = count;
- }
- ok(good, 'Discussions are listed in order of reply count');
- });
- });
-});
\ No newline at end of file
diff --git a/framework/core/ember/admin/tests/test-helper.js b/framework/core/ember/admin/tests/test-helper.js
deleted file mode 100644
index e6cfb70fe..000000000
--- a/framework/core/ember/admin/tests/test-helper.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import resolver from './helpers/resolver';
-import {
- setResolver
-} from 'ember-qunit';
-
-setResolver(resolver);
diff --git a/framework/core/ember/admin/tests/unit/.gitkeep b/framework/core/ember/admin/tests/unit/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/.bowerrc b/framework/core/ember/common/.bowerrc
deleted file mode 100644
index 959e1696e..000000000
--- a/framework/core/ember/common/.bowerrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "directory": "bower_components",
- "analytics": false
-}
diff --git a/framework/core/ember/common/.editorconfig b/framework/core/ember/common/.editorconfig
deleted file mode 100644
index 2fe4874a0..000000000
--- a/framework/core/ember/common/.editorconfig
+++ /dev/null
@@ -1,33 +0,0 @@
-# EditorConfig helps developers define and maintain consistent
-# coding styles between different editors and IDEs
-# editorconfig.org
-
-root = true
-
-
-[*]
-end_of_line = lf
-charset = utf-8
-trim_trailing_whitespace = true
-insert_final_newline = true
-indent_style = space
-indent_size = 2
-
-[*.js]
-indent_style = space
-indent_size = 2
-
-[*.hbs]
-indent_style = space
-indent_size = 2
-
-[*.css]
-indent_style = space
-indent_size = 2
-
-[*.html]
-indent_style = space
-indent_size = 2
-
-[*.{diff,md}]
-trim_trailing_whitespace = false
diff --git a/framework/core/ember/common/.gitignore b/framework/core/ember/common/.gitignore
deleted file mode 100644
index 86fceae7a..000000000
--- a/framework/core/ember/common/.gitignore
+++ /dev/null
@@ -1,17 +0,0 @@
-# See http://help.github.com/ignore-files/ for more about ignoring files.
-
-# compiled output
-/dist
-/tmp
-
-# dependencies
-/node_modules
-/bower_components
-
-# misc
-/.sass-cache
-/connect.lock
-/coverage/*
-/libpeerconnection.log
-npm-debug.log
-testem.log
diff --git a/framework/core/ember/common/.jshintrc b/framework/core/ember/common/.jshintrc
deleted file mode 100644
index 08096effa..000000000
--- a/framework/core/ember/common/.jshintrc
+++ /dev/null
@@ -1,32 +0,0 @@
-{
- "predef": [
- "document",
- "window",
- "-Promise"
- ],
- "browser": true,
- "boss": true,
- "curly": true,
- "debug": false,
- "devel": true,
- "eqeqeq": true,
- "evil": true,
- "forin": false,
- "immed": false,
- "laxbreak": false,
- "newcap": true,
- "noarg": true,
- "noempty": false,
- "nonew": false,
- "nomen": false,
- "onevar": false,
- "plusplus": false,
- "regexp": false,
- "undef": true,
- "sub": true,
- "strict": false,
- "white": false,
- "eqnull": true,
- "esnext": true,
- "unused": true
-}
diff --git a/framework/core/ember/common/.npmignore b/framework/core/ember/common/.npmignore
deleted file mode 100644
index 0533b9183..000000000
--- a/framework/core/ember/common/.npmignore
+++ /dev/null
@@ -1,12 +0,0 @@
-bower_components/
-tests/
-
-.bowerrc
-.editorconfig
-.ember-cli
-.travis.yml
-.npmignore
-**/.gitkeep
-bower.json
-Brocfile.js
-testem.json
diff --git a/framework/core/ember/common/.travis.yml b/framework/core/ember/common/.travis.yml
deleted file mode 100644
index cf23938b7..000000000
--- a/framework/core/ember/common/.travis.yml
+++ /dev/null
@@ -1,20 +0,0 @@
----
-language: node_js
-
-sudo: false
-
-cache:
- directories:
- - node_modules
-
-before_install:
- - "npm config set spin false"
- - "npm install -g npm@^2"
-
-install:
- - npm install -g bower
- - npm install
- - bower install
-
-script:
- - npm test
diff --git a/framework/core/ember/common/Brocfile.js b/framework/core/ember/common/Brocfile.js
deleted file mode 100644
index 042a64dd6..000000000
--- a/framework/core/ember/common/Brocfile.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/* jshint node: true */
-/* global require, module */
-
-var EmberAddon = require('ember-cli/lib/broccoli/ember-addon');
-
-var app = new EmberAddon();
-
-// Use `app.import` to add additional libraries to the generated
-// output files.
-//
-// If you need to use different assets in different
-// environments, specify an object as the first parameter. That
-// object's keys should be the environment name and the values
-// should be the asset to use in that environment.
-//
-// If the library that you are including contains AMD or ES6
-// modules that you would like to import into your application
-// please specify an object with the list of modules as keys
-// along with the exports of each module as its value.
-
-module.exports = app.toTree();
diff --git a/framework/core/ember/common/addon/.gitkeep b/framework/core/ember/common/addon/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/app/.gitkeep b/framework/core/ember/common/app/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/app/adapters/application.js b/framework/core/ember/common/app/adapters/application.js
deleted file mode 100644
index 589a91e46..000000000
--- a/framework/core/ember/common/app/adapters/application.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import DS from 'ember-data';
-import JsonApiAdapter from 'ember-json-api/json-api-adapter';
-
-import config from '../config/environment';
-import AlertMessage from '../components/ui/alert-message';
-
-export default JsonApiAdapter.extend({
- host: config.apiURL,
-
- pathForType: function(type) {
- if (type == 'activity') {
- return type;
- }
- return this._super(type);
- },
-
- 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;
- }
- 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;
- if (errors.status === 401) {
- message = 'You don\'t have permission to do this.';
- } else {
- message = errors.message;
- }
- var alert = AlertMessage.extend({
- type: 'warning',
- message: message
- });
- this.get('alerts').send('alert', alert);
- }
-
- return errors;
- }
-});
diff --git a/framework/core/ember/common/app/authenticators/flarum.js b/framework/core/ember/common/app/authenticators/flarum.js
deleted file mode 100644
index edec017f1..000000000
--- a/framework/core/ember/common/app/authenticators/flarum.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import Base from 'simple-auth/authenticators/base';
-import config from '../config/environment';
-
-export default Base.extend({
-
- authenticate: function(credentials) {
- var container = this.container;
- return new Ember.RSVP.Promise(function(resolve, reject) {
- Ember.$.ajax({
- url: config.baseURL+'login',
- type: 'POST',
- data: { identification: credentials.identification, password: credentials.password }
- }).then(function(response) {
- container.lookup('store:main').find('user', response.userId).then(function(user) {
- resolve({ token: response.token, userId: response.userId, user: user });
- });
- }, function(xhr, status, error) {
- reject(xhr.responseJSON.errors);
- });
- });
- },
-
- invalidate: function(data) {
- return new Ember.RSVP.Promise(function() {
- window.location = config.baseURL+'logout';
- });
- }
-});
diff --git a/framework/core/ember/common/app/authorizers/flarum.js b/framework/core/ember/common/app/authorizers/flarum.js
deleted file mode 100644
index 8cd3d01ec..000000000
--- a/framework/core/ember/common/app/authorizers/flarum.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import Base from 'simple-auth/authorizers/base';
-
-export default Base.extend({
- authorize: function(jqXHR, requestOptions) {
- var token = this.get('session.token');
- if (this.get('session.isAuthenticated') && !Ember.isEmpty(token)) {
- jqXHR.setRequestHeader('Authorization', 'Token ' + token);
- }
- }
-});
diff --git a/framework/core/ember/common/app/components/application/back-button.js b/framework/core/ember/common/app/components/application/back-button.js
deleted file mode 100755
index 5ecaa8090..000000000
--- a/framework/core/ember/common/app/components/application/back-button.js
+++ /dev/null
@@ -1,43 +0,0 @@
-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', 'className'],
-
- active: Ember.computed.or('target.paneIsShowing', 'target.paneIsPinned'),
-
- mouseEnter: function() {
- var target = this.get('target');
- if (target) {
- target.send('showPane');
- }
- },
-
- mouseLeave: function() {
- var target = this.get('target');
- if (target) {
- target.send('hidePane');
- }
- },
-
- actions: {
- // WE HAVE TO GO BACK. WAAAAAALLLLLLTTTTT
- back: function() {
- this.sendAction('goBack');
- },
-
- togglePinned: function() {
- var target = this.get('target');
- if (target) {
- target.send('togglePinned');
- }
- },
-
- toggleDrawer: function() {
- this.sendAction('toggleDrawer');
- }
- }
-});
diff --git a/framework/core/ember/common/app/components/ui/action-button.js b/framework/core/ember/common/app/components/ui/action-button.js
deleted file mode 100644
index 97be6707f..000000000
--- a/framework/core/ember/common/app/components/ui/action-button.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import Ember from 'ember';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-/**
- Button which sends an action when clicked.
- */
-export default Ember.Component.extend({
- tagName: 'a',
- attributeBindings: ['href', 'title'],
- classNameBindings: ['className'],
- href: '#',
- layout: precompileTemplate('{{#if icon}}{{fa-icon icon class="fa-fw icon-glyph"}} {{/if}}{{label}}'),
-
- label: '',
- icon: '',
- className: '',
- action: null,
-
- click: function(e) {
- e.preventDefault();
- var action = this.get('action');
- if (typeof action === 'string') {
- this.sendAction('action');
- } else if (typeof action === 'function') {
- action.call(this);
- }
- }
-});
diff --git a/framework/core/ember/common/app/components/ui/alert-message.js b/framework/core/ember/common/app/components/ui/alert-message.js
deleted file mode 100755
index 40a268243..000000000
--- a/framework/core/ember/common/app/components/ui/alert-message.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from '../../mixins/has-item-lists';
-import ActionButton from './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.extend({
- label: button.label,
- action: function() {
- component.send('dismiss');
- button.action();
- }
- }));
- });
-
- if (this.get('dismissable')) {
- var dismiss = ActionButton.extend({
- icon: 'times',
- className: 'btn btn-icon btn-link',
- action: function() { component.send('dismiss'); }
- });
- controls.pushObjectWithTag(dismiss, 'dismiss');
- }
- },
-
- actions: {
- dismiss: function() {
- this.sendAction('dismiss', this);
- }
- }
-});
diff --git a/framework/core/ember/common/app/components/ui/badge-button.js b/framework/core/ember/common/app/components/ui/badge-button.js
deleted file mode 100644
index b70920cc4..000000000
--- a/framework/core/ember/common/app/components/ui/badge-button.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import ActionButton from './action-button';
-
-export default ActionButton.extend({
- tagName: 'span',
- classNames: ['badge'],
- title: Ember.computed.alias('label'),
-
- didInsertElement: function() {
- this.$().tooltip();
- }
-});
diff --git a/framework/core/ember/common/app/components/ui/dropdown-button.js b/framework/core/ember/common/app/components/ui/dropdown-button.js
deleted file mode 100644
index 7afbb73a9..000000000
--- a/framework/core/ember/common/app/components/ui/dropdown-button.js
+++ /dev/null
@@ -1,31 +0,0 @@
-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() {
- var count = this.get('items.length');
- return count ? 'item-count-'+this.get('items.length') : '';
- }),
-
- actions: {
- buttonClick: function() {
- this.sendAction('buttonClick');
- }
- }
-});
diff --git a/framework/core/ember/common/app/components/ui/dropdown-select.js b/framework/core/ember/common/app/components/ui/dropdown-select.js
deleted file mode 100644
index b7012e92a..000000000
--- a/framework/core/ember/common/app/components/ui/dropdown-select.js
+++ /dev/null
@@ -1,32 +0,0 @@
-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', 'className'],
-
- buttonClass: 'btn btn-default',
- menuClass: '',
- icon: 'ellipsis-v',
- items: [],
-
- mainButtonClass: Ember.computed('buttonClass', function() {
- return 'btn '+this.get('buttonClass');
- }),
-
- dropdownMenuClass: Ember.computed('menuClass', function() {
- return 'dropdown-menu '+this.get('menuClass');
- }),
-
- itemCountClass: Ember.computed('items.length', function() {
- return 'item-count-'+this.get('items.length');
- }),
-
- activeItem: Ember.computed('menu.childViews.@each.active', function() {
- return this.get('menu.childViews').findBy('active');
- })
-});
diff --git a/framework/core/ember/common/app/components/ui/dropdown-split.js b/framework/core/ember/common/app/components/ui/dropdown-split.js
deleted file mode 100644
index 5fcc43690..000000000
--- a/framework/core/ember/common/app/components/ui/dropdown-split.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Ember from 'ember';
-
-import DropdownButton from './dropdown-button';
-
-/**
- Given a list of items, this component displays a split button: the left side
- is the first item in the list, while the right side is a dropdown-toggle
- which shows a dropdown menu containing all of the items.
- */
-export default DropdownButton.extend({
- layoutName: 'components/ui/dropdown-split',
- classNames: ['dropdown', 'dropdown-split', 'btn-group'],
- menuClass: 'pull-right',
-
- mainButtonClass: Ember.computed('buttonClass', function() {
- return 'btn '+this.get('buttonClass');
- }),
-
- firstItem: Ember.computed('items.[]', function() {
- return this.get('items').objectAt(0);
- })
-});
diff --git a/framework/core/ember/common/app/components/ui/field-set.js b/framework/core/ember/common/app/components/ui/field-set.js
deleted file mode 100644
index f12ddb4f1..000000000
--- a/framework/core/ember/common/app/components/ui/field-set.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import Ember from 'ember';
-
-/**
- A set of fields with a heading.
- */
-export default Ember.Component.extend({
- layoutName: 'components/ui/field-set',
- tagName: 'fieldset',
- classNameBindings: ['className'],
-
- label: '',
- fields: []
-});
diff --git a/framework/core/ember/common/app/components/ui/item-list.js b/framework/core/ember/common/app/components/ui/item-list.js
deleted file mode 100644
index 10ecd4525..000000000
--- a/framework/core/ember/common/app/components/ui/item-list.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import Ember from 'ember';
-
-/**
- Output a list of components within a , making sure each one is contained
- in an - 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 [];
- }
- var instances = [];
- items.forEach(function(item) {
- item = item.create();
- item.set('isListItem', item.constructor.proto().tagName === 'li');
- instances.pushObject(item);
- });
- return instances;
- })
-});
diff --git a/framework/core/ember/common/app/components/ui/loading-indicator.js b/framework/core/ember/common/app/components/ui/loading-indicator.js
deleted file mode 100644
index 6f54c92e4..000000000
--- a/framework/core/ember/common/app/components/ui/loading-indicator.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import Ember from 'ember';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-/**
- Loading spinner.
- */
-export default Ember.Component.extend({
- classNames: ['loading-indicator'],
-
- layout: precompileTemplate(' '),
- size: 'small',
-
- didInsertElement: function() {
- var size = this.get('size');
- Ember.$.fn.spin.presets[size].zIndex = 'auto';
- this.$().spin(size);
- }
-});
diff --git a/framework/core/ember/common/app/components/ui/nav-item.js b/framework/core/ember/common/app/components/ui/nav-item.js
deleted file mode 100644
index 5504be97c..000000000
--- a/framework/core/ember/common/app/components/ui/nav-item.js
+++ /dev/null
@@ -1,22 +0,0 @@
-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}} {{badge}}{{/link-to}}'),
- tagName: 'li',
- classNameBindings: ['active'],
-
- icon: '',
- label: '',
- badge: '',
- routeName: '',
-
- active: Ember.computed('childViews.@each.active', function() {
- return !!this.get('childViews').anyBy('active');
- })
-});
diff --git a/framework/core/ember/common/app/components/ui/search-input.js b/framework/core/ember/common/app/components/ui/search-input.js
deleted file mode 100644
index 3b676da87..000000000
--- a/framework/core/ember/common/app/components/ui/search-input.js
+++ /dev/null
@@ -1,36 +0,0 @@
-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'));
- }
- }
-});
diff --git a/framework/core/ember/common/app/components/ui/select-input.js b/framework/core/ember/common/app/components/ui/select-input.js
deleted file mode 100644
index bb90891f9..000000000
--- a/framework/core/ember/common/app/components/ui/select-input.js
+++ /dev/null
@@ -1,16 +0,0 @@
-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'
-});
diff --git a/framework/core/ember/common/app/components/ui/separator-item.js b/framework/core/ember/common/app/components/ui/separator-item.js
deleted file mode 100644
index cf927b883..000000000
--- a/framework/core/ember/common/app/components/ui/separator-item.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import Ember from 'ember';
-
-/**
- A simple separator list item for use in menus.
- */
-export default Ember.Component.extend({
- tagName: 'li',
- classNames: ['divider']
-});
diff --git a/framework/core/ember/common/app/components/ui/switch-input.js b/framework/core/ember/common/app/components/ui/switch-input.js
deleted file mode 100644
index 278eae4c3..000000000
--- a/framework/core/ember/common/app/components/ui/switch-input.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import Ember from 'ember';
-
-/**
- A toggle switch.
- */
-export default Ember.Component.extend({
- layoutName: 'components/ui/switch-input',
- classNames: ['checkbox', 'checkbox-switch'],
-
- label: '',
- toggleState: true,
-
- didInsertElement: function() {
- var component = this;
- this.$('input').on('change', function() {
- component.get('changed')($(this).prop('checked'), component);
- });
- }
-});
diff --git a/framework/core/ember/common/app/components/ui/text-editor.js b/framework/core/ember/common/app/components/ui/text-editor.js
deleted file mode 100644
index e61e737f1..000000000
--- a/framework/core/ember/common/app/components/ui/text-editor.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from '../../mixins/has-item-lists';
-import ActionButton from './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'), 'check').reopen({className: 'btn btn-primary', listItemClass: 'primary-control'});
- },
-
- actions: {
- submit: function() {
- this.sendAction('submit', this.get('value'));
- }
- }
-});
diff --git a/framework/core/ember/common/app/components/ui/text-input.js b/framework/core/ember/common/app/components/ui/text-input.js
deleted file mode 100644
index b050c71c5..000000000
--- a/framework/core/ember/common/app/components/ui/text-input.js
+++ /dev/null
@@ -1,26 +0,0 @@
-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('');
- }
- });
- }
- }
-});
diff --git a/framework/core/ember/common/app/components/ui/yesno-input.js b/framework/core/ember/common/app/components/ui/yesno-input.js
deleted file mode 100644
index 88e16897c..000000000
--- a/framework/core/ember/common/app/components/ui/yesno-input.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Ember from 'ember';
-
-/**
- A toggle switch.
- */
-export default Ember.Component.extend({
- layoutName: 'components/ui/yesno-input',
- tagName: 'label',
- classNames: ['yesno-control'],
-
- toggleState: true,
- disabled: false,
-
- didInsertElement: function() {
- var component = this;
- this.$('input').on('change', function() {
- component.get('changed')($(this).prop('checked'), component);
- });
- }
-});
diff --git a/framework/core/ember/common/app/controllers/alerts.js b/framework/core/ember/common/app/controllers/alerts.js
deleted file mode 100644
index 562f165ae..000000000
--- a/framework/core/ember/common/app/controllers/alerts.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Controller.extend({
- alerts: [],
-
- actions: {
- alert: function(message) {
- this.get('alerts').pushObject(message);
- },
- dismissAlert: function(message) {
- this.get('alerts').removeObject(message.constructor);
- },
- clearAlerts: function() {
- this.get('alerts').clear();
- }
- }
-});
diff --git a/framework/core/ember/common/app/helpers/.gitkeep b/framework/core/ember/common/app/helpers/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/app/helpers/abbreviate-number.js b/framework/core/ember/common/app/helpers/abbreviate-number.js
deleted file mode 100644
index 09f0a1215..000000000
--- a/framework/core/ember/common/app/helpers/abbreviate-number.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Handlebars.makeBoundHelper(function(number) {
- return new Ember.Handlebars.SafeString(''+number);
-});
-
diff --git a/framework/core/ember/common/app/helpers/fa-icon.js b/framework/core/ember/common/app/helpers/fa-icon.js
deleted file mode 100644
index 3ca42f355..000000000
--- a/framework/core/ember/common/app/helpers/fa-icon.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Handlebars.makeBoundHelper(function(icon, options) {
- return new Ember.Handlebars.SafeString('');
-});
-
diff --git a/framework/core/ember/common/app/helpers/full-time.js b/framework/core/ember/common/app/helpers/full-time.js
deleted file mode 100644
index b231e32ec..000000000
--- a/framework/core/ember/common/app/helpers/full-time.js
+++ /dev/null
@@ -1,9 +0,0 @@
-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('');
-});
diff --git a/framework/core/ember/common/app/helpers/highlight-words.js b/framework/core/ember/common/app/helpers/highlight-words.js
deleted file mode 100644
index 1942fa9fd..000000000
--- a/framework/core/ember/common/app/helpers/highlight-words.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Handlebars.makeBoundHelper(function(text, phrase) {
- if (phrase) {
- var words = phrase.split(' ');
- var replacement = function(matched) {
- return ''+matched+'';
- };
- words.forEach(function(word) {
- text = text.replace(
- new RegExp("\\b"+word+"\\b", 'gi'),
- replacement
- );
- });
- }
- return new Ember.Handlebars.SafeString(text);
-});
diff --git a/framework/core/ember/common/app/helpers/human-time.js b/framework/core/ember/common/app/helpers/human-time.js
deleted file mode 100644
index 919ebe257..000000000
--- a/framework/core/ember/common/app/helpers/human-time.js
+++ /dev/null
@@ -1,13 +0,0 @@
-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 ago = humanTime(m);
-
- return new Ember.Handlebars.SafeString('');
-});
diff --git a/framework/core/ember/common/app/helpers/user-avatar.js b/framework/core/ember/common/app/helpers/user-avatar.js
deleted file mode 100644
index b65af43ee..000000000
--- a/framework/core/ember/common/app/helpers/user-avatar.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Handlebars.makeBoundHelper(function(user, options) {
- var attributes = 'class="avatar '+(options.hash.class || '')+'"';
- var content = '';
-
- if (user) {
- var username = user.get('username') || '?';
-
- if (typeof options.hash.title === 'undefined') {
- options.hash.title = Ember.Handlebars.Utils.escapeExpression(username);
- }
- attributes += ' title="'+options.hash.title+'"';
-
- var avatarUrl = user.get('avatarUrl');
- if (avatarUrl) {
- return new Ember.Handlebars.SafeString('');
- }
-
- content = username.charAt(0).toUpperCase();
- attributes += ' style="background:'+user.get('color')+'"';
- }
-
- return new Ember.Handlebars.SafeString(''+content+'');
-}, 'avatarUrl', 'username', 'color');
-
diff --git a/framework/core/ember/common/app/helpers/user-name.js b/framework/core/ember/common/app/helpers/user-name.js
deleted file mode 100644
index 7f426749e..000000000
--- a/framework/core/ember/common/app/helpers/user-name.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Handlebars.makeBoundHelper(function(user, options) {
- var username;
- if (user) {
- username = user.get('username');
- }
- username = username || '[deleted]';
-
- return new Ember.Handlebars.SafeString(''+Ember.Handlebars.Utils.escapeExpression(username)+'');
-});
-
diff --git a/framework/core/ember/common/app/initializers/authentication.js b/framework/core/ember/common/app/initializers/authentication.js
deleted file mode 100644
index fc257a003..000000000
--- a/framework/core/ember/common/app/initializers/authentication.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import FlarumAuthorizer from '../authorizers/flarum';
-import Config from '../config/environment';
-
-export default {
- name: 'authentication',
- before: 'simple-auth',
- initialize: function(container) {
- container.register('authorizer:flarum', FlarumAuthorizer);
- Config['simple-auth'] = {authorizer: 'authorizer:flarum'};
- }
-};
diff --git a/framework/core/ember/common/app/initializers/find-query-one.js b/framework/core/ember/common/app/initializers/find-query-one.js
deleted file mode 100644
index 6e9adc934..000000000
--- a/framework/core/ember/common/app/initializers/find-query-one.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import DS from 'ember-data';
-
-// This can be removed when
-// https://github.com/emberjs/data/pull/2584 is implemented.
-
-export default {
- name: 'find-query-one',
- initialize: function(container) {
- DS.Store.reopen({
- findQueryOne: function(type, id, query) {
- var store = this;
- var typeClass = store.modelFor(type);
- var adapter = store.adapterFor(typeClass);
- var serializer = store.serializerFor(typeClass);
- var url = adapter.buildURL(type, id);
- var ajaxPromise = adapter.ajax(url, 'GET', { data: query });
-
- return ajaxPromise.then(function(rawPayload) {
- var extractedPayload = serializer.extract(store, typeClass, rawPayload, id, 'find');
- return store.push(typeClass, extractedPayload);
- });
- }
- });
- }
-};
\ No newline at end of file
diff --git a/framework/core/ember/common/app/initializers/human-time-updater.js b/framework/core/ember/common/app/initializers/human-time-updater.js
deleted file mode 100644
index 3b32e0371..000000000
--- a/framework/core/ember/common/app/initializers/human-time-updater.js
+++ /dev/null
@@ -1,147 +0,0 @@
-import Ember from 'ember';
-
-import humanTime from '../utils/human-time';
-
-var $ = Ember.$;
-
-export default {
- name: 'human-time-updater',
- initialize: function(container) {
-
- // Livestamp.js / v1.1.2 / (c) 2012 Matt Bradley / MIT License
- // @todo rewrite this to be simpler and cleaner
- (function($, moment) {
- var updateInterval = 1e3,
- paused = false,
- $livestamps = $([]),
-
- init = function() {
- livestampGlobal.resume();
- },
-
- prep = function($el, timestamp) {
- var oldData = $el.data('livestampdata');
- if (typeof timestamp == 'number')
- timestamp *= 1e3;
-
- $el.removeAttr('data-livestamp')
- .removeData('livestamp');
-
- timestamp = moment(timestamp);
- if (timestamp.diff(moment(new Date())) < 60 * 60) {
- return;
- }
- if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
- var newData = $.extend({ }, { 'original': $el.contents() }, oldData);
- newData.moment = moment(timestamp);
-
- $el.data('livestampdata', newData).empty();
- $livestamps.push($el[0]);
- }
- },
-
- run = function() {
- if (paused) return;
- livestampGlobal.update();
- setTimeout(run, updateInterval);
- },
-
- livestampGlobal = {
- update: function() {
- $('[data-humantime]').each(function() {
- var $this = $(this);
- prep($this, $this.attr('datetime'));
- });
-
- var toRemove = [];
- $livestamps.each(function() {
- var $this = $(this),
- data = $this.data('livestampdata');
-
- if (data === undefined)
- toRemove.push(this);
- else if (moment.isMoment(data.moment)) {
- var from = $this.html(),
- to = humanTime(data.moment);
- // to = data.moment.fromNow();
-
- if (from != to) {
- var e = $.Event('change.livestamp');
- $this.trigger(e, [from, to]);
- if (!e.isDefaultPrevented())
- $this.html(to);
- }
- }
- });
-
- $livestamps = $livestamps.not(toRemove);
- },
-
- pause: function() {
- paused = true;
- },
-
- resume: function() {
- paused = false;
- run();
- },
-
- interval: function(interval) {
- if (interval === undefined)
- return updateInterval;
- updateInterval = interval;
- }
- },
-
- livestampLocal = {
- add: function($el, timestamp) {
- if (typeof timestamp == 'number')
- timestamp *= 1e3;
- timestamp = moment(timestamp);
-
- if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
- $el.each(function() {
- prep($(this), timestamp);
- });
- livestampGlobal.update();
- }
-
- return $el;
- },
-
- destroy: function($el) {
- $livestamps = $livestamps.not($el);
- $el.each(function() {
- var $this = $(this),
- data = $this.data('livestampdata');
-
- if (data === undefined)
- return $el;
-
- $this
- .html(data.original ? data.original : '')
- .removeData('livestampdata');
- });
-
- return $el;
- },
-
- isLivestamp: function($el) {
- return $el.data('livestampdata') !== undefined;
- }
- };
-
- $.livestamp = livestampGlobal;
- $(init);
- $.fn.livestamp = function(method, options) {
- if (!livestampLocal[method]) {
- options = method;
- method = 'add';
- }
-
- return livestampLocal[method](this, options);
- };
- })(jQuery, moment);
-
- }
-};
diff --git a/framework/core/ember/common/app/initializers/inject-components.js b/framework/core/ember/common/app/initializers/inject-components.js
deleted file mode 100644
index 87946d15c..000000000
--- a/framework/core/ember/common/app/initializers/inject-components.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export default {
- name: 'inject-components',
- initialize: function(container, application) {
- application.inject('adapter', 'alerts', 'controller:alerts')
- application.inject('component', 'alerts', 'controller:alerts')
- application.inject('model', 'session', 'simple-auth-session:main')
- application.inject('component', 'session', 'simple-auth-session:main')
- application.inject('component', 'store', 'store:main')
- }
-};
diff --git a/framework/core/ember/common/app/initializers/preload-data.js b/framework/core/ember/common/app/initializers/preload-data.js
deleted file mode 100644
index 5c18048d6..000000000
--- a/framework/core/ember/common/app/initializers/preload-data.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Ember from 'ember';
-
-export default {
- name: 'preload-data',
- after: 'ember-data',
- initialize: function(container) {
- var store = container.lookup('store:main');
- if (!Ember.isEmpty(FLARUM_DATA)) {
- store.pushPayload({included: FLARUM_DATA});
- }
- if (!Ember.isEmpty(FLARUM_SESSION)) {
- FLARUM_SESSION.user = store.getById('user', FLARUM_SESSION.userId);
- container.lookup('simple-auth-session:main').setProperties({
- isAuthenticated: true,
- authenticator: 'authenticator:flarum',
- content: FLARUM_SESSION
- });
- }
- }
-};
diff --git a/framework/core/ember/common/app/mixins/add-css-class-to-body.js b/framework/core/ember/common/app/mixins/add-css-class-to-body.js
deleted file mode 100644
index 30d2fa4ca..000000000
--- a/framework/core/ember/common/app/mixins/add-css-class-to-body.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Mixin.create({
- activate: function() {
- var cssClass = this.toCssClass();
- Ember.$('body').addClass(cssClass);
- },
-
- deactivate: function() {
- Ember.$('body').removeClass(this.toCssClass());
- },
-
- toCssClass: function() {
- return this.routeName.replace(/\./g, '-').dasherize();
- }
-});
\ No newline at end of file
diff --git a/framework/core/ember/common/app/mixins/fade-in.js b/framework/core/ember/common/app/mixins/fade-in.js
deleted file mode 100644
index 269103aeb..000000000
--- a/framework/core/ember/common/app/mixins/fade-in.js
+++ /dev/null
@@ -1,14 +0,0 @@
-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);
- })
-});
diff --git a/framework/core/ember/common/app/mixins/has-item-lists.js b/framework/core/ember/common/app/mixins/has-item-lists.js
deleted file mode 100644
index e45b23a2e..000000000
--- a/framework/core/ember/common/app/mixins/has-item-lists.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import Ember from 'ember';
-
-import TaggedArray from '../utils/tagged-array';
-import ActionButton from '../components/ui/action-button';
-import SeparatorItem from '../components/ui/separator-item';
-
-export default Ember.Mixin.create({
- itemLists: [],
-
- initItemLists: Ember.on('init', 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);
- this.removeUnneededSeparatorItems(items);
- return items;
- },
-
- addActionItem: function(items, tag, label, icon, conditionProperty, action) {
- if (conditionProperty && !this.get(conditionProperty)) { return; }
-
- var self = this;
- var item = ActionButton.extend({
- label: label,
- icon: icon,
- action: action || function() {
- self.get('controller').send(tag);
- }
- });
-
- items.pushObjectWithTag(item, tag);
-
- return item;
- },
-
- addSeparatorItem: function(items) {
- items.pushObject(SeparatorItem);
- },
-
- removeUnneededSeparatorItems: function(items) {
- var prevItem = null;
- items.forEach(function(item) {
- if (prevItem === SeparatorItem && item === SeparatorItem) {
- items.removeObject(item);
- return;
- }
- prevItem = item;
- });
- if (prevItem === SeparatorItem) {
- items.removeObject(prevItem);
- }
- }
-});
diff --git a/framework/core/ember/common/app/mixins/modal-controller.js b/framework/core/ember/common/app/mixins/modal-controller.js
deleted file mode 100644
index 7291580fe..000000000
--- a/framework/core/ember/common/app/mixins/modal-controller.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Mixin.create(Ember.Evented, {
- actions: {
- focus: function() {
- this.trigger('focus');
- }
- }
-});
diff --git a/framework/core/ember/common/app/mixins/modal-view.js b/framework/core/ember/common/app/mixins/modal-view.js
deleted file mode 100644
index 603ad58c2..000000000
--- a/framework/core/ember/common/app/mixins/modal-view.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Mixin.create({
- focusEventOn: Ember.on('didInsertElement', function() {
- this.get('controller').on('focus', this, this.focus);
- }),
-
- focusEventOff: Ember.on('willDestroyElement', function() {
- this.get('controller').off('focus', this, this.focus);
- }),
-
- focus: Ember.on('didInsertElement', function() {
- this.$('input:first:visible:enabled').focus();
- })
-});
diff --git a/framework/core/ember/common/app/models/.gitkeep b/framework/core/ember/common/app/models/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/app/models/activity.js b/framework/core/ember/common/app/models/activity.js
deleted file mode 100644
index 671c85f24..000000000
--- a/framework/core/ember/common/app/models/activity.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import DS from 'ember-data';
-
-export default DS.Model.extend({
- contentType: DS.attr('string'),
- content: DS.attr(),
- time: DS.attr('date'),
-
- user: DS.belongsTo('user'),
- sender: DS.belongsTo('user'),
- post: DS.belongsTo('post')
-});
diff --git a/framework/core/ember/common/app/models/discussion-result.js b/framework/core/ember/common/app/models/discussion-result.js
deleted file mode 100644
index 46a6711af..000000000
--- a/framework/core/ember/common/app/models/discussion-result.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.ObjectProxy.extend({
- relevantPosts: null,
- startPost: null,
- lastPost: null
-});
diff --git a/framework/core/ember/common/app/models/discussion.js b/framework/core/ember/common/app/models/discussion.js
deleted file mode 100644
index c1f292320..000000000
--- a/framework/core/ember/common/app/models/discussion.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import Ember from 'ember';
-import DS from 'ember-data';
-
-import HasItemLists from '../mixins/has-item-lists';
-import Subject from './subject';
-
-export default Subject.extend(HasItemLists, {
- /**
- Define a "badges" item list. Example usage:
- ```
- populateBadges: function(items) {
- items.pushObjectWithTag(BadgeButton.extend({
- label: 'Sticky',
- icon: 'thumb-tack',
- className: 'badge-sticky',
- discussion: this,
- isHiddenInList: Ember.computed.not('discussion.sticky')
- }), 'sticky');
- }
- ```
- */
- itemLists: ['badges'],
-
- title: DS.attr('string'),
- slug: Ember.computed('title', function() {
- return this.get('title').toLowerCase().replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').replace(/-$|^-/g, '');
- }),
-
- startTime: DS.attr('date'),
- startUser: DS.belongsTo('user'),
- startPost: DS.belongsTo('post'),
-
- lastTime: DS.attr('date'),
- lastUser: DS.belongsTo('user'),
- lastPost: DS.belongsTo('post'),
- lastPostNumber: DS.attr('number'),
-
- canReply: DS.attr('boolean'),
- canEdit: DS.attr('boolean'),
- canDelete: DS.attr('boolean'),
-
- commentsCount: DS.attr('number'),
- repliesCount: Ember.computed('commentsCount', function() {
- return Math.max(0, this.get('commentsCount') - 1);
- }),
-
- // 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'),
- posts: DS.hasMany('post', {async: true}),
- postIds: Ember.computed(function() {
- var ids = [];
- this.get('data.posts').forEach(function(post) {
- ids.push(post.id);
- });
- return ids;
- }),
- loadedPosts: DS.hasMany('post'),
- relevantPosts: DS.hasMany('post'),
- addedPosts: DS.hasMany('post'),
-
- 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'),
-
- // Only used to save a new discussion
- content: DS.attr('string')
-});
diff --git a/framework/core/ember/common/app/models/group.js b/framework/core/ember/common/app/models/group.js
deleted file mode 100644
index c03e313aa..000000000
--- a/framework/core/ember/common/app/models/group.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import DS from 'ember-data';
-
-export default DS.Model.extend({
- name: DS.attr('string'),
- users: DS.hasMany('group'),
-});
diff --git a/framework/core/ember/common/app/models/notification.js b/framework/core/ember/common/app/models/notification.js
deleted file mode 100644
index 98f04f317..000000000
--- a/framework/core/ember/common/app/models/notification.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import DS from 'ember-data';
-
-export default DS.Model.extend({
- contentType: DS.attr('string'),
- subjectId: DS.attr('number'),
- content: DS.attr(),
- time: DS.attr('date'),
- isRead: DS.attr('boolean'),
- unreadCount: DS.attr('number'),
- additionalUnreadCount: Ember.computed('unreadCount', function() {
- return Math.max(0, this.get('unreadCount') - 1);
- }),
-
- decodedContent: Ember.computed('content', function() {
- return JSON.parse(this.get('content'));
- }),
-
- user: DS.belongsTo('user'),
- sender: DS.belongsTo('user'),
- subject: DS.belongsTo('subject', {polymorphic: true})
-});
diff --git a/framework/core/ember/common/app/models/post-result.js b/framework/core/ember/common/app/models/post-result.js
deleted file mode 100644
index 1800ef7bf..000000000
--- a/framework/core/ember/common/app/models/post-result.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Ember from 'ember';
-
-var PostResult = Ember.ObjectProxy.extend({
-
- relevantContent: ''
-
-});
-
-PostResult.reopenClass({
- create: function(post) {
- if (!post) {
- return null;
- }
-
- var result = this._super();
- result.set('content', post);
- result.set('relevantContent', post.get('content'));
- return result;
- }
-});
-
-export default PostResult;
diff --git a/framework/core/ember/common/app/models/post-stream.js b/framework/core/ember/common/app/models/post-stream.js
deleted file mode 100644
index 32677fca0..000000000
--- a/framework/core/ember/common/app/models/post-stream.js
+++ /dev/null
@@ -1,209 +0,0 @@
-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. ...
- */
-export default Ember.ArrayProxy.extend(Ember.Evented, {
-
- // An array of all of the post IDs, in chronological order, in the discussion.
- ids: null,
-
- content: null,
-
- store: null,
- discussion: null,
-
- postLoadCount: 20,
-
- count: Ember.computed.alias('ids.length'),
-
- loadedCount: Ember.computed('content.@each', function() {
- return this.get('content').filterBy('content').length;
- }),
-
- firstLoaded: Ember.computed('content.@each', function() {
- var first = this.objectAt(0);
- return first && first.content;
- }),
-
- 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();
- },
-
- 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);
- },
-
- 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');
- }
- });
-
- // 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);
- },
-
- 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));
- },
-
- removePost: function(post) {
- this.get('ids').removeObject(post.get('id'));
- var content = this.get('content');
- content.removeObject(content.findBy('content', 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('contentType')
- });
- }
- 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');
- }
-});
diff --git a/framework/core/ember/common/app/models/post.js b/framework/core/ember/common/app/models/post.js
deleted file mode 100644
index f91677257..000000000
--- a/framework/core/ember/common/app/models/post.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Ember from 'ember';
-import DS from 'ember-data';
-import Subject from './subject';
-
-export default Subject.extend({
- discussion: DS.belongsTo('discussion', {inverse: 'loadedPosts'}),
- number: DS.attr('number'),
-
- time: DS.attr('date'),
- user: DS.belongsTo('user'),
- contentType: DS.attr('string'),
- content: DS.attr(),
- contentHtml: DS.attr('string'),
-
- editTime: DS.attr('date'),
- editUser: DS.belongsTo('user'),
- isEdited: Ember.computed.notEmpty('editTime'),
-
- hideTime: DS.attr('date'),
- hideUser: DS.belongsTo('user'),
- isHidden: DS.attr('boolean'),
-
- canEdit: DS.attr('boolean'),
- canDelete: DS.attr('boolean')
-});
diff --git a/framework/core/ember/common/app/models/subject.js b/framework/core/ember/common/app/models/subject.js
deleted file mode 100644
index d7b7b0561..000000000
--- a/framework/core/ember/common/app/models/subject.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import DS from 'ember-data';
-
-export default DS.Model.extend({
- notification: DS.belongsTo('notification')
-});
diff --git a/framework/core/ember/common/app/models/user.js b/framework/core/ember/common/app/models/user.js
deleted file mode 100644
index 3885f9551..000000000
--- a/framework/core/ember/common/app/models/user.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import DS from 'ember-data';
-
-import HasItemLists from '../mixins/has-item-lists';
-import stringToColor from '../utils/string-to-color';
-
-export default DS.Model.extend(HasItemLists, {
- itemLists: ['badges'],
-
- username: DS.attr('string'),
- email: DS.attr('string'),
- password: DS.attr('string'),
- avatarUrl: DS.attr('string'),
- bio: DS.attr('string'),
- bioHtml: DS.attr('string'),
- preferences: DS.attr(),
-
- groups: DS.hasMany('group'),
-
- joinTime: DS.attr('date'),
- lastSeenTime: DS.attr('date'),
- online: Ember.computed('lastSeenTime', function() {
- return this.get('lastSeenTime') > moment().subtract(5, 'minutes').toDate();
- }),
- readTime: DS.attr('date'),
- unreadNotificationsCount: DS.attr('number'),
-
- discussionsCount: DS.attr('number'),
- commentsCount: DS.attr('number'),
-
- canEdit: DS.attr('boolean'),
- canDelete: DS.attr('boolean'),
-
- color: Ember.computed('username', function() {
- return '#'+stringToColor(this.get('username'));
- })
-});
diff --git a/framework/core/ember/common/app/serializers/application.js b/framework/core/ember/common/app/serializers/application.js
deleted file mode 100644
index 81deb0401..000000000
--- a/framework/core/ember/common/app/serializers/application.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import JsonApiSerializer from 'ember-json-api/json-api-serializer';
-
-export default JsonApiSerializer.extend({
- store: Ember.inject.service()
-});
diff --git a/framework/core/ember/common/app/serializers/post.js b/framework/core/ember/common/app/serializers/post.js
deleted file mode 100644
index 661069c42..000000000
--- a/framework/core/ember/common/app/serializers/post.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import ApplicationSerializer from '../serializers/application';
-
-export default ApplicationSerializer.extend({
- attrs: {
- number: {serialize: false},
- time: {serialize: false},
- type: {serialize: false},
- contentHtml: {serialize: false},
- editTime: {serialize: false},
- editUser: {serialize: false},
- hideTime: {serialize: false},
- hideUser: {serialize: false},
- canEdit: {serialize: false},
- canDelete: {serialize: false}
- }
-});
diff --git a/framework/core/ember/common/app/templates/alerts.hbs b/framework/core/ember/common/app/templates/alerts.hbs
deleted file mode 100644
index 1821af7cd..000000000
--- a/framework/core/ember/common/app/templates/alerts.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-
- {{#each alert in alerts}}
-
- {{view alert dismiss="dismissAlert"}}
-
- {{/each}}
-
diff --git a/framework/core/ember/common/app/templates/components/application/back-button.hbs b/framework/core/ember/common/app/templates/components/application/back-button.hbs
deleted file mode 100644
index 63e5bf683..000000000
--- a/framework/core/ember/common/app/templates/components/application/back-button.hbs
+++ /dev/null
@@ -1,10 +0,0 @@
-{{#if canGoBack}}
-
-
- {{#if target.paned}}
-
- {{/if}}
-
-{{else if toggleDrawer}}
-
-{{/if}}
diff --git a/framework/core/ember/common/app/templates/components/application/user-dropdown.hbs b/framework/core/ember/common/app/templates/components/application/user-dropdown.hbs
deleted file mode 100644
index 0f14f66a0..000000000
--- a/framework/core/ember/common/app/templates/components/application/user-dropdown.hbs
+++ /dev/null
@@ -1,5 +0,0 @@
-
- {{user-avatar user}}
- {{label}}
-
-{{ui/item-list items=items class=dropdownMenuClass}}
diff --git a/framework/core/ember/common/app/templates/components/ui/alert-message.hbs b/framework/core/ember/common/app/templates/components/ui/alert-message.hbs
deleted file mode 100644
index 4ff865d54..000000000
--- a/framework/core/ember/common/app/templates/components/ui/alert-message.hbs
+++ /dev/null
@@ -1,2 +0,0 @@
-{{message}}
-{{ui/item-list items=controls class="alert-controls"}}
diff --git a/framework/core/ember/common/app/templates/components/ui/dropdown-button.hbs b/framework/core/ember/common/app/templates/components/ui/dropdown-button.hbs
deleted file mode 100644
index 36a94dfd0..000000000
--- a/framework/core/ember/common/app/templates/components/ui/dropdown-button.hbs
+++ /dev/null
@@ -1,6 +0,0 @@
-
- {{fa-icon icon class="icon-glyph"}}
- {{label}}
- {{fa-icon "caret-down" class="icon-caret"}}
-
-{{ui/item-list items=items class=dropdownMenuClass}}
diff --git a/framework/core/ember/common/app/templates/components/ui/dropdown-select.hbs b/framework/core/ember/common/app/templates/components/ui/dropdown-select.hbs
deleted file mode 100644
index 9aec7f42c..000000000
--- a/framework/core/ember/common/app/templates/components/ui/dropdown-select.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-{{#if items}}
-
- {{activeItem.label}}
- {{fa-icon "sort" class="icon-caret"}}
-
- {{ui/item-list items=items class=dropdownMenuClass viewName="menu"}}
-{{/if}}
diff --git a/framework/core/ember/common/app/templates/components/ui/dropdown-split.hbs b/framework/core/ember/common/app/templates/components/ui/dropdown-split.hbs
deleted file mode 100644
index ab8f82e91..000000000
--- a/framework/core/ember/common/app/templates/components/ui/dropdown-split.hbs
+++ /dev/null
@@ -1,8 +0,0 @@
-{{#if items}}
- {{view firstItem className=mainButtonClass}}
-
- {{ui/item-list items=items class=dropdownMenuClass}}
-{{/if}}
diff --git a/framework/core/ember/common/app/templates/components/ui/field-set.hbs b/framework/core/ember/common/app/templates/components/ui/field-set.hbs
deleted file mode 100644
index d4e10daa0..000000000
--- a/framework/core/ember/common/app/templates/components/ui/field-set.hbs
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-{{ui/item-list items=fields}}
diff --git a/framework/core/ember/common/app/templates/components/ui/item-list.hbs b/framework/core/ember/common/app/templates/components/ui/item-list.hbs
deleted file mode 100644
index 039cbdbe5..000000000
--- a/framework/core/ember/common/app/templates/components/ui/item-list.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-{{#each item in listItems}}
- {{#if item.isListItem}}
- {{view item}}
- {{else}}
- - {{view item}}
- {{/if}}
-{{/each}}
diff --git a/framework/core/ember/common/app/templates/components/ui/search-input.hbs b/framework/core/ember/common/app/templates/components/ui/search-input.hbs
deleted file mode 100644
index 7d7ce7782..000000000
--- a/framework/core/ember/common/app/templates/components/ui/search-input.hbs
+++ /dev/null
@@ -1,2 +0,0 @@
-{{input type="text" placeholder=placeholder class="form-control" value=value action="search"}}
-
diff --git a/framework/core/ember/common/app/templates/components/ui/switch-input.hbs b/framework/core/ember/common/app/templates/components/ui/switch-input.hbs
deleted file mode 100644
index be78ff97c..000000000
--- a/framework/core/ember/common/app/templates/components/ui/switch-input.hbs
+++ /dev/null
@@ -1,12 +0,0 @@
-
diff --git a/framework/core/ember/common/app/templates/components/ui/text-editor.hbs b/framework/core/ember/common/app/templates/components/ui/text-editor.hbs
deleted file mode 100644
index 5c2be620c..000000000
--- a/framework/core/ember/common/app/templates/components/ui/text-editor.hbs
+++ /dev/null
@@ -1,3 +0,0 @@
-{{textarea value=value placeholder=placeholder class="form-control flexible-height" disabled=disabled}}
-
-{{ui/item-list items=controls class="text-editor-controls fade" classNameBindings="value:in"}}
diff --git a/framework/core/ember/common/app/templates/components/ui/yesno-input.hbs b/framework/core/ember/common/app/templates/components/ui/yesno-input.hbs
deleted file mode 100644
index f443a0fd9..000000000
--- a/framework/core/ember/common/app/templates/components/ui/yesno-input.hbs
+++ /dev/null
@@ -1,10 +0,0 @@
-{{input type="checkbox" checked=toggleState disabled=disabled}}
-
- {{#if loading}}
- {{ui/loading-indicator size="tiny"}}
- {{else if toggleState}}
- {{fa-icon "check"}}
- {{else}}
- {{fa-icon "times"}}
- {{/if}}
-
diff --git a/framework/core/ember/common/app/utils/.gitkeep b/framework/core/ember/common/app/utils/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/app/utils/tagged-array.js b/framework/core/ember/common/app/utils/tagged-array.js
deleted file mode 100644
index 12aac4ed3..000000000
--- a/framework/core/ember/common/app/utils/tagged-array.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.ArrayProxy.extend({
- content: null,
- taggedObjects: null,
-
- init: function() {
- this.set('content', []);
- this.set('taggedObjects', {});
- this._super();
- },
-
- pushObjectWithTag: function(obj, tag) {
- this.insertAtWithTag(this.get('length'), obj, tag);
- },
-
- insertAtWithTag: function(idx, obj, tag) {
- this.insertAt(idx, obj);
- this.get('taggedObjects')[tag] = obj;
- },
-
- insertAfterTag: function(anchorTag, obj, tag) {
- var idx = this.indexOfTag(anchorTag);
- this.insertAtWithTag(idx + 1, obj, tag);
- },
-
- insertBeforeTag: function(anchorTag, obj, tag) {
- var idx = this.indexOfTag(anchorTag);
- this.insertAtWithTag(idx - 1, obj, tag);
- },
-
- removeByTag: function(tag) {
- var idx = this.indexOfTag(tag);
- this.removeAt(idx);
- delete this.get('taggedObjects')[tag];
- },
-
- replaceByTag: function(tag, obj) {
- var idx = this.indexOfTag(tag);
- this.removeByTag(tag);
- this.insertAtWithTag(idx, obj, tag);
- },
-
- moveByTag: function(tag, idx) {
- var obj = this.objectByTag(tag);
- this.removeByTag(tag);
- this.insertAtWithTag(idx, obj, tag);
- },
-
- indexOfTag: function(tag) {
- return this.indexOf(this.get('taggedObjects')[tag]);
- },
-
- objectByTag: function(tag) {
- return this.get('taggedObjects')[tag];
- }
-});
diff --git a/framework/core/ember/common/bower.json b/framework/core/ember/common/bower.json
deleted file mode 100644
index 71ce0e012..000000000
--- a/framework/core/ember/common/bower.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "name": "flarum-common",
- "dependencies": {
- "jquery": "^1.11.1",
- "ember": "1.10.0",
- "ember-data": "1.0.0-beta.15",
- "ember-resolver": "~0.1.11",
- "loader.js": "ember-cli/loader.js#1.0.1",
- "ember-cli-shims": "ember-cli/ember-cli-shims#0.0.3",
- "ember-cli-test-loader": "ember-cli-test-loader#0.1.3",
- "ember-load-initializers": "ember-cli/ember-load-initializers#0.0.2",
- "ember-qunit": "0.2.8",
- "ember-qunit-notifications": "0.0.7",
- "qunit": "~1.17.1"
- }
-}
diff --git a/framework/core/ember/common/config/environment.js b/framework/core/ember/common/config/environment.js
deleted file mode 100644
index 0dfaed472..000000000
--- a/framework/core/ember/common/config/environment.js
+++ /dev/null
@@ -1,5 +0,0 @@
-'use strict';
-
-module.exports = function(/* environment, appConfig */) {
- return { };
-};
diff --git a/framework/core/ember/common/index.js b/framework/core/ember/common/index.js
deleted file mode 100644
index 66e6565e5..000000000
--- a/framework/core/ember/common/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-/* jshint node: true */
-'use strict';
-
-module.exports = {
- name: 'flarum-common',
- isDevelopingAddon: function() {
- return true;
- }
-};
diff --git a/framework/core/ember/common/package.json b/framework/core/ember/common/package.json
deleted file mode 100644
index b8ef14247..000000000
--- a/framework/core/ember/common/package.json
+++ /dev/null
@@ -1,43 +0,0 @@
-{
- "name": "flarum-common",
- "version": "0.0.0",
- "description": "The default blueprint for ember-cli addons.",
- "directories": {
- "doc": "doc",
- "test": "tests"
- },
- "scripts": {
- "start": "ember server",
- "build": "ember build",
- "test": "ember test"
- },
- "repository": "",
- "engines": {
- "node": ">= 0.10.0"
- },
- "author": "",
- "license": "MIT",
- "devDependencies": {
- "broccoli-asset-rev": "^2.0.0",
- "ember-cli": "0.2.0-beta.1",
- "ember-cli-babel": "^4.0.0",
- "ember-cli-app-version": "0.3.1",
- "ember-cli-content-security-policy": "0.3.0",
- "ember-cli-dependency-checker": "0.0.7",
- "ember-cli-htmlbars": "0.7.4",
- "ember-cli-ic-ajax": "0.1.1",
- "ember-cli-inject-live-reload": "^1.3.0",
- "ember-cli-qunit": "0.3.8",
- "ember-cli-uglify": "1.0.1",
- "ember-data": "1.0.0-beta.15",
- "ember-export-application-global": "^1.0.2",
- "express": "^4.8.5",
- "glob": "^4.0.5"
- },
- "keywords": [
- "ember-addon"
- ],
- "ember-addon": {
- "configPath": "tests/dummy/config"
- }
-}
diff --git a/framework/core/ember/common/testem.json b/framework/core/ember/common/testem.json
deleted file mode 100644
index 42a4ddb22..000000000
--- a/framework/core/ember/common/testem.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "framework": "qunit",
- "test_page": "tests/index.html?hidepassed",
- "launch_in_ci": [
- "PhantomJS"
- ],
- "launch_in_dev": [
- "PhantomJS",
- "Chrome"
- ]
-}
diff --git a/framework/core/ember/common/tests/.jshintrc b/framework/core/ember/common/tests/.jshintrc
deleted file mode 100644
index ea8b88f62..000000000
--- a/framework/core/ember/common/tests/.jshintrc
+++ /dev/null
@@ -1,51 +0,0 @@
-{
- "predef": [
- "document",
- "window",
- "location",
- "setTimeout",
- "$",
- "-Promise",
- "define",
- "console",
- "visit",
- "exists",
- "fillIn",
- "click",
- "keyEvent",
- "triggerEvent",
- "find",
- "findWithAssert",
- "wait",
- "DS",
- "andThen",
- "currentURL",
- "currentPath",
- "currentRouteName"
- ],
- "node": false,
- "browser": false,
- "boss": true,
- "curly": false,
- "debug": false,
- "devel": false,
- "eqeqeq": true,
- "evil": true,
- "forin": false,
- "immed": false,
- "laxbreak": false,
- "newcap": true,
- "noarg": true,
- "noempty": false,
- "nonew": false,
- "nomen": false,
- "onevar": false,
- "plusplus": false,
- "regexp": false,
- "undef": true,
- "sub": true,
- "strict": false,
- "white": false,
- "eqnull": true,
- "esnext": true
-}
diff --git a/framework/core/ember/common/tests/dummy/app/app.js b/framework/core/ember/common/tests/dummy/app/app.js
deleted file mode 100644
index 757df3899..000000000
--- a/framework/core/ember/common/tests/dummy/app/app.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Ember from 'ember';
-import Resolver from 'ember/resolver';
-import loadInitializers from 'ember/load-initializers';
-import config from './config/environment';
-
-Ember.MODEL_FACTORY_INJECTIONS = true;
-
-var App = Ember.Application.extend({
- modulePrefix: config.modulePrefix,
- podModulePrefix: config.podModulePrefix,
- Resolver: Resolver
-});
-
-loadInitializers(App, config.modulePrefix);
-
-export default App;
diff --git a/framework/core/ember/common/tests/dummy/app/components/.gitkeep b/framework/core/ember/common/tests/dummy/app/components/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/tests/dummy/app/controllers/.gitkeep b/framework/core/ember/common/tests/dummy/app/controllers/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/tests/dummy/app/helpers/.gitkeep b/framework/core/ember/common/tests/dummy/app/helpers/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/tests/dummy/app/index.html b/framework/core/ember/common/tests/dummy/app/index.html
deleted file mode 100644
index 1c49d36d0..000000000
--- a/framework/core/ember/common/tests/dummy/app/index.html
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
- Dummy
-
-
-
- {{content-for 'head'}}
-
-
-
-
- {{content-for 'head-footer'}}
-
-
- {{content-for 'body'}}
-
-
-
-
- {{content-for 'body-footer'}}
-
-
diff --git a/framework/core/ember/common/tests/dummy/app/models/.gitkeep b/framework/core/ember/common/tests/dummy/app/models/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/tests/dummy/app/router.js b/framework/core/ember/common/tests/dummy/app/router.js
deleted file mode 100644
index cef554b3d..000000000
--- a/framework/core/ember/common/tests/dummy/app/router.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import Ember from 'ember';
-import config from './config/environment';
-
-var Router = Ember.Router.extend({
- location: config.locationType
-});
-
-Router.map(function() {
-});
-
-export default Router;
diff --git a/framework/core/ember/common/tests/dummy/app/routes/.gitkeep b/framework/core/ember/common/tests/dummy/app/routes/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/tests/dummy/app/styles/app.css b/framework/core/ember/common/tests/dummy/app/styles/app.css
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/tests/dummy/app/templates/application.hbs b/framework/core/ember/common/tests/dummy/app/templates/application.hbs
deleted file mode 100644
index 05eb936cf..000000000
--- a/framework/core/ember/common/tests/dummy/app/templates/application.hbs
+++ /dev/null
@@ -1,3 +0,0 @@
-Welcome to Ember.js
-
-{{outlet}}
diff --git a/framework/core/ember/common/tests/dummy/app/templates/components/.gitkeep b/framework/core/ember/common/tests/dummy/app/templates/components/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/tests/dummy/app/views/.gitkeep b/framework/core/ember/common/tests/dummy/app/views/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/common/tests/dummy/config/environment.js b/framework/core/ember/common/tests/dummy/config/environment.js
deleted file mode 100644
index c59bcd538..000000000
--- a/framework/core/ember/common/tests/dummy/config/environment.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/* jshint node: true */
-
-module.exports = function(environment) {
- var ENV = {
- modulePrefix: 'dummy',
- environment: environment,
- baseURL: '/',
- locationType: 'auto',
- EmberENV: {
- FEATURES: {
- // Here you can enable experimental features on an ember canary build
- // e.g. 'with-controller': true
- }
- },
-
- APP: {
- // Here you can pass flags/options to your application instance
- // when it is created
- }
- };
-
- if (environment === 'development') {
- // ENV.APP.LOG_RESOLVER = true;
- // ENV.APP.LOG_ACTIVE_GENERATION = true;
- // ENV.APP.LOG_TRANSITIONS = true;
- // ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
- // ENV.APP.LOG_VIEW_LOOKUPS = true;
- }
-
- if (environment === 'test') {
- // Testem prefers this...
- ENV.baseURL = '/';
- ENV.locationType = 'none';
-
- // keep test console output quieter
- ENV.APP.LOG_ACTIVE_GENERATION = false;
- ENV.APP.LOG_VIEW_LOOKUPS = false;
-
- ENV.APP.rootElement = '#ember-testing';
- }
-
- if (environment === 'production') {
-
- }
-
- return ENV;
-};
diff --git a/framework/core/ember/common/tests/dummy/public/crossdomain.xml b/framework/core/ember/common/tests/dummy/public/crossdomain.xml
deleted file mode 100644
index 29a035d7f..000000000
--- a/framework/core/ember/common/tests/dummy/public/crossdomain.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/framework/core/ember/common/tests/dummy/public/robots.txt b/framework/core/ember/common/tests/dummy/public/robots.txt
deleted file mode 100644
index 5debfa4df..000000000
--- a/framework/core/ember/common/tests/dummy/public/robots.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-# http://www.robotstxt.org
-User-agent: *
diff --git a/framework/core/ember/common/tests/helpers/resolver.js b/framework/core/ember/common/tests/helpers/resolver.js
deleted file mode 100644
index 28f4ece46..000000000
--- a/framework/core/ember/common/tests/helpers/resolver.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import Resolver from 'ember/resolver';
-import config from '../../config/environment';
-
-var resolver = Resolver.create();
-
-resolver.namespace = {
- modulePrefix: config.modulePrefix,
- podModulePrefix: config.podModulePrefix
-};
-
-export default resolver;
diff --git a/framework/core/ember/common/tests/helpers/start-app.js b/framework/core/ember/common/tests/helpers/start-app.js
deleted file mode 100644
index 16cc7c398..000000000
--- a/framework/core/ember/common/tests/helpers/start-app.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import Ember from 'ember';
-import Application from '../../app';
-import Router from '../../router';
-import config from '../../config/environment';
-
-export default function startApp(attrs) {
- var application;
-
- var attributes = Ember.merge({}, config.APP);
- attributes = Ember.merge(attributes, attrs); // use defaults, but you can override;
-
- Ember.run(function() {
- application = Application.create(attributes);
- application.setupForTesting();
- application.injectTestHelpers();
- });
-
- return application;
-}
diff --git a/framework/core/ember/common/tests/index.html b/framework/core/ember/common/tests/index.html
deleted file mode 100644
index 8fea6fe70..000000000
--- a/framework/core/ember/common/tests/index.html
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
- Dummy Tests
-
-
-
- {{content-for 'head'}}
- {{content-for 'test-head'}}
-
-
-
-
-
- {{content-for 'head-footer'}}
- {{content-for 'test-head-footer'}}
-
-
-
- {{content-for 'body'}}
- {{content-for 'test-body'}}
-
-
-
-
-
-
- {{content-for 'body-footer'}}
- {{content-for 'test-body-footer'}}
-
-
diff --git a/framework/core/ember/common/tests/test-helper.js b/framework/core/ember/common/tests/test-helper.js
deleted file mode 100644
index e6cfb70fe..000000000
--- a/framework/core/ember/common/tests/test-helper.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import resolver from './helpers/resolver';
-import {
- setResolver
-} from 'ember-qunit';
-
-setResolver(resolver);
diff --git a/framework/core/ember/common/tests/unit/.gitkeep b/framework/core/ember/common/tests/unit/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/forum/.bowerrc b/framework/core/ember/forum/.bowerrc
deleted file mode 100644
index 959e1696e..000000000
--- a/framework/core/ember/forum/.bowerrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "directory": "bower_components",
- "analytics": false
-}
diff --git a/framework/core/ember/forum/.editorconfig b/framework/core/ember/forum/.editorconfig
deleted file mode 100644
index 2fe4874a0..000000000
--- a/framework/core/ember/forum/.editorconfig
+++ /dev/null
@@ -1,33 +0,0 @@
-# EditorConfig helps developers define and maintain consistent
-# coding styles between different editors and IDEs
-# editorconfig.org
-
-root = true
-
-
-[*]
-end_of_line = lf
-charset = utf-8
-trim_trailing_whitespace = true
-insert_final_newline = true
-indent_style = space
-indent_size = 2
-
-[*.js]
-indent_style = space
-indent_size = 2
-
-[*.hbs]
-indent_style = space
-indent_size = 2
-
-[*.css]
-indent_style = space
-indent_size = 2
-
-[*.html]
-indent_style = space
-indent_size = 2
-
-[*.{diff,md}]
-trim_trailing_whitespace = false
diff --git a/framework/core/ember/forum/.gitignore b/framework/core/ember/forum/.gitignore
deleted file mode 100644
index 86fceae7a..000000000
--- a/framework/core/ember/forum/.gitignore
+++ /dev/null
@@ -1,17 +0,0 @@
-# See http://help.github.com/ignore-files/ for more about ignoring files.
-
-# compiled output
-/dist
-/tmp
-
-# dependencies
-/node_modules
-/bower_components
-
-# misc
-/.sass-cache
-/connect.lock
-/coverage/*
-/libpeerconnection.log
-npm-debug.log
-testem.log
diff --git a/framework/core/ember/forum/.jshintrc b/framework/core/ember/forum/.jshintrc
deleted file mode 100644
index e75f71963..000000000
--- a/framework/core/ember/forum/.jshintrc
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "predef": [
- "document",
- "window",
- "-Promise",
- "moment"
- ],
- "browser": true,
- "boss": true,
- "curly": true,
- "debug": false,
- "devel": true,
- "eqeqeq": true,
- "evil": true,
- "forin": false,
- "immed": false,
- "laxbreak": false,
- "newcap": true,
- "noarg": true,
- "noempty": false,
- "nonew": false,
- "nomen": false,
- "onevar": false,
- "plusplus": false,
- "regexp": false,
- "undef": true,
- "sub": true,
- "strict": false,
- "white": false,
- "eqnull": true,
- "esnext": true,
- "unused": true
-}
diff --git a/framework/core/ember/forum/.travis.yml b/framework/core/ember/forum/.travis.yml
deleted file mode 100644
index cf23938b7..000000000
--- a/framework/core/ember/forum/.travis.yml
+++ /dev/null
@@ -1,20 +0,0 @@
----
-language: node_js
-
-sudo: false
-
-cache:
- directories:
- - node_modules
-
-before_install:
- - "npm config set spin false"
- - "npm install -g npm@^2"
-
-install:
- - npm install -g bower
- - npm install
- - bower install
-
-script:
- - npm test
diff --git a/framework/core/ember/forum/Brocfile.js b/framework/core/ember/forum/Brocfile.js
deleted file mode 100644
index 0e4a4a415..000000000
--- a/framework/core/ember/forum/Brocfile.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/* global require, module */
-
-var EmberApp = require('ember-cli/lib/broccoli/ember-app');
-
-var app = new EmberApp();
-
-app.import('bower_components/bootstrap/dist/js/bootstrap.js');
-app.import('bower_components/spin.js/spin.js');
-app.import('bower_components/spin.js/jquery.spin.js');
-app.import('bower_components/moment/moment.js');
-app.import('bower_components/jquery.hotkeys/jquery.hotkeys.js');
-app.import('bower_components/blurjs/dist/jquery.blur.js');
-
-app.import('bower_components/font-awesome/fonts/fontawesome-webfont.eot');
-app.import('bower_components/font-awesome/fonts/fontawesome-webfont.svg');
-app.import('bower_components/font-awesome/fonts/fontawesome-webfont.ttf');
-app.import('bower_components/font-awesome/fonts/fontawesome-webfont.woff');
-app.import('bower_components/font-awesome/fonts/FontAwesome.otf');
-
-module.exports = app.toTree();
diff --git a/framework/core/ember/forum/app/app.js b/framework/core/ember/forum/app/app.js
deleted file mode 100644
index a4a0917c5..000000000
--- a/framework/core/ember/forum/app/app.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Ember from 'ember';
-import Resolver from 'ember/resolver';
-import loadInitializers from 'ember/load-initializers';
-import config from './config/environment';
-
-Ember.MODEL_FACTORY_INJECTIONS = true;
-
-var App = Ember.Application.extend({
- modulePrefix: config.modulePrefix,
- podModulePrefix: config.podModulePrefix,
- Resolver: Resolver
-});
-
-loadInitializers(App, config.modulePrefix);
-
-Ember.$('#assets-loading').remove();
-
-export default App;
diff --git a/framework/core/ember/forum/app/components/application/forum-statistic.js b/framework/core/ember/forum/app/components/application/forum-statistic.js
deleted file mode 100644
index e640ec544..000000000
--- a/framework/core/ember/forum/app/components/application/forum-statistic.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Ember from 'ember';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-export default Ember.Component.extend({
- layout: precompileTemplate('{{number}} {{label}}')
-});
diff --git a/framework/core/ember/forum/app/components/application/notification-discussion-renamed.js b/framework/core/ember/forum/app/components/application/notification-discussion-renamed.js
deleted file mode 100644
index bdff99203..000000000
--- a/framework/core/ember/forum/app/components/application/notification-discussion-renamed.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import Notification from './notification';
-
-export default Notification.extend();
diff --git a/framework/core/ember/forum/app/components/application/notification-item.js b/framework/core/ember/forum/app/components/application/notification-item.js
deleted file mode 100644
index 7c6954d56..000000000
--- a/framework/core/ember/forum/app/components/application/notification-item.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Ember from 'ember';
-
-import FadeIn from 'flarum-forum/mixins/fade-in';
-
-export default Ember.Component.extend(FadeIn, {
- layoutName: 'components/application/notification-item',
- tagName: 'li',
-
- componentName: Ember.computed('notification.contentType', function() {
- return 'application/notification-'+this.get('notification.contentType');
- })
-});
diff --git a/framework/core/ember/forum/app/components/application/notification.js b/framework/core/ember/forum/app/components/application/notification.js
deleted file mode 100644
index e28a22a1c..000000000
--- a/framework/core/ember/forum/app/components/application/notification.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Component.extend({
- classNames: ['notification'],
- classNameBindings: ['notification.isRead::unread'],
-
- click: function() {
- console.log('click')
- this.get('notification').set('isRead', true).save();
- }
-});
diff --git a/framework/core/ember/forum/app/components/application/powered-by.js b/framework/core/ember/forum/app/components/application/powered-by.js
deleted file mode 100644
index 0acba3e34..000000000
--- a/framework/core/ember/forum/app/components/application/powered-by.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Ember from 'ember';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-export default Ember.Component.extend({
- layout: precompileTemplate('Powered by Flarum')
-});
diff --git a/framework/core/ember/forum/app/components/application/user-dropdown.js b/framework/core/ember/forum/app/components/application/user-dropdown.js
deleted file mode 100644
index c9379d421..000000000
--- a/framework/core/ember/forum/app/components/application/user-dropdown.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-import DropdownButton from 'flarum-forum/components/ui/dropdown-button';
-import config from 'flarum-forum/config/environment';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-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) {
- var self = this;
-
- items.pushObjectWithTag(Ember.Component.extend({
- tagName: 'li',
- layout: precompileTemplate('{{#link-to "user" user}}{{fa-icon "user"}} Profile{{/link-to}}'),
- user: this.get('user')
- }));
-
- items.pushObjectWithTag(Ember.Component.extend({
- tagName: 'li',
- layout: precompileTemplate('{{#link-to "user.settings" user}}{{fa-icon "cog"}} Settings{{/link-to}}'),
- user: this.get('user')
- }));
-
- if (this.get('user.groups').findBy('id', '1')) {
- items.pushObjectWithTag(Ember.Component.extend({
- tagName: 'li',
- baseURL: config.baseURL,
- layout: precompileTemplate('{{fa-icon "wrench"}} Administration')
- }));
- }
-
- this.addSeparatorItem(items);
-
- this.addActionItem(items, 'logout', 'Log Out', 'sign-out', null, function() {
- self.get('parentController').send('invalidateSession');
- });
- }
-})
diff --git a/framework/core/ember/forum/app/components/application/user-notifications.js b/framework/core/ember/forum/app/components/application/user-notifications.js
deleted file mode 100644
index e89dd3205..000000000
--- a/framework/core/ember/forum/app/components/application/user-notifications.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import Ember from 'ember';
-
-import DropdownButton from 'flarum-forum/components/ui/dropdown-button';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-export default DropdownButton.extend({
- layoutName: 'components/application/user-notifications',
- classNames: ['notifications'],
- classNameBindings: ['unread'],
-
- buttonClass: 'btn btn-default btn-rounded btn-naked btn-icon',
- menuClass: 'pull-right',
-
- unread: Ember.computed.bool('user.unreadNotificationsCount'),
-
- actions: {
- buttonClick: function() {
- if (!this.get('notifications')) {
- var component = this;
- this.set('notificationsLoading', true);
- this.get('parentController.store').find('notification').then(function(notifications) {
- component.set('user.unreadNotificationsCount', 0);
- component.set('notifications', notifications);
- component.set('notificationsLoading', false);
- });
- }
- },
-
- markAllAsRead: function() {
- this.get('notifications').forEach(function(notification) {
- if (!notification.get('isRead')) {
- notification.set('isRead', true);
- notification.save();
- }
- })
- },
- }
-})
diff --git a/framework/core/ember/forum/app/components/composer/composer-body.js b/framework/core/ember/forum/app/components/composer/composer-body.js
deleted file mode 100644
index 900c97412..000000000
--- a/framework/core/ember/forum/app/components/composer/composer-body.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-
-/**
- This component is a base class for a composer body. It provides a template
- with a list of controls, a text editor, and some default behaviour.
- */
-export default Ember.Component.extend(HasItemLists, {
- layoutName: 'components/composer/composer-body',
-
- itemLists: ['controls'],
-
- submitLabel: '',
- placeholder: '',
- content: '',
- originalContent: '',
- user: null,
- submit: null,
- loading: false,
- confirmExit: '',
-
- disabled: Ember.computed.alias('composer.minimized'),
-
- actions: {
- submit: function(content) {
- this.get('submit')({
- content: content
- });
- },
-
- willExit: function(abort) {
- // If the user has typed something, prompt them before exiting
- // this composer state.
- if (this.get('content') !== this.get('originalContent') && !confirm(this.get('confirmExit'))) {
- abort();
- }
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/composer/composer-discussion.js b/framework/core/ember/forum/app/components/composer/composer-discussion.js
deleted file mode 100644
index 766168d41..000000000
--- a/framework/core/ember/forum/app/components/composer/composer-discussion.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import Ember from 'ember';
-
-import ComposerBody from 'flarum-forum/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.extend({
- tagName: 'h3',
- layout: precompileTemplate('{{ui/text-input value=component.title class="form-control" placeholder=component.titlePlaceholder disabled=component.disabled autoGrow=true}}'),
- component: this
- });
- items.pushObjectWithTag(title, 'title');
- },
-
- actions: {
- submit: function(content) {
- this.get('submit')({
- title: this.get('title'),
- content: content
- });
- },
-
- willExit: function(abort) {
- if ((this.get('title') || this.get('content')) && !confirm(this.get('confirmExit'))) {
- abort();
- }
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/composer/composer-edit.js b/framework/core/ember/forum/app/components/composer/composer-edit.js
deleted file mode 100644
index 4c7e902f0..000000000
--- a/framework/core/ember/forum/app/components/composer/composer-edit.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import Ember from 'ember';
-
-import ComposerBody from 'flarum-forum/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.extend({
- tagName: 'h3',
- layout: precompileTemplate('Editing Post #{{component.post.number}} in {{discussion.title}}'),
- discussion: this.get('post.discussion'),
- component: this
- });
- controls.pushObjectWithTag(title, 'title');
- }
-});
diff --git a/framework/core/ember/forum/app/components/composer/composer-reply.js b/framework/core/ember/forum/app/components/composer/composer-reply.js
deleted file mode 100644
index 646568468..000000000
--- a/framework/core/ember/forum/app/components/composer/composer-reply.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Ember from 'ember';
-
-import ComposerBody from 'flarum-forum/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.extend({
- tagName: 'h3',
- layout: precompileTemplate('Replying to {{component.discussion.title}}'),
- component: this
- });
- items.pushObjectWithTag(title, 'title');
- }
-});
diff --git a/framework/core/ember/forum/app/components/discussion/post-comment.js b/framework/core/ember/forum/app/components/discussion/post-comment.js
deleted file mode 100644
index ae1b94c2d..000000000
--- a/framework/core/ember/forum/app/components/discussion/post-comment.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import Ember from 'ember';
-
-import UseComposer from 'flarum-forum/mixins/use-composer';
-import FadeIn from 'flarum-forum/mixins/fade-in';
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-import ComposerEdit from 'flarum-forum/components/composer/composer-edit';
-import PostHeaderUser from 'flarum-forum/components/discussion/post-header/user';
-import PostHeaderMeta from 'flarum-forum/components/discussion/post-header/meta';
-import PostHeaderEdited from 'flarum-forum/components/discussion/post-header/edited';
-import PostHeaderToggle from 'flarum-forum/components/discussion/post-header/toggle';
-
-/**
- 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:is-hidden',
- 'post.isEdited:is-edited',
- 'revealContent:reveal-content'
- ],
- itemLists: ['controls', 'header', 'footer', 'actions'],
-
- // 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 Forever', '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.extend(properties), 'user');
- items.pushObjectWithTag(PostHeaderMeta.extend(properties), 'meta');
- items.pushObjectWithTag(PostHeaderEdited.extend(properties), 'edited');
- items.pushObjectWithTag(PostHeaderToggle.extend(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,
- hideTime: new Date(),
- hideUser: this.get('session.user')
- });
- post.save();
- },
-
- restore: function() {
- var post = this.get('post');
- post.setProperties({
- isHidden: false,
- hideTime: null,
- hideUser: null
- });
- post.save();
- },
-
- delete: function() {
- var post = this.get('post');
- post.destroyRecord();
- this.sendAction('postRemoved', post);
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/discussion/post-discussion-renamed.js b/framework/core/ember/forum/app/components/discussion/post-discussion-renamed.js
deleted file mode 100644
index 5eaa7c171..000000000
--- a/framework/core/ember/forum/app/components/discussion/post-discussion-renamed.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import Ember from 'ember';
-
-import FadeIn from 'flarum-forum/mixins/fade-in';
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-/**
- Component for a `discussionRenamed`-typed post.
- */
-export default Ember.Component.extend(FadeIn, HasItemLists, {
- layoutName: 'components/discussion/post-discussion-renamed',
- tagName: 'article',
- classNames: ['post', 'post-discussion-renamed', 'post-activity'],
- itemLists: ['controls'],
-
- // 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'),
- oldTitle: Ember.computed.alias('post.content.0'),
- newTitle: Ember.computed.alias('post.content.1'),
-
- populateControls: function(items) {
- this.addActionItem(items, 'delete', 'Delete', 'times', 'post.canDelete');
- },
-
- 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'));
- },
-
- delete: function() {
- var post = this.get('post');
- post.destroyRecord();
- this.sendAction('postRemoved', post);
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/discussion/post-header/edited.js b/framework/core/ember/forum/app/components/discussion/post-header/edited.js
deleted file mode 100644
index 5cbbe3eb5..000000000
--- a/framework/core/ember/forum/app/components/discussion/post-header/edited.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import Ember from 'ember';
-
-import humanTime from 'flarum-forum/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: 'li',
- classNames: ['post-edited'],
- classNameBindings: ['hidden'],
- 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.
- hidden: Ember.computed('post.isEdited', 'post.isHidden', function() {
- return !this.get('post.isEdited') || this.get('post.isHidden');
- }),
-
- didInsertElement: function() {
- this.$().tooltip();
- },
-
- // Whenever the title changes, we need to tell the tooltip to update to
- // reflect the new value.
- updateTooltip: Ember.observer('title', function() {
- Ember.run.scheduleOnce('afterRender', this, function() {
- this.$().tooltip('fixTitle');
- });
- })
-});
diff --git a/framework/core/ember/forum/app/components/discussion/post-header/meta.js b/framework/core/ember/forum/app/components/discussion/post-header/meta.js
deleted file mode 100644
index bb2515ac8..000000000
--- a/framework/core/ember/forum/app/components/discussion/post-header/meta.js
+++ /dev/null
@@ -1,38 +0,0 @@
-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/meta',
-
- // 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.$('.dropdown-toggle').click(function() {
- setTimeout(function() { component.$('.permalink').select(); }, 1);
- });
-
- // Prevent clicking on the input from closing it.
- this.$('.permalink').click(function(e) {
- e.stopPropagation();
- });
-
- this.set('touch', 'ontouchstart' in document.documentElement);
- }
-});
diff --git a/framework/core/ember/forum/app/components/discussion/post-header/toggle.js b/framework/core/ember/forum/app/components/discussion/post-header/toggle.js
deleted file mode 100644
index 714bdcd3c..000000000
--- a/framework/core/ember/forum/app/components/discussion/post-header/toggle.js
+++ /dev/null
@@ -1,22 +0,0 @@
-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',
- classNameBindings: ['hidden'],
- layout: precompileTemplate('{{fa-icon "ellipsis-h"}}'),
-
- hidden: Ember.computed.not('post.isHidden'),
-
- actions: {
- toggle: function() {
- this.toggleProperty('parent.revealContent');
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/discussion/post-header/user.js b/framework/core/ember/forum/app/components/discussion/post-header/user.js
deleted file mode 100644
index 168bc5e27..000000000
--- a/framework/core/ember/forum/app/components/discussion/post-header/user.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Ember from 'ember';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-/**
- Component for the username/avatar in a post header.
- */
-export default Ember.Component.extend({
- classNames: ['post-user'],
- layout: precompileTemplate('{{#if post.user}}{{#link-to "user" post.user}}{{user-avatar post.user}} {{user-name post.user}}{{/link-to}} {{ui/item-list items=post.user.badges class="badges"}}
{{#if showCard}}{{user/user-card user=post.user class="user-card-popover fade" controlsButtonClass="btn btn-default btn-icon btn-sm btn-naked"}}{{/if}}{{else}}{{user-avatar post.user}} {{user-name post.user}}
{{/if}}'),
-
- didInsertElement: function() {
- var component = this;
- var timeout;
- this.$().bind('mouseover', '> a, .user-card', function() {
- clearTimeout(timeout);
- timeout = setTimeout(function() {
- component.set('showCard', true);
- Ember.run.scheduleOnce('afterRender', function() {
- Ember.run.next(function() { component.$('.user-card').addClass('in'); });
- });
- }, 250);
- }).bind('mouseout', '> a, .user-card', function() {
- clearTimeout(timeout);
- timeout = setTimeout(function() {
- component.$('.user-card').removeClass('in').one('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function() {
- component.set('showCard', false);
- });
- }, 250);
- });
- }
-});
diff --git a/framework/core/ember/forum/app/components/discussion/stream-content.js b/framework/core/ember/forum/app/components/discussion/stream-content.js
deleted file mode 100644
index 8f12fd5d4..000000000
--- a/framework/core/ember/forum/app/components/discussion/stream-content.js
+++ /dev/null
@@ -1,297 +0,0 @@
-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);
- },
-
- postRemoved: function(post) {
- this.sendAction('postRemoved', post);
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/discussion/stream-item.js b/framework/core/ember/forum/app/components/discussion/stream-item.js
deleted file mode 100644
index 7cac1419d..000000000
--- a/framework/core/ember/forum/app/components/discussion/stream-item.js
+++ /dev/null
@@ -1,125 +0,0 @@
-import Ember from 'ember';
-
-var $ = Ember.$;
-
-/**
- A stream 'item' represents one item in the post stream - this may be a
- single post, or it may represent a gap of many posts which have not been
- loaded.
- */
-export default Ember.Component.extend({
- classNames: ['item'],
- classNameBindings: ['gap', 'loading', 'direction'],
- attributeBindings: [
- 'start:data-start',
- 'end:data-end',
- 'time:data-time',
- 'number:data-number'
- ],
-
- start: Ember.computed.alias('item.indexStart'),
- end: Ember.computed.alias('item.indexEnd'),
- number: Ember.computed.alias('item.content.number'),
- loading: Ember.computed.alias('item.loading'),
- direction: Ember.computed.alias('item.direction'),
- gap: Ember.computed.not('item.content'),
-
- time: Ember.computed('item.content.time', function() {
- var time = this.get('item.content.time');
- return time ? time.toString() : null;
- }),
-
- count: Ember.computed('start', 'end', function() {
- return this.get('end') - this.get('start') + 1;
- }),
-
- loadingChanged: Ember.observer('loading', function() {
- this.rerender();
- }),
-
- render: function(buffer) {
- if (this.get('item.content')) {
- return this._super(buffer);
- }
-
- buffer.push('');
- if (this.get('loading')) {
- buffer.push(' ');
- } else {
- buffer.push(this.get('count')+' more post'+(this.get('count') !== 1 ? 's' : ''));
- }
- buffer.push('');
- },
-
- didInsertElement: function() {
- if (!this.get('gap')) {
- return;
- }
-
- if (this.get('loading')) {
- var view = this;
- Ember.run.scheduleOnce('afterRender', function() {
- view.$().spin('small');
- });
- } else {
- var self = this;
- this.$().hover(function(e) {
- if (! self.get('loading')) {
- var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
- self.set('direction', up ? 'up' : 'down');
- }
- });
- }
- },
-
- load: function(relativeIndex) {
- // If this item is not a gap, or if we're already loading its posts,
- // then we don't need to do anything.
- if (! this.get('gap') || this.get('loading')) {
- return false;
- }
-
- // If new posts are being loaded in an upwards direction, then when
- // they are rendered, the rest of the posts will be pushed down the
- // page. If loaded in a downwards direction from the end of a
- // discussion, the terminal gap will disappear and the page will
- // scroll up a bit before the new posts are rendered. In order to
- // maintain the current scroll position relative to the content
- // before/after the gap, we need to find item directly after the gap
- // and use it as an anchor.
- var siblingFunc = this.get('direction') === 'up' ? 'nextAll' : 'prevAll';
- var anchor = this.$()[siblingFunc]('.item:first');
-
- // Immediately after the posts have been loaded (but before they
- // have been rendered,) we want to grab the distance from the top of
- // the viewport to the top of the anchor element.
- this.get('stream').one('postsLoaded', function() {
- if (anchor.length) {
- var scrollOffset = anchor.offset().top - $(document).scrollTop();
- }
-
- // After they have been rendered, we scroll back to a position
- // so that the distance from the top of the viewport to the top
- // of the anchor element is the same as before. If there is no
- // anchor (i.e. this gap is terminal,) then we'll scroll to the
- // bottom of the document.
- Ember.run.scheduleOnce('afterRender', function() {
- $('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
- });
- });
-
- // Tell the controller that we want to load the range of posts that this
- // gap represents. We also specify which direction we want to load the
- // posts from.
- this.sendAction(
- 'loadRange',
- this.get('start') + (relativeIndex || 0),
- this.get('end'),
- this.get('direction') === 'up'
- );
- },
-
- click: function() {
- this.load();
- }
-});
diff --git a/framework/core/ember/forum/app/components/index/discussion-listing.js b/framework/core/ember/forum/app/components/index/discussion-listing.js
deleted file mode 100755
index 1c31d3903..000000000
--- a/framework/core/ember/forum/app/components/index/discussion-listing.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-import FadeIn from 'flarum-forum/mixins/fade-in';
-import humanTime from 'flarum-forum/utils/human-time';
-
-/**
- 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',
- 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');
- }),
-
- jumpTo: Ember.computed('discussion.lastPostNumber', 'discussion.readNumber', function() {
- return Math.min(this.get('discussion.lastPostNumber'), (this.get('discussion.readNumber') || 0) + 1);
- }),
-
- authorInfo: Ember.computed('discussion.startUser.username', 'discussion.startTime', function() {
- return (this.get('discussion.startUser.username') || '[deleted]')+' started '+humanTime(this.get('discussion.startTime'));
- }),
-
- 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')];
- }
- }),
-
- didInsertElement: function() {
- this.$('.author').tooltip({ placement: 'right' });
- },
-
- populateControls: function(items) {
- this.addActionItem(items, 'delete', 'Delete', 'times', 'discussion.canDelete');
- },
-
- 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'),
- 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() {
- var discussion = this.get('discussion');
- if (discussion.get('isUnread')) {
- discussion.set('readNumber', discussion.get('lastPostNumber'));
- discussion.save();
- }
- },
-
- delete: function() {
- if (confirm('Are you sure you want to delete this discussion?')) {
- var discussion = this.get('discussion');
- discussion.destroyRecord();
- this.sendAction('discussionRemoved', discussion);
- }
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/index/welcome-hero.js b/framework/core/ember/forum/app/components/index/welcome-hero.js
deleted file mode 100644
index f1e3c8ab8..000000000
--- a/framework/core/ember/forum/app/components/index/welcome-hero.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import Ember from 'ember';
-
-/**
- Component for the "welcome to this forum" hero on the discussions index.
- */
-export default Ember.Component.extend({
- layoutName: 'components/index/welcome-hero',
- tagName: 'header',
- classNames: ['hero', 'welcome-hero'],
-
- title: '',
- description: '',
-
- actions: {
- close: function() {
- this.$().slideUp();
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/user/activity-item.js b/framework/core/ember/forum/app/components/user/activity-item.js
deleted file mode 100644
index c7f01060d..000000000
--- a/framework/core/ember/forum/app/components/user/activity-item.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import Ember from 'ember';
-
-import FadeIn from 'flarum-forum/mixins/fade-in';
-
-export default Ember.Component.extend(FadeIn, {
- layoutName: 'components/user/activity-item',
- tagName: 'li',
-
- componentName: Ember.computed('activity.contentType', function() {
- return 'user/activity-'+this.get('activity.contentType');
- })
-});
diff --git a/framework/core/ember/forum/app/components/user/activity-post.js b/framework/core/ember/forum/app/components/user/activity-post.js
deleted file mode 100644
index bbd26d1e4..000000000
--- a/framework/core/ember/forum/app/components/user/activity-post.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Component.extend({
- layoutName: 'components/user/activity-post',
-
- isFirstPost: Ember.computed('activity.post.number', function() {
- return this.get('activity.post.number') === 1;
- })
-});
diff --git a/framework/core/ember/forum/app/components/user/avatar-editor.js b/framework/core/ember/forum/app/components/user/avatar-editor.js
deleted file mode 100644
index 8c7526d68..000000000
--- a/framework/core/ember/forum/app/components/user/avatar-editor.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import Ember from 'ember';
-
-import config from 'flarum-forum/config/environment';
-
-var $ = Ember.$;
-
-export default Ember.Component.extend({
- layoutName: 'components/user/avatar-editor',
- classNames: ['avatar-editor', 'dropdown'],
- classNameBindings: ['loading'],
-
- didInsertElement: function() {
- var component = this;
- this.$('.dropdown-toggle').click(function(e) {
- if (! component.get('user.avatarUrl')) {
- e.preventDefault();
- e.stopPropagation();
- component.send('upload');
- }
- });
- },
-
- actions: {
- upload: function() {
- if (this.get('loading')) { return; }
-
- var $input = $('');
- var userId = this.get('user.id');
- var component = this;
- $input.appendTo('body').hide().click().on('change', function() {
- var formData = new FormData();
- formData.append('avatar', $(this)[0].files[0]);
- component.set('loading', true);
- $.ajax({
- type: 'POST',
- url: config.apiURL+'/users/'+userId+'/avatar',
- data: formData,
- cache: false,
- contentType: false,
- processData: false,
- complete: function() {
- component.set('loading', false);
- },
- success: function(data) {
- Ember.run.next(function() {
- component.get('store').pushPayload(data);
- });
- }
- });
- });
- },
-
- remove: function() {
- this.get('store').push('user', {id: this.get('user.id'), avatarUrl: null});
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/user/notification-grid.js b/framework/core/ember/forum/app/components/user/notification-grid.js
deleted file mode 100644
index cdc981cd3..000000000
--- a/framework/core/ember/forum/app/components/user/notification-grid.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Component.extend({
- layoutName: 'components/user/notification-grid',
- classNames: ['notification-grid'],
-
- methods: [
- { name: 'alert', icon: 'bell', label: 'Alert' },
- { name: 'email', icon: 'envelope-o', label: 'Email' }
- ],
-
- didInsertElement: function() {
- var component = this;
- this.$('thead .toggle-group').bind('mouseenter mouseleave', function(e) {
- var i = parseInt($(this).index()) + 1;
- component.$('table').find('td:nth-child('+i+')').toggleClass('highlighted', e.type === 'mouseenter');
- });
- this.$('tbody .toggle-group').bind('mouseenter mouseleave', function(e) {
- $(this).parent().find('td').toggleClass('highlighted', e.type === 'mouseenter');
- });
- },
-
- preferenceKey: function(type, method) {
- return 'notify_'+type+'_'+method;
- },
-
- grid: Ember.computed('methods', 'notificationTypes', function() {
- var grid = [];
- var component = this;
- var notificationTypes = this.get('notificationTypes');
- var methods = this.get('methods');
- var user = this.get('user');
-
- notificationTypes.forEach(function(type) {
- var row = Ember.Object.create({
- type: type,
- label: type.label,
- cells: []
- });
- methods.forEach(function(method) {
- var preferenceKey = 'preferences.'+component.preferenceKey(type.name, method.name);
- var cell = Ember.Object.create({
- type: type,
- method: method,
- enabled: !!user.get(preferenceKey),
- loading: false,
- disabled: typeof user.get(preferenceKey) == 'undefined'
- });
- cell.set('save', function(value, component) {
- cell.set('loading', true);
- user.set(preferenceKey, value).save().then(function() {
- cell.set('loading', false);
- });
- });
- row.get('cells').pushObject(cell);
- });
- grid.pushObject(row);
- });
-
- return grid;
- }),
-
- toggleCells: function(cells) {
- var enabled = !cells[0].get('enabled');
- var user = this.get('user');
- var component = this;
- cells.forEach(function(cell) {
- if (!cell.get('disabled')) {
- cell.set('loading', true);
- cell.set('enabled', enabled);
- user.set('preferences.'+component.preferenceKey(cell.get('type.name'), cell.get('method.name')), enabled);
- }
- });
- user.save().then(function() {
- cells.forEach(function(cell) {
- cell.set('loading', false);
- })
- });
- },
-
- actions: {
- toggleMethod: function(method) {
- var grid = this.get('grid');
- var component = this;
- var cells = [];
- grid.forEach(function(row) {
- row.get('cells').some(function(cell) {
- if (cell.get('method') === method) {
- cells.pushObject(cell);
- return true;
- }
- });
- });
- component.toggleCells(cells);
- },
-
- toggleType: function(type) {
- var grid = this.get('grid');
- var component = this;
- grid.some(function(row) {
- if (row.get('type') === type) {
- component.toggleCells(row.get('cells'));
- return true;
- }
- });
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/user/user-bio.js b/framework/core/ember/forum/app/components/user/user-bio.js
deleted file mode 100644
index f18558877..000000000
--- a/framework/core/ember/forum/app/components/user/user-bio.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Component.extend({
- layoutName: 'components/user/user-bio',
- classNames: ['user-bio'],
- classNameBindings: ['isEditable:editable', 'editing'],
-
- isEditable: Ember.computed.and('user.canEdit', 'editable'),
- editing: false,
-
- didInsertElement: function() {
- this.$().on('click', 'a', function(e) {
- e.stopPropagation();
- });
- },
-
- click: function() {
- this.send('edit');
- },
-
- actions: {
- edit: function() {
- if (!this.get('isEditable')) { return; }
-
- this.set('editing', true);
- var component = this;
- var height = this.$().height();
- Ember.run.scheduleOnce('afterRender', this, function() {
- var save = function(e) {
- if (e.shiftKey) { return; }
- e.preventDefault();
- component.send('save', $(this).val());
- };
- this.$('textarea').css('height', height).focus().bind('blur', save).bind('keydown', 'return', save);
- });
- },
-
- save: function(value) {
- this.set('editing', false);
-
- var user = this.get('user');
- user.set('bio', value);
- user.save();
- }
- }
-});
diff --git a/framework/core/ember/forum/app/components/user/user-card.js b/framework/core/ember/forum/app/components/user/user-card.js
deleted file mode 100644
index a2cf972b9..000000000
--- a/framework/core/ember/forum/app/components/user/user-card.js
+++ /dev/null
@@ -1,88 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-import UserBio from 'flarum-forum/components/user/user-bio';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-export default Ember.Component.extend(HasItemLists, {
- layoutName: 'components/user/user-card',
- classNames: ['user-card'],
- attributeBindings: ['style'],
- itemLists: ['controls', 'info'],
-
- style: Ember.computed('user.color', function() {
- return 'background-color: '+this.get('user.color');
- }),
-
- avatarUrlDidChange: Ember.observer('user.avatarUrl', function() {
- this.refreshOverlay(true);
- }),
-
- didInsertElement: function() {
- this.refreshOverlay();
- },
-
- refreshOverlay: function(animate) {
- var component = this;
- var $overlay = component.$('.darken-overlay');
- var $newOverlay = $overlay.clone().removeAttr('style').insertBefore($overlay);
- var avatarUrl = component.get('user.avatarUrl');
- var hideOverlay = function() {
- if (animate) {
- $overlay.fadeOut('slow');
- }
- $overlay.promise().done(function() {
- $(this).remove();
- });
- };
-
- if (avatarUrl) {
- $('').attr('src', avatarUrl).on('load', function() {
- component.$().css('background-image', 'url('+avatarUrl+')');
- $newOverlay.blurjs({
- source: component.$(),
- radius: 50,
- overlay: 'rgba(0, 0, 0, .2)',
- useCss: false
- });
- component.$().css('background-image', '');
- if (animate) {
- $newOverlay.hide().fadeIn('slow');
- }
- hideOverlay();
- });
- } else {
- hideOverlay();
- }
- },
-
- populateControls: function(items) {
- this.addActionItem(items, 'edit', 'Edit', 'pencil');
- this.addActionItem(items, 'delete', 'Delete', 'times');
- },
-
- populateInfo: function(items) {
- if (this.get('user.bioHtml') || (this.get('editable') && this.get('user.canEdit'))) {
- items.pushObjectWithTag(UserBio.extend({
- user: this.get('user'),
- editable: this.get('editable'),
- listItemClass: 'block-item'
- }), 'bio');
- }
-
- items.pushObjectWithTag(Ember.Component.extend({
- tagName: 'li',
- classNames: ['user-last-seen'],
- classNameBindings: ['hidden', 'user.online:online'],
- layout: precompileTemplate('{{#if user.online}}{{fa-icon "circle"}} Online{{else}}{{fa-icon "clock-o"}} {{human-time user.lastSeenTime}}{{/if}}'),
- user: this.get('user'),
- hidden: Ember.computed.not('user.lastSeenTime')
- }), 'lastActiveTime');
-
- items.pushObjectWithTag(Ember.Component.extend({
- layout: precompileTemplate('Joined {{human-time user.joinTime}}'),
- user: this.get('user')
- }), 'joinTime');
- }
-});
diff --git a/framework/core/ember/forum/app/controllers/.gitkeep b/framework/core/ember/forum/app/controllers/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/forum/app/controllers/application.js b/framework/core/ember/forum/app/controllers/application.js
deleted file mode 100644
index ec27c72de..000000000
--- a/framework/core/ember/forum/app/controllers/application.js
+++ /dev/null
@@ -1,59 +0,0 @@
-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 current page. This should be set as appropriate in
- // controllers/views.
- pageTitle: '',
-
- backButtonTarget: null,
-
- searchQuery: '',
- searchActive: false,
-
- history: null,
-
- init: function() {
- this._super();
- this.set('history', []);
- this.pushHistory('index', '/');
- },
-
- pushHistory: function(name, url) {
- var url = url || this.get('target.url');
- var last = this.get('history').get('lastObject');
- if (last && last.name === name) {
- last.url = url;
- } else {
- this.get('history').pushObject({name: name, url: url});
- }
- },
-
- popHistory: function(name) {
- var last = this.get('history').get('lastObject');
- if (last && last.name === name) {
- this.get('history').popObject();
- }
- },
-
- canGoBack: Ember.computed('history.length', function() {
- return this.get('history.length') > 1;
- }),
-
- actions: {
- goBack: function() {
- this.get('history').popObject();
- var history = this.get('history').get('lastObject');
- this.transitionToRoute.call(this, history.url);
- },
- search: function(query) {
- this.transitionToRoute('index', {queryParams: {searchQuery: query, sort: query ? 'relevance' : 'recent'}});
- },
- toggleDrawer: function() {
- this.toggleProperty('drawerShowing');
- }
- }
-});
diff --git a/framework/core/ember/forum/app/controllers/composer.js b/framework/core/ember/forum/app/controllers/composer.js
deleted file mode 100644
index 1556b27f9..000000000
--- a/framework/core/ember/forum/app/controllers/composer.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import Ember from 'ember';
-
-export var PositionEnum = {
- HIDDEN: 'hidden',
- NORMAL: 'normal',
- MINIMIZED: 'minimized',
- FULLSCREEN: 'fullscreen'
-};
-
-export default Ember.Controller.extend(Ember.Evented, {
- 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),
-
- // 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');
- });
- },
-
- hide: function() {
- this.set('position', PositionEnum.HIDDEN);
- },
-
- clearContent: function() {
- this.set('content', null);
- },
-
- 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');
- }
- }
- }
-
-});
diff --git a/framework/core/ember/forum/app/controllers/discussion.js b/framework/core/ember/forum/app/controllers/discussion.js
deleted file mode 100644
index df6d0c9d5..000000000
--- a/framework/core/ember/forum/app/controllers/discussion.js
+++ /dev/null
@@ -1,135 +0,0 @@
-import Ember from 'ember';
-
-import ComposerReply from 'flarum-forum/components/composer/composer-reply';
-import ActionButton from 'flarum-forum/components/ui/action-button';
-import AlertMessage from 'flarum-forum/components/ui/alert-message';
-import UseComposerMixin from 'flarum-forum/mixins/use-composer';
-
-export default Ember.Controller.extend(Ember.Evented, UseComposerMixin, {
- needs: ['application', 'index'],
- composer: Ember.inject.controller('composer'),
- alerts: Ember.inject.controller('alerts'),
-
- queryParams: ['start'],
- start: '1',
- searchQuery: '',
-
- loaded: false,
- stream: null,
-
- // 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 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')
- });
-
- // 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);
- controller.transitionToRoute({queryParams: {start: post.get('number')}});
- } 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.extend({
- type: 'success',
- message: 'Your reply was posted.',
- buttons: [{
- label: 'View',
- action: function() {
- controller.transitionToRoute('discussion', post.get('discussion'), {queryParams: {start: post.get('number')}});
- }
- }]
- });
- 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;
- if (this.get('session.isAuthenticated')) {
- this.showComposer(function() {
- return ComposerReply.create({
- user: controller.get('session.user'),
- discussion: discussion,
- submit: function(data) {
- controller.saveReply(discussion, data);
- }
- });
- });
- } else {
- this.send('signup');
- }
- },
-
- // 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);
-
- var discussion = this.get('model');
- if (endNumber > discussion.get('readNumber') && this.get('session.isAuthenticated')) {
- discussion.set('readNumber', endNumber);
- discussion.save();
- }
- },
-
- postRemoved: function(post) {
- this.get('stream').removePost(post);
- },
-
- rename: function(title) {
- var discussion = this.get('model');
- discussion.set('title', title);
-
- // When we save the title, we should get back an 'added post' in the
- // response which documents the title change. We'll add this to the post
- // stream.
- var controller = this;
- discussion.save().then(function(discussion) {
- discussion.get('addedPosts').forEach(function(post) {
- controller.get('stream').addPostToEnd(post);
- });
- });
- },
-
- delete: function() {
- var controller = this;
- var discussion = this.get('model');
- discussion.destroyRecord().then(function() {
- controller.get('controllers.index').send('discussionRemoved', discussion);
- });
- }
- }
-});
diff --git a/framework/core/ember/forum/app/controllers/index.js b/framework/core/ember/forum/app/controllers/index.js
deleted file mode 100644
index d2b9aefbd..000000000
--- a/framework/core/ember/forum/app/controllers/index.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import Ember from 'ember';
-
-import DiscussionResult from 'flarum-forum/models/discussion-result';
-import PostResult from 'flarum-forum/models/post-result';
-import Paneable from 'flarum-forum/mixins/paneable';
-import ComposerDiscussion from 'flarum-forum/components/composer/composer-discussion';
-import AlertMessage from 'flarum-forum/components/ui/alert-message';
-import UseComposer from 'flarum-forum/mixins/use-composer';
-
-export default Ember.Controller.extend(UseComposer, Paneable, {
- needs: ['application', 'index/index', 'discussion'],
- composer: Ember.inject.controller('composer'),
- alerts: Ember.inject.controller('alerts'),
-
- index: Ember.computed.alias('controllers.index/index'),
-
- paneDisabled: Ember.computed.not('index.model.length'),
-
- saveDiscussion: function(data) {
- var discussion = this.store.createRecord('discussion', {
- title: data.title,
- content: data.content
- });
-
- var controller = this;
- return this.saveAndDismissComposer(discussion).then(function(discussion) {
- if (discussion) {
- controller.get('index').send('loadResults');
- controller.transitionToRoute('discussion', discussion);
- }
- });
- },
-
- actions: {
- 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;
- if (this.get('session.isAuthenticated')) {
- this.showComposer(function() {
- return ComposerDiscussion.create({
- user: controller.get('session.user'),
- submit: function(data) {
- controller.saveDiscussion(data);
- }
- });
- });
- } else {
- this.send('signup');
- }
- },
-
- discussionRemoved: function(discussion) {
- if (this.get('controllers.discussion.model') === discussion) {
- this.transitionToRoute('index');
- }
- this.get('index').send('discussionRemoved', discussion);
- }
- }
-});
diff --git a/framework/core/ember/forum/app/controllers/index/index.js b/framework/core/ember/forum/app/controllers/index/index.js
deleted file mode 100644
index 897e87b72..000000000
--- a/framework/core/ember/forum/app/controllers/index/index.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import Ember from 'ember';
-
-import DiscussionResult from 'flarum-forum/models/discussion-result';
-import PostResult from 'flarum-forum/models/post-result';
-
-export default Ember.Controller.extend({
- needs: ['application'],
-
- queryParams: ['sort', 'show', {searchQuery: 'q'}, 'filter'],
- sort: 'recent',
- show: 'discussions',
- filter: '',
- searchQuery: '',
-
- meta: null,
- resultsLoading: false,
-
- sortOptions: [
- {key: 'recent', label: 'Recent', sort: 'recent'},
- {key: 'replies', label: 'Replies', sort: '-replies'},
- {key: 'newest', label: 'Newest', sort: '-created'},
- {key: 'oldest', label: 'Oldest', sort: 'created'},
- ],
-
- terminalPostType: Ember.computed('sort', function() {
- return ['newest', 'oldest'].indexOf(this.get('sort')) !== -1 ? 'start' : 'last';
- }),
-
- countType: Ember.computed('sort', function() {
- return this.get('sort') === 'replies' ? 'replies' : 'unread';
- }),
-
- moreResults: Ember.computed.bool('meta.moreUrl'),
-
- getResults: function(start) {
- var searchQuery = this.get('searchQuery');
- var sort = this.get('sort');
- var sortOptions = this.get('sortOptions');
- var sortOption = sortOptions.findBy('key', sort) || sortOptions.objectAt(0);
-
- var params = {
- sort: sortOption.sort,
- q: searchQuery,
- start: start
- };
-
- if (this.get('show') === 'posts') {
- if (searchQuery) {
- params.include = 'relevantPosts';
- } else if (sort === 'created') {
- params.include = 'startPost,startUser';
- } else {
- params.include = 'lastPost,lastUser';
- }
- }
-
- // var results = Ember.RSVP.resolve(FLARUM_DATA.discussions);
-
- return this.store.find('discussion', params).then(function(discussions) {
- var results = [];
- discussions.forEach(function(discussion) {
- var relevantPosts = [];
- // discussion.get('relevantPosts.content').forEach(function(post) {
- // relevantPosts.pushObject(PostResult.create(post));
- // });
- results.pushObject(DiscussionResult.create({
- content: discussion,
- relevantPosts: relevantPosts,
- lastPost: PostResult.create(discussion.get('lastPost')),
- startPost: PostResult.create(discussion.get('startPost'))
- }));
- results.set('meta', discussions.get('meta'));
- });
- return results;
- });
- },
-
- searchQueryDidChange: Ember.observer('searchQuery', function() {
- var searchQuery = this.get('searchQuery');
- this.get('controllers.application').setProperties({
- searchQuery: searchQuery,
- searchActive: !!searchQuery
- });
-
- var sortOptions = this.get('sortOptions');
-
- if (this.get('searchQuery') && sortOptions[0].sort !== 'relevance') {
- sortOptions.unshiftObject({key: 'relevance', label: 'Relevance', sort: 'relevance'});
- } else if (!this.get('searchQuery') && sortOptions[0].sort === 'relevance') {
- sortOptions.shiftObject();
- }
- }),
-
- paramsDidChange: Ember.observer('sort', 'show', 'searchQuery', function() {
- if (this.get('model') && !this.get('resultsLoading')) {
- Ember.run.once(this, this.loadResults);
- }
- }),
-
- loadResults: function() {
- this.send('loadResults');
- },
-
- actions: {
- discussionRemoved: function(discussion) {
- var model = this.get('model');
- model.removeObject(model.findBy('content', discussion));
- },
-
- loadResults: function() {
- var controller = this;
- controller.get('model').clear();
- controller.set('resultsLoading', true);
- controller.getResults().then(function(results) {
- controller
- .set('resultsLoading', false)
- .set('meta', results.get('meta'))
- .set('model.content', results);
- });
- },
-
- loadMore: function() {
- var controller = this;
- this.set('resultsLoading', true);
- this.getResults(this.get('model.length')).then(function(results) {
- controller.get('model').addObjects(results);
- controller.set('meta', results.get('meta'));
- controller.set('resultsLoading', false);
- });
- },
- }
-});
diff --git a/framework/core/ember/forum/app/controllers/login.js b/framework/core/ember/forum/app/controllers/login.js
deleted file mode 100644
index d0b4183fc..000000000
--- a/framework/core/ember/forum/app/controllers/login.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import Ember from 'ember';
-
-import AuthenticationControllerMixin from 'simple-auth/mixins/authentication-controller-mixin';
-import ModalController from 'flarum-forum/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;
-
- default:
- controller.set('error', 'Something went wrong. (Error code: '+errors[0].code+')');
- }
- controller.trigger('refocus');
- }).finally(function() {
- controller.set('loading', false);
- });
- }
- }
-});
diff --git a/framework/core/ember/forum/app/controllers/signup.js b/framework/core/ember/forum/app/controllers/signup.js
deleted file mode 100644
index 3f3763409..000000000
--- a/framework/core/ember/forum/app/controllers/signup.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import Ember from 'ember';
-
-import ModalController from 'flarum-forum/mixins/modal-controller';
-
-export default Ember.Controller.extend(ModalController, {
- emailProviderName: Ember.computed('welcomeUser.email', function() {
- if (!this.get('welcomeUser.email')) { return; }
- return this.get('welcomeUser.email').split('@')[1];
- }),
-
- emailProviderUrl: Ember.computed('emailProviderName', function() {
- return 'http://'+this.get('emailProviderName');
- }),
-
- welcomeStyle: Ember.computed('welcomeUser.color', function() {
- return 'background:'+this.get('welcomeUser.color');
- }),
-
- 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);
-
- return user.save().then(function(user) {
- controller.set('welcomeUser', user);
- controller.set('loading', false);
- controller.send('saveState');
- }, function(reason) {
- controller.set('loading', false);
- });
- }
- }
-});
diff --git a/framework/core/ember/forum/app/controllers/user.js b/framework/core/ember/forum/app/controllers/user.js
deleted file mode 100644
index b461f2cd3..000000000
--- a/framework/core/ember/forum/app/controllers/user.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Controller.extend({
-
-});
diff --git a/framework/core/ember/forum/app/controllers/user/activity.js b/framework/core/ember/forum/app/controllers/user/activity.js
deleted file mode 100644
index 6efe75ad0..000000000
--- a/framework/core/ember/forum/app/controllers/user/activity.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Controller.extend({
- needs: ['user'],
-
- queryParams: ['filter'],
- filter: '',
-
- resultsLoading: false,
-
- moreResults: true,
-
- loadCount: 10,
-
- getResults: function(start) {
- var type;
- switch (this.get('filter')) {
- case 'discussions':
- type = 'discussion';
- break;
-
- case 'posts':
- type = 'post';
- break;
- }
- var controller = this;
- return this.store.find('activity', {
- users: this.get('controllers.user.model.id'),
- type: type,
- start: start,
- count: this.get('loadCount')
- }).then(function(results) {
- controller.set('moreResults', results.get('length') >= controller.get('loadCount'));
- return results;
- });
- },
-
- paramsDidChange: Ember.observer('filter', function() {
- if (this.get('model') && !this.get('resultsLoading')) {
- Ember.run.once(this, this.loadResults);
- }
- }),
-
- loadResults: function() {
- this.send('loadResults');
- },
-
- actions: {
- loadResults: function() {
- var controller = this;
- controller.get('model').set('content', []);
- controller.set('resultsLoading', true);
- controller.getResults().then(function(results) {
- controller
- .set('resultsLoading', false)
- .set('meta', results.get('meta'))
- .set('model.content', results);
- });
- },
-
- loadMore: function() {
- var controller = this;
- this.set('resultsLoading', true);
- this.getResults(this.get('model.length')).then(function(results) {
- controller.get('model.content').addObjects(results);
- controller.set('meta', results.get('meta'));
- controller.set('resultsLoading', false);
- });
- },
- }
-});
diff --git a/framework/core/ember/forum/app/index.html b/framework/core/ember/forum/app/index.html
deleted file mode 100644
index 5bfcca9ec..000000000
--- a/framework/core/ember/forum/app/index.html
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
- Flarum
-
-
-
- {{content-for 'head'}}
-
-
-
-
- {{content-for 'head-footer'}}
-
-
- {{content-for 'body'}}
-
-
-
-
- {{content-for 'body-footer'}}
-
-
diff --git a/framework/core/ember/forum/app/initializers/inject-composer.js b/framework/core/ember/forum/app/initializers/inject-composer.js
deleted file mode 100644
index 52b09bde4..000000000
--- a/framework/core/ember/forum/app/initializers/inject-composer.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default {
- name: 'inject-composer',
- initialize: function(container, application) {
- application.inject('component', 'composer', 'controller:composer')
- }
-};
diff --git a/framework/core/ember/forum/app/mixins/paneable.js b/framework/core/ember/forum/app/mixins/paneable.js
deleted file mode 100644
index 34b8ec7ff..000000000
--- a/framework/core/ember/forum/app/mixins/paneable.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import Ember from 'ember';
-
-/**
- This mixin defines a "paneable" controller - this is, one that has a portion
- of its interface that can be turned into a pane which slides out from the
- side of the screen. This is useful, for instance, when you have nested
- routes (index > discussion) and want to have the parent route's interface
- transform into a side pane when entering the child route.
- */
-export default Ember.Mixin.create({
- needs: ['application'],
-
- // Whether or not the "paneable" interface element is paned.
- paned: false,
-
- // Whether or not the pane should be visible on screen.
- paneShowing: false,
- paneHideTimeout: null,
-
- // Whether or not the pane is always visible on screen, even when the
- // mouse is taken away.
- panePinned: localStorage.getItem('panePinned') !== 'false',
-
- // Disable the paneable behaviour completely, regardless of if it is
- // paned, showing, or pinned.
- paneDisabled: false,
-
- paneEnabled: Ember.computed.not('paneDisabled'),
- paneIsShowing: Ember.computed.and('paned', 'paneShowing', 'paneEnabled'),
- paneIsPinned: Ember.computed.and('paned', 'panePinned', 'paneEnabled'),
-
- // Tell the application controller when we pin/unpin the pane so that
- // other parts of the interface can respond appropriately.
- paneIsPinnedChanged: Ember.observer('paneIsPinned', function() {
- this.set('controllers.application.panePinned', this.get('paneIsPinned'));
- }),
-
- actions: {
- showPane: function() {
- if (this.get('paned')) {
- clearTimeout(this.get('paneHideTimeout'));
- this.set('paneShowing', true);
- }
- },
-
- hidePane: function(delay) {
- var controller = this;
- controller.set('paneHideTimeout', setTimeout(function() {
- controller.set('paneShowing', false);
- }, delay || 250));
- },
-
- togglePinned: function() {
- localStorage.setItem('panePinned', this.toggleProperty('panePinned') ? 'true' : 'false');
- }
- }
-});
diff --git a/framework/core/ember/forum/app/mixins/pushes-history.js b/framework/core/ember/forum/app/mixins/pushes-history.js
deleted file mode 100644
index c0ca98d31..000000000
--- a/framework/core/ember/forum/app/mixins/pushes-history.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Mixin.create({
- pushHistory: function() {
- Ember.run.next(this, function() {
- this.controllerFor('application').pushHistory(this.get('historyKey'), this.get('url'));
- });
- },
-
- setupController: function(controller, model) {
- this._super(controller, model);
- this.pushHistory();
- },
-
- actions: {
- queryParamsDidChange: function() {
- this.pushHistory();
- }
- }
-})
diff --git a/framework/core/ember/forum/app/mixins/use-composer.js b/framework/core/ember/forum/app/mixins/use-composer.js
deleted file mode 100644
index 2af36e73d..000000000
--- a/framework/core/ember/forum/app/mixins/use-composer.js
+++ /dev/null
@@ -1,38 +0,0 @@
-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');
- var controller = this;
- 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.extend({
- type: 'warning',
- message: errors[i]
- });
- this.get('alerts').send('alert', message);
- }
- }
-})
diff --git a/framework/core/ember/forum/app/router.js b/framework/core/ember/forum/app/router.js
deleted file mode 100644
index e281d62bc..000000000
--- a/framework/core/ember/forum/app/router.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import Ember from 'ember';
-import config from './config/environment';
-
-var Router = Ember.Router.extend({
- location: config.locationType
-});
-
-Router.map(function() {
- 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', {path: '/'});
- this.route('edit');
- this.route('settings');
- });
-});
-
-export default Router;
diff --git a/framework/core/ember/forum/app/routes/.gitkeep b/framework/core/ember/forum/app/routes/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/forum/app/routes/application.js b/framework/core/ember/forum/app/routes/application.js
deleted file mode 100644
index d6b207da3..000000000
--- a/framework/core/ember/forum/app/routes/application.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import Ember from 'ember';
-import ApplicationRouteMixin from 'simple-auth/mixins/application-route-mixin';
-
-import AlertMessage from 'flarum-forum/components/ui/alert-message';
-
-export default Ember.Route.extend(ApplicationRouteMixin, {
- activate: function() {
- if (!Ember.isEmpty(FLARUM_ALERT)) {
- this.controllerFor('alerts').send('alert', AlertMessage.extend(FLARUM_ALERT));
- FLARUM_ALERT = null;
- }
-
- var restoreUrl = localStorage.getItem('restoreUrl');
- if (restoreUrl && this.get('session.isAuthenticated')) {
- this.transitionTo(restoreUrl);
- localStorage.removeItem('restoreUrl');
- }
- },
-
- actions: {
- login: function() {
- this.controllerFor('login').set('error', null);
- this.send('showModal', 'login');
- },
-
- signup: function() {
- this.controllerFor('signup').set('error', null).set('welcomeUser', null);
- this.send('showModal', 'signup');
- },
-
- 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);
- },
-
- destroyModal: function() {
- this.disconnectOutlet({
- outlet: 'modal',
- parentView: 'application'
- });
- },
-
- sessionChanged: function() {
- this.refresh();
- },
-
- saveState: function() {
- localStorage.setItem('restoreUrl', this.router.get('url'));
- }
- }
-});
diff --git a/framework/core/ember/forum/app/routes/discussion.js b/framework/core/ember/forum/app/routes/discussion.js
deleted file mode 100644
index e1b57306e..000000000
--- a/framework/core/ember/forum/app/routes/discussion.js
+++ /dev/null
@@ -1,135 +0,0 @@
-import Ember from 'ember';
-
-import PostStream from 'flarum-forum/models/post-stream';
-import PushesHistory from 'flarum-forum/mixins/pushes-history';
-
-export default Ember.Route.extend(PushesHistory, {
- historyKey: 'discussion',
-
- queryParams: {
- start: {replace: true}
- },
-
- discussion: function(id, start) {
- return this.store.findQueryOne('discussion', id, {
- include: 'posts',
- near: start
- });
- },
-
- // When we fetch the discussion from the model hook (i.e. on a fresh page
- // load), we'll wrap it in an object proxy and set a `loaded` flag to true
- // so that it won't be reloaded later on.
- model: function(params) {
- return this.discussion(params.id, params.start).then(function(discussion) {
- return Ember.ObjectProxy.create({content: discussion, loaded: true});
- });
- },
-
- 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) {
- this._super(controller, 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
- });
- controller.set('stream', stream);
-
- // We need to make sure we have an up-to-date list of the discussion's
- // post IDs. If we didn't enter this route using the model hook (like if
- // clicking on a discussion in the index), then we'll reload the model.
- var promise = discussion.get('loaded') ?
- Ember.RSVP.resolve(discussion.get('content')) :
- this.discussion(discussion.get('id'), 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) {
- controller.set('model', 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;
- }));
- }
-
- // 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) {
- this._super(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');
-
- var application = this.controllerFor('application');
- if (!application.get('backButtonTarget')) {
- application.set('backButtonTarget', this.controllerFor('index'));
- }
- }
- }
-});
diff --git a/framework/core/ember/forum/app/routes/index.js b/framework/core/ember/forum/app/routes/index.js
deleted file mode 100644
index 2aedef2f3..000000000
--- a/framework/core/ember/forum/app/routes/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Route.extend({
- deactivate: function() {
- this.controllerFor('application').set('backButtonTarget', null);
- }
-});
diff --git a/framework/core/ember/forum/app/routes/index/index.js b/framework/core/ember/forum/app/routes/index/index.js
deleted file mode 100644
index 97c9cf675..000000000
--- a/framework/core/ember/forum/app/routes/index/index.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import Ember from 'ember';
-
-import AddCssClassToBody from 'flarum-forum/mixins/add-css-class-to-body';
-import PushesHistory from 'flarum-forum/mixins/pushes-history';
-
-export default Ember.Route.extend(AddCssClassToBody, PushesHistory, {
- historyKey: 'index',
-
- cachedModel: null,
-
- model: function() {
- if (!this.get('cachedModel')) {
- this.set('cachedModel', Ember.ArrayProxy.create());
- }
- return Ember.RSVP.resolve(this.get('cachedModel'));
- },
-
- setupController: function(controller, model) {
- this._super(controller, model);
-
- if (!model.get('length')) {
- controller.send('loadResults');
- }
- },
-
- deactivate: function() {
- this._super();
- this.controllerFor('application').set('backButtonTarget', this.controllerFor('index'));
- },
-
- 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);
- }
- }
-});
diff --git a/framework/core/ember/forum/app/routes/user.js b/framework/core/ember/forum/app/routes/user.js
deleted file mode 100644
index 4fa670d8a..000000000
--- a/framework/core/ember/forum/app/routes/user.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Ember from 'ember';
-
-import PushesHistory from 'flarum-forum/mixins/pushes-history';
-
-export default Ember.Route.extend(PushesHistory, {
- historyKey: 'user',
-
- model: function(params) {
- return this.store.find('user', params.username);
- },
-
- afterModel: function(model) {
- if (!model.get('joinTime')) {
- return model.reload();
- }
- }
-});
diff --git a/framework/core/ember/forum/app/routes/user/activity.js b/framework/core/ember/forum/app/routes/user/activity.js
deleted file mode 100644
index bec0726c5..000000000
--- a/framework/core/ember/forum/app/routes/user/activity.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Route.extend({
- model: function() {
- return Ember.RSVP.resolve(Ember.ArrayProxy.create());
- },
-
- setupController: function(controller, model) {
- this._super(controller, model);
-
- controller.send('loadResults');
- }
-});
diff --git a/framework/core/ember/forum/app/routes/user/settings.js b/framework/core/ember/forum/app/routes/user/settings.js
deleted file mode 100644
index 8b44a23e0..000000000
--- a/framework/core/ember/forum/app/routes/user/settings.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Route.extend({
- model: function() {
- return Ember.RSVP.resolve(this.modelFor('user'));
- }
-});
diff --git a/framework/core/ember/forum/app/styles/app.css b/framework/core/ember/forum/app/styles/app.css
deleted file mode 100644
index 65fd7e93e..000000000
--- a/framework/core/ember/forum/app/styles/app.css
+++ /dev/null
@@ -1,2 +0,0 @@
-// Flarum styles are stored in the top-level `less` directory. This remains
-// here as a placeholder file to prevent ember-cli from crashing.
diff --git a/framework/core/ember/forum/app/templates/.gitkeep b/framework/core/ember/forum/app/templates/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/forum/app/templates/application.hbs b/framework/core/ember/forum/app/templates/application.hbs
deleted file mode 100644
index 219c056a6..000000000
--- a/framework/core/ember/forum/app/templates/application.hbs
+++ /dev/null
@@ -1,55 +0,0 @@
-
-
- {{application/back-button target=backButtonTarget className="back-control" toggleDrawer="toggleDrawer" goBack="goBack" canGoBack=canGoBack}}
-
-
-
-
-
-
-
-
- {{outlet}}
-
-
-
- {{render "composer"}}
-
-
-
-
-
-
-
- {{outlet "modal"}}
-
-
-{{render "alerts"}}
diff --git a/framework/core/ember/forum/app/templates/components/.gitkeep b/framework/core/ember/forum/app/templates/components/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/forum/app/templates/components/application/notification-discussion-renamed.hbs b/framework/core/ember/forum/app/templates/components/application/notification-discussion-renamed.hbs
deleted file mode 100644
index 8f468b13c..000000000
--- a/framework/core/ember/forum/app/templates/components/application/notification-discussion-renamed.hbs
+++ /dev/null
@@ -1,14 +0,0 @@
-{{#link-to "discussion" notification.subject (query-params start=notification.content.number)}}
- {{user-avatar notification.sender}}
-
- {{notification.content.oldTitle}}
-
-
- {{fa-icon "pencil"}}
- Renamed by {{user-name notification.sender}}
- {{#if notification.additionalUnreadCount}}
- and {{notification.additionalUnreadCount}} others
- {{/if}}
- {{human-time notification.time}}
-
-{{/link-to}}
diff --git a/framework/core/ember/forum/app/templates/components/application/notification-item.hbs b/framework/core/ember/forum/app/templates/components/application/notification-item.hbs
deleted file mode 100644
index 21e7113b0..000000000
--- a/framework/core/ember/forum/app/templates/components/application/notification-item.hbs
+++ /dev/null
@@ -1 +0,0 @@
-{{component componentName notification=notification}}
diff --git a/framework/core/ember/forum/app/templates/components/application/user-notifications.hbs b/framework/core/ember/forum/app/templates/components/application/user-notifications.hbs
deleted file mode 100644
index e2956932b..000000000
--- a/framework/core/ember/forum/app/templates/components/application/user-notifications.hbs
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
- {{#if unread}}
- {{user.unreadNotificationsCount}}
- {{else}}
- {{fa-icon "bell" class="icon-glyph"}}
- {{/if}}
-
- Notifications
-
-
diff --git a/framework/core/ember/forum/app/templates/components/composer/composer-body.hbs b/framework/core/ember/forum/app/templates/components/composer/composer-body.hbs
deleted file mode 100644
index ce0705efd..000000000
--- a/framework/core/ember/forum/app/templates/components/composer/composer-body.hbs
+++ /dev/null
@@ -1,11 +0,0 @@
-{{user-avatar user class="composer-avatar"}}
-
-
- {{ui/item-list items=controls class="composer-header"}}
-
-
- {{ui/text-editor submit="submit" value=content placeholder=placeholder submitLabel=submitLabel disabled=loading}}
-
-
-
-{{ui/loading-indicator classNameBindings=":composer-loading loading:active"}}
diff --git a/framework/core/ember/forum/app/templates/components/discussion/post-comment.hbs b/framework/core/ember/forum/app/templates/components/discussion/post-comment.hbs
deleted file mode 100644
index 212438e80..000000000
--- a/framework/core/ember/forum/app/templates/components/discussion/post-comment.hbs
+++ /dev/null
@@ -1,17 +0,0 @@
-{{partial "components/discussion/post-controls"}}
-
-
-
-
- {{{post.contentHtml}}}
-
-
-
-
-
diff --git a/framework/core/ember/forum/app/templates/components/discussion/post-controls.hbs b/framework/core/ember/forum/app/templates/components/discussion/post-controls.hbs
deleted file mode 100644
index f4a8b920d..000000000
--- a/framework/core/ember/forum/app/templates/components/discussion/post-controls.hbs
+++ /dev/null
@@ -1,8 +0,0 @@
-{{#if controls}}
- {{ui/dropdown-button
- items=renderControls
- class="contextual-controls"
- buttonClass="btn btn-default btn-icon btn-sm btn-naked"
- buttonClick="renderControls"
- menuClass="pull-right"}}
-{{/if}}
diff --git a/framework/core/ember/forum/app/templates/components/discussion/post-discussion-renamed.hbs b/framework/core/ember/forum/app/templates/components/discussion/post-discussion-renamed.hbs
deleted file mode 100644
index bb5ffaa82..000000000
--- a/framework/core/ember/forum/app/templates/components/discussion/post-discussion-renamed.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-{{partial "components/discussion/post-controls"}}
-
-{{fa-icon "pencil" class="post-icon"}}
-
-{{#link-to "user" post.user class="post-user"}}{{post.user.username}}{{/link-to}} changed the title from {{oldTitle}} to {{newTitle}}.
-
-{{human-time post.time}}
diff --git a/framework/core/ember/forum/app/templates/components/discussion/post-header/meta.hbs b/framework/core/ember/forum/app/templates/components/discussion/post-header/meta.hbs
deleted file mode 100644
index f7b3438ed..000000000
--- a/framework/core/ember/forum/app/templates/components/discussion/post-header/meta.hbs
+++ /dev/null
@@ -1,10 +0,0 @@
-{{human-time post.time}}
-
diff --git a/framework/core/ember/forum/app/templates/components/discussion/stream-content.hbs b/framework/core/ember/forum/app/templates/components/discussion/stream-content.hbs
deleted file mode 100644
index 2a7431cfa..000000000
--- a/framework/core/ember/forum/app/templates/components/discussion/stream-content.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-{{#each item in stream}}
- {{#discussion/stream-item item=item stream=stream loadRange="loadRange"}}
- {{#if item.content}}
- {{component item.component content=item.content postRemoved="postRemoved"}}
- {{/if}}
- {{/discussion/stream-item}}
-{{/each}}
diff --git a/framework/core/ember/forum/app/templates/components/discussion/stream-scrubber.hbs b/framework/core/ember/forum/app/templates/components/discussion/stream-scrubber.hbs
deleted file mode 100644
index e7b9b7c7d..000000000
--- a/framework/core/ember/forum/app/templates/components/discussion/stream-scrubber.hbs
+++ /dev/null
@@ -1,28 +0,0 @@
-
- {{visibleIndex}} of {{count}} posts
- {{fa-icon "sort" class="icon-glyph"}}
-
-
diff --git a/framework/core/ember/forum/app/templates/components/index/discussion-info/terminal-post.hbs b/framework/core/ember/forum/app/templates/components/index/discussion-info/terminal-post.hbs
deleted file mode 100644
index 3145a0f3e..000000000
--- a/framework/core/ember/forum/app/templates/components/index/discussion-info/terminal-post.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-{{#if displayLastPost}}
- {{user-name discussion.lastUser}} replied
- {{human-time discussion.lastTime}}
-{{else}}
- {{user-name discussion.startUser}} started
- {{human-time discussion.startTime}}
-{{/if}}
diff --git a/framework/core/ember/forum/app/templates/components/index/discussion-listing.hbs b/framework/core/ember/forum/app/templates/components/index/discussion-listing.hbs
deleted file mode 100644
index 5fc13f377..000000000
--- a/framework/core/ember/forum/app/templates/components/index/discussion-listing.hbs
+++ /dev/null
@@ -1,36 +0,0 @@
-{{#if controls}}
- {{ui/dropdown-button
- items=renderControls
- class="contextual-controls"
- buttonClass="btn btn-default btn-icon btn-sm btn-naked"
- buttonClick="renderControls"
- menuClass="pull-right"}}
-{{/if}}
-
-{{#link-to "user" discussion.startUser class="author" title=authorInfo}}{{user-avatar discussion.startUser title=""}}{{/link-to}}
-
-{{ui/item-list items=discussion.badges class="badges"}}
-
-{{#link-to "discussion" discussion (query-params start=jumpTo) current-when="discussion" class="main"}}
- {{highlight-words discussion.title searchQuery}}
- {{ui/item-list items=info class="info"}}
-{{/link-to}}
-
-
- {{#if displayUnread}}
- {{abbreviate-number discussion.unreadCount}} unread
- {{else}}
- {{abbreviate-number discussion.repliesCount}} replies
- {{/if}}
-
-
-{{#if relevantPosts}}
-
- {{#each post in relevantPosts}}
- {{#link-to "discussion" discussion (query-params start=post.number) class="post item"}}
- {{user-avatar post.user class="avatar-thumb"}}
- {{highlight-words post.relevantContent searchQuery}}
- {{/link-to}}
- {{/each}}
-
-{{/if}}
diff --git a/framework/core/ember/forum/app/templates/components/index/welcome-hero.hbs b/framework/core/ember/forum/app/templates/components/index/welcome-hero.hbs
deleted file mode 100644
index 459b5287c..000000000
--- a/framework/core/ember/forum/app/templates/components/index/welcome-hero.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
Welcome to {{title}}
-
{{{description}}}
-
-
diff --git a/framework/core/ember/forum/app/templates/components/user/activity-item.hbs b/framework/core/ember/forum/app/templates/components/user/activity-item.hbs
deleted file mode 100644
index 2311dacf6..000000000
--- a/framework/core/ember/forum/app/templates/components/user/activity-item.hbs
+++ /dev/null
@@ -1 +0,0 @@
-{{component componentName activity=activity}}
diff --git a/framework/core/ember/forum/app/templates/components/user/activity-join.hbs b/framework/core/ember/forum/app/templates/components/user/activity-join.hbs
deleted file mode 100644
index 3ffafbfb8..000000000
--- a/framework/core/ember/forum/app/templates/components/user/activity-join.hbs
+++ /dev/null
@@ -1,6 +0,0 @@
-{{user-avatar activity.user class="activity-icon"}}
-
-
- Joined the forum
- {{human-time activity.time}}
-
diff --git a/framework/core/ember/forum/app/templates/components/user/activity-post.hbs b/framework/core/ember/forum/app/templates/components/user/activity-post.hbs
deleted file mode 100644
index f58019629..000000000
--- a/framework/core/ember/forum/app/templates/components/user/activity-post.hbs
+++ /dev/null
@@ -1,21 +0,0 @@
-{{user-avatar activity.post.user class="activity-icon"}}
-
-
- {{if isFirstPost "Started a discussion" "Posted a reply"}}
- {{human-time activity.time}}
-
-
-{{#if isFirstPost}}
-
- {{index/discussion-listing discussion=activity.post.discussion}}
-
-{{else}}
- {{#link-to "discussion" activity.post.discussion (query-params start=activity.post.number) class="activity-content activity-post"}}
-
- {{activity.post.discussion.title}}
-
-
- {{{activity.post.contentHtml}}}
-
- {{/link-to}}
-{{/if}}
diff --git a/framework/core/ember/forum/app/templates/components/user/avatar-editor.hbs b/framework/core/ember/forum/app/templates/components/user/avatar-editor.hbs
deleted file mode 100644
index 7f204a6cd..000000000
--- a/framework/core/ember/forum/app/templates/components/user/avatar-editor.hbs
+++ /dev/null
@@ -1,12 +0,0 @@
-{{user-avatar user}}
-
- {{#if loading}}
- {{ui/loading-indicator}}
- {{else}}
- {{fa-icon "pencil"}}
- {{/if}}
-
-
diff --git a/framework/core/ember/forum/app/templates/components/user/notification-grid.hbs b/framework/core/ember/forum/app/templates/components/user/notification-grid.hbs
deleted file mode 100644
index 88d5e6319..000000000
--- a/framework/core/ember/forum/app/templates/components/user/notification-grid.hbs
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
- |
- {{#each methods as |method|}}
- {{fa-icon method.icon}} {{method.label}} |
- {{/each}}
-
-
-
- {{#each grid as |row|}}
-
- {{row.label}} |
- {{#each row.cells as |cell|}}
- {{ui/yesno-input toggleState=cell.enabled changed=cell.save loading=cell.loading disabled=cell.disabled}} |
- {{/each}}
-
- {{/each}}
-
-
diff --git a/framework/core/ember/forum/app/templates/components/user/user-bio.hbs b/framework/core/ember/forum/app/templates/components/user/user-bio.hbs
deleted file mode 100644
index 6bee687ab..000000000
--- a/framework/core/ember/forum/app/templates/components/user/user-bio.hbs
+++ /dev/null
@@ -1,11 +0,0 @@
-{{#if editing}}
- {{textarea value=user.bio class="form-control"}}
-{{else}}
-
- {{#if user.bioHtml}}
- {{{user.bioHtml}}}
- {{else if isEditable}}
-
Write something about yourself...
- {{/if}}
-
-{{/if}}
diff --git a/framework/core/ember/forum/app/templates/components/user/user-card.hbs b/framework/core/ember/forum/app/templates/components/user/user-card.hbs
deleted file mode 100644
index 0a8e80dcf..000000000
--- a/framework/core/ember/forum/app/templates/components/user/user-card.hbs
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
- {{#if controls}}
- {{ui/dropdown-button items=controls class="contextual-controls" menuClass="pull-right" buttonClass=controlsButtonClass}}
- {{/if}}
-
-
-
- {{#if editable}}
- {{user/avatar-editor user=user class="user-avatar"}}{{user-name user}}
- {{else}}
- {{#link-to "user" user}}{{user-avatar user class="user-avatar"}}{{user-name user}}{{/link-to}}
- {{/if}}
-
-
- {{ui/item-list items=user.badges class="badges user-badges"}}
-
- {{ui/item-list items=info class="user-info"}}
-
-
diff --git a/framework/core/ember/forum/app/templates/composer.hbs b/framework/core/ember/forum/app/templates/composer.hbs
deleted file mode 100644
index 4c3600dad..000000000
--- a/framework/core/ember/forum/app/templates/composer.hbs
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-{{ui/item-list items=view.controls class="composer-controls"}}
-
-
- {{#if content}}
- {{view content}}
- {{/if}}
-
diff --git a/framework/core/ember/forum/app/templates/discussion.hbs b/framework/core/ember/forum/app/templates/discussion.hbs
deleted file mode 100644
index a35a41e8b..000000000
--- a/framework/core/ember/forum/app/templates/discussion.hbs
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
- {{discussion/stream-content
- viewName="streamContent"
- stream=stream
- class="discussion-posts posts"
- positionChanged="positionChanged"
- postRemoved="postRemoved"}}
-
diff --git a/framework/core/ember/forum/app/templates/error.hbs b/framework/core/ember/forum/app/templates/error.hbs
deleted file mode 100644
index b17a9157d..000000000
--- a/framework/core/ember/forum/app/templates/error.hbs
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
{{model.message}}
-
-
{{model.stack}}
-
-
diff --git a/framework/core/ember/forum/app/templates/index.hbs b/framework/core/ember/forum/app/templates/index.hbs
deleted file mode 100644
index 4a32da04b..000000000
--- a/framework/core/ember/forum/app/templates/index.hbs
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
- {{#if view.hero}}
- {{view view.hero}}
- {{/if}}
-
-
-
-
-
-
-
-
-
-
- {{#each discussion in index.model}}
- {{index/discussion-listing
- tagName="li"
- discussion=discussion.content
- searchQuery=index.searchQuery
- terminalPostType=index.terminalPostType
- countType=index.countType
- discussionRemoved="discussionRemoved"}}
- {{/each}}
-
-
- {{#if index.resultsLoading}}
- {{ui/loading-indicator size="small"}}
- {{else if index.moreResults}}
-
- {{ui/action-button class="control-loadMore btn btn-default" action="loadMore" label="Load More"}}
-
- {{/if}}
-
-
-
-
-
-
-
-
- {{outlet}}
-
diff --git a/framework/core/ember/forum/app/templates/loading.hbs b/framework/core/ember/forum/app/templates/loading.hbs
deleted file mode 100644
index ce3ce25fe..000000000
--- a/framework/core/ember/forum/app/templates/loading.hbs
+++ /dev/null
@@ -1 +0,0 @@
-{{ui/loading-indicator class="loading-indicator-block"}}
diff --git a/framework/core/ember/forum/app/templates/login.hbs b/framework/core/ember/forum/app/templates/login.hbs
deleted file mode 100644
index 704657ec4..000000000
--- a/framework/core/ember/forum/app/templates/login.hbs
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-{{ui/loading-indicator classNameBindings=":modal-loading loading:active"}}
diff --git a/framework/core/ember/forum/app/templates/signup.hbs b/framework/core/ember/forum/app/templates/signup.hbs
deleted file mode 100644
index 19e65780d..000000000
--- a/framework/core/ember/forum/app/templates/signup.hbs
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-{{ui/loading-indicator classNameBindings=":modal-loading loading:active"}}
-
-{{#if welcomeUser}}
-
- {{user-avatar welcomeUser}}
-
Welcome, {{welcomeUser.username}}!
-
- {{#unless welcomeUser.isConfirmed}}
-
We've sent a confirmation email to {{welcomeUser.email}}. If it doesn't arrive soon, check your spam folder.
-
Go to {{emailProviderName}}
- {{/unless}}
-
-{{/if}}
diff --git a/framework/core/ember/forum/app/templates/user.hbs b/framework/core/ember/forum/app/templates/user.hbs
deleted file mode 100644
index dea7c6a91..000000000
--- a/framework/core/ember/forum/app/templates/user.hbs
+++ /dev/null
@@ -1,11 +0,0 @@
-{{user/user-card user=model class="hero user-hero" editable=true controlsButtonClass="btn btn-default"}}
-
-
-
-
-
- {{outlet}}
-
-
diff --git a/framework/core/ember/forum/app/templates/user/activity.hbs b/framework/core/ember/forum/app/templates/user/activity.hbs
deleted file mode 100644
index cc7ccd434..000000000
--- a/framework/core/ember/forum/app/templates/user/activity.hbs
+++ /dev/null
@@ -1,13 +0,0 @@
-
- {{#each activity in model}}
- {{user/activity-item activity=activity}}
- {{/each}}
-
-
-{{#if resultsLoading}}
- {{ui/loading-indicator size="small"}}
-{{else if moreResults}}
-
- {{ui/action-button class="control-loadMore btn btn-default" action="loadMore" label="Load More"}}
-
-{{/if}}
diff --git a/framework/core/ember/forum/app/templates/user/settings.hbs b/framework/core/ember/forum/app/templates/user/settings.hbs
deleted file mode 100644
index f4ff909f4..000000000
--- a/framework/core/ember/forum/app/templates/user/settings.hbs
+++ /dev/null
@@ -1 +0,0 @@
-{{ui/item-list items=view.settings}}
diff --git a/framework/core/ember/forum/app/views/.gitkeep b/framework/core/ember/forum/app/views/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/ember/forum/app/views/application.js b/framework/core/ember/forum/app/views/application.js
deleted file mode 100644
index 458f1435b..000000000
--- a/framework/core/ember/forum/app/views/application.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-import SearchInput from 'flarum-forum/components/ui/search-input';
-import UserNotifications from 'flarum-forum/components/application/user-notifications';
-import UserDropdown from 'flarum-forum/components/application/user-dropdown';
-import ForumStatistic from 'flarum-forum/components/application/forum-statistic';
-import PoweredBy from 'flarum-forum/components/application/powered-by';
-
-var $ = Ember.$;
-
-export default Ember.View.extend(HasItemLists, {
- itemLists: ['headerPrimary', 'headerSecondary', 'footerPrimary', 'footerSecondary'],
-
- title: Ember.computed.alias('controller.forumTitle'),
-
- // When either the forum title or the page title changes, we want to
- // refresh the document's title.
- updateTitle: Ember.observer('controller.pageTitle', 'controller.forumTitle', function() {
- var parts = [this.get('controller.forumTitle')];
- var pageTitle = this.get('controller.pageTitle');
- if (pageTitle) {
- parts.unshift(pageTitle);
- }
- document.title = parts.join(' - ');
- }),
-
- modalShowingChanged: Ember.observer('controller.modalController', function() {
- Ember.run.scheduleOnce('afterRender', this, function() {
- this.$('#modal').modal(this.get('controller.modalController') ? 'show' : 'hide');
- });
- }),
-
- drawerShowingChanged: Ember.observer('controller.drawerShowing', function() {
- Ember.run.scheduleOnce('afterRender', this, function() {
- $('body').toggleClass('drawer-open', this.get('controller.drawerShowing'));
- });
- }),
-
- didInsertElement: function() {
- // Add a class to the body when the window is scrolled down.
- $(window).scroll(function() {
- $('body').toggleClass('scrolled', $(window).scrollTop() > 0);
- }).scroll();
-
- // Resize the main content area so that the footer sticks to the
- // bottom of the viewport.
- // $(window).resize(function() {
- // $('#main').css('min-height', $(window).height() - $('#header').outerHeight() - $('#footer').outerHeight(true));
- // }).resize();
-
- var view = this;
- this.$('#modal').on('hide.bs.modal', function() {
- view.get('controller').send('closeModal');
- }).on('hidden.bs.modal', function() {
- view.get('controller').send('destroyModal');
- }).on('shown.bs.modal', function() {
- view.get('controller.modalController').send('focus');
- });
-
- this.$().on('show.bs.dropdown', function() {
- $('body').addClass('dropdown-open');
- }).on('hide.bs.dropdown', function() {
- $('body').removeClass('dropdown-open');
- });
-
- this.$('.global-content').click(function(e) {
- if (view.get('controller.drawerShowing')) {
- e.preventDefault();
- view.set('controller.drawerShowing', false);
- }
- });
- },
-
- switchHeader: Ember.observer('controller.session.user', function() {
- this.initItemList('headerPrimary');
- this.initItemList('headerSecondary');
- }),
-
- populateHeaderSecondary: function(items) {
- var controller = this.get('controller');
-
- items.pushObjectWithTag(SearchInput.extend({
- placeholder: 'Search Forum',
- controller: controller,
- valueBinding: Ember.Binding.oneWay('controller.searchQuery'),
- activeBinding: Ember.Binding.oneWay('controller.searchActive'),
- action: function(value) { controller.send('search', value); }
- }), 'search');
-
- if (this.get('controller.session.isAuthenticated')) {
- items.pushObjectWithTag(UserNotifications.extend({
- user: this.get('controller.session.user'),
- parentController: controller
- }), 'notifications');
-
- items.pushObjectWithTag(UserDropdown.extend({
- user: this.get('controller.session.user'),
- parentController: controller
- }), 'user');
- } else {
- this.addActionItem(items, 'signup', 'Sign Up').reopen({className: 'btn btn-link'});
- this.addActionItem(items, 'login', 'Log In').reopen({className: 'btn btn-link'});
- }
- },
-
- populateFooterPrimary: function(items) {
- var addStatistic = function(label, number) {
- items.pushObjectWithTag(ForumStatistic.extend({
- label: label,
- number: number
- }), 'statistics.'+label);
- };
- // addStatistic('discussions', 12);
- // addStatistic('posts', 12);
- // addStatistic('users', 12);
- // addStatistic('online', 12);
- },
-
- populateFooterSecondary: function(items) {
- items.pushObjectWithTag(PoweredBy, 'poweredBy');
- }
-});
diff --git a/framework/core/ember/forum/app/views/composer.js b/framework/core/ember/forum/app/views/composer.js
deleted file mode 100644
index 649b3c75d..000000000
--- a/framework/core/ember/forum/app/views/composer.js
+++ /dev/null
@@ -1,262 +0,0 @@
-import Ember from 'ember';
-
-import { PositionEnum } from 'flarum-forum/controllers/composer';
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-
-var $ = Ember.$;
-
-export default Ember.View.extend(HasItemLists, {
- classNames: ['composer'],
- classNameBindings: ['visible', 'minimized', 'fullscreen', 'active'],
- itemLists: ['controls'],
-
- position: Ember.computed.alias('controller.position'),
- visible: Ember.computed.alias('controller.visible'),
- normal: Ember.computed.alias('controller.normal'),
- minimized: Ember.computed.alias('controller.minimized'),
- fullscreen: Ember.computed.alias('controller.fullscreen'),
-
- // Calculate the composer's current height, based on the intended height
- // (which is set when the resizing handle is dragged), and the composer's
- // current state.
- computedHeight: Ember.computed('height', 'minimized', 'fullscreen', function() {
- if (this.get('minimized')) {
- return '';
- } else if (this.get('fullscreen')) {
- return $(window).height();
- } else {
- return Math.max(200, Math.min(this.get('height'), $(window).height() - $('#header').outerHeight()));
- }
- }),
-
- didInsertElement: function() {
- var view = this;
- var controller = this.get('controller');
-
- // Hide the composer to begin with.
- this.set('height', localStorage.getItem('composerHeight') || this.$().height());
- this.$().hide();
-
- // If the composer is minimized, allow the user to click anywhere on
- // it to show it.
- this.$('.composer-content').click(function() {
- if (view.get('minimized')) {
- controller.send('show');
- }
- });
-
- // Modulate the view's active property/class according to the focus
- // state of any inputs.
- this.$().on('focus', ':input', function() {
- view.set('active', true);
- }).on('blur', ':input', function() {
- view.set('active', false);
- });
-
- // Focus on the first input when the controller wants to focus.
- controller.on('focus', this, this.focus);
-
- // Set up the handle so that the composer can be resized.
- $(window).on('resize', {view: this}, this.windowWasResized).resize();
-
- var dragData = {view: this};
- this.$('.composer-handle').css('cursor', 'row-resize')
- .mousedown(function(e) {
- dragData.mouseStart = e.clientY;
- dragData.heightStart = view.$().height();
- dragData.handle = $(this);
- $('body').css('cursor', 'row-resize');
- }).bind('dragstart mousedown', function(e) {
- e.preventDefault();
- });
-
- $(document)
- .on('mousemove', dragData, this.mouseWasMoved)
- .on('mouseup', dragData, this.mouseWasReleased);
-
- // When the escape key is pressed on any inputs, close the composer.
- this.$().on('keydown', ':input', 'esc', function() {
- controller.send('close');
- });
- },
-
- willDestroyElement: function() {
- $(window).off('resize', this.windowWasResized);
-
- $(document)
- .off('mousemove', this.mouseWasMoved)
- .off('mouseup', this.mouseWasReleased);
- },
-
- // Update the amount of padding-bottom on the body so that the page's
- // content will still be visible above the composer when the page is
- // scrolled right to the bottom.
- updateBodyPadding: function(animate) {
- // Before we change anything, work out if we're currently scrolled
- // right to the bottom of the page. If we are, we'll want to anchor
- // the body's scroll position to the bottom after we update the
- // padding.
- var scrollTop = $(window).scrollTop();
- var anchorScroll = scrollTop > 0 && scrollTop + $(window).height() >= $(document).height();
-
- var func = animate ? 'animate' : 'css';
- var paddingBottom = this.get('visible') ? this.get('computedHeight') - parseInt($('#page').css('padding-bottom')) : 0;
- $('#content')[func]({paddingBottom: paddingBottom}, 'fast');
-
- if (anchorScroll) {
- if (animate) {
- $('html, body').animate({scrollTop: $(document).height()}, 'fast');
- } else {
- $('html, body').scrollTop($(document).height());
- }
- }
- },
-
- // Update the height of the stuff inside of the composer. There should be
- // an element with the class .flexible-height — this element is intended
- // to fill up the height of the composer, minus the space taken up by the
- // composer's header/footer/etc.
- setContentHeight: function(height) {
- var content = this.$('.composer-content');
- this.$('.flexible-height').height(height -
- parseInt(content.css('padding-top')) -
- parseInt(content.css('padding-bottom')) -
- this.$('.composer-header').outerHeight(true) -
- this.$('.text-editor-controls').outerHeight(true));
- },
-
- // ------------------------------------------------------------------------
- // OBSERVERS
- // ------------------------------------------------------------------------
-
- // Whenever the composer's computed height changes, update the DOM to
- // reflect it.
- updateHeight: Ember.observer('computedHeight', function() {
- Ember.run.scheduleOnce('afterRender', this, function() {
- this.$().height(this.get('computedHeight'));
- });
- }),
-
- updateContentHeight: Ember.observer('computedHeight', 'controller.content', function() {
- Ember.run.scheduleOnce('afterRender', this, function() {
- this.setContentHeight(this.get('computedHeight'));
- });
- }),
-
- updateBody: Ember.observer('visible', function() {
- Ember.run.scheduleOnce('afterRender', this, function() {
- $('body').toggleClass('composer-open', this.get('visible'));
- });
- }),
-
- // Whenever the composer's display state changes, update the DOM to slide
- // it in or out.
- positionDidChange: Ember.observer('position', function() {
- // At this stage, the position property has just changed, and the
- // class name hasn't been altered in the DOM. So, we can grab the
- // composer's current height which we might want to animate from.
- // After the DOM has updated, we animate to its new height.
- var $composer = this.$();
- var oldHeight = $composer ? $composer.height() : 0;
-
- Ember.run.scheduleOnce('afterRender', this, function() {
- var $composer = this.$();
- var newHeight = $composer.height();
- var view = this;
-
- switch (this.get('position')) {
- case PositionEnum.HIDDEN:
- $composer.css({height: oldHeight}).animate({bottom: -newHeight}, 'fast', function() {
- $composer.hide();
- view.get('controller').send('clearContent');
- });
- break;
-
- case PositionEnum.NORMAL:
- if (this.get('oldPosition') !== PositionEnum.FULLSCREEN) {
- $composer.show();
- $composer.css({height: oldHeight}).animate({bottom: 0, height: newHeight}, 'fast', function() {
- view.focus();
- });
- }
- break;
-
- case PositionEnum.MINIMIZED:
- $composer.css({height: oldHeight}).animate({height: newHeight}, 'fast', function() {
- view.focus();
- });
- break;
- }
-
- $composer.css('overflow', '');
-
- if (this.get('position') !== PositionEnum.FULLSCREEN) {
- this.updateBodyPadding(true);
- }
- this.setContentHeight(this.get('computedHeight'));
- this.set('oldPosition', this.get('position'));
- });
- }),
-
- // ------------------------------------------------------------------------
- // LISTENERS
- // ------------------------------------------------------------------------
-
- windowWasResized: function(event) {
- // Force a recalculation of the computed height, because its value
- // depends on the window's height.
- var view = event.data.view;
- view.notifyPropertyChange('computedHeight');
- },
-
- mouseWasMoved: function(event) {
- if (! event.data.handle) { return; }
- var view = event.data.view;
-
- // Work out how much the mouse has been moved, and set the height
- // relative to the old one based on that. Then update the content's
- // height so that it fills the height of the composer, and update the
- // body's padding.
- var deltaPixels = event.data.mouseStart - event.clientY;
- var height = event.data.heightStart + deltaPixels;
- view.set('height', height);
- view.updateBodyPadding();
-
- localStorage.setItem('composerHeight', height);
- },
-
- mouseWasReleased: function(event) {
- if (! event.data.handle) { return; }
- event.data.handle = null;
- $('body').css('cursor', '');
- },
-
- focus: function() {
- if (this.$().is(':hidden')) { return; }
-
- Ember.run.scheduleOnce('afterRender', this, function() {
- this.$().find(':input:enabled:visible:first').focus();
- });
- },
-
- populateControls: function(items) {
- var view = this;
- var addControl = function(tag, title, icon) {
- return view.addActionItem(items, tag, null, icon).reopen({className: 'btn btn-icon btn-link', title: title});
- };
-
- if (this.get('fullscreen')) {
- addControl('exitFullscreen', 'Exit Full Screen', 'compress');
- } else {
- if (!this.get('minimized')) {
- addControl('minimize', 'Minimize', 'minus minimize');
- addControl('fullscreen', 'Full Screen', 'expand');
- }
- addControl('close', 'Close', 'times').reopen({listItemClass: 'back-control'});
- }
- },
-
- refreshControls: Ember.observer('position', function() {
- this.initItemList('controls');
- })
-});
diff --git a/framework/core/ember/forum/app/views/discussion.js b/framework/core/ember/forum/app/views/discussion.js
deleted file mode 100644
index 0bde7e4f6..000000000
--- a/framework/core/ember/forum/app/views/discussion.js
+++ /dev/null
@@ -1,91 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-import DropdownSplit from 'flarum-forum/components/ui/dropdown-split';
-import StreamScrubber from 'flarum-forum/components/discussion/stream-scrubber';
-
-var $ = Ember.$;
-
-export default Ember.View.extend(HasItemLists, {
- itemLists: ['sidebar'],
-
- discussion: Ember.computed.alias('controller.model'),
-
- didInsertElement: function() {
- this.get('controller').on('loaded', this, this.loaded);
- this.get('controller').on('startWasChanged', this, this.startWasChanged);
- },
-
- willDestroyElement: function() {
- this.get('controller').off('loaded', this, this.loaded);
- this.get('controller').off('startWasChanged', this, this.startWasChanged);
- },
-
- // When the controller has finished loading, we want to scroll down to the
- // appropriate post instantly (without animation).
- loaded: function() {
- this.get('streamContent').send('goToNumber', this.get('controller.start'), true);
- },
-
- // When the start position of the discussion changes, we want to scroll
- // down to the appropriate post.
- startWasChanged: function(start) {
- this.get('streamContent').send('goToNumber', start);
- },
-
- // ------------------------------------------------------------------------
- // OBSERVERS
- // ------------------------------------------------------------------------
-
- // Whenever the model's title changes, we want to update that document's
- // title the reflect the new title.
- updateTitle: Ember.observer('controller.model.title', function() {
- this.set('controller.controllers.application.pageTitle', this.get('controller.model.title'));
- }),
-
- // ------------------------------------------------------------------------
- // LISTENERS
- // ------------------------------------------------------------------------
-
- populateSidebar: function(items) {
- items.pushObjectWithTag(DropdownSplit.extend({
- items: this.populateItemList('controls'),
- icon: 'reply',
- buttonClass: 'btn-primary',
- listItemClass: 'primary-control',
- }), 'controls');
- },
-
- addStreamScrubber: Ember.on('didInsertElement', function() {
- this.get('sidebar').pushObjectWithTag(StreamScrubber.extend({
- streamContent: this.get('streamContent'),
- listItemClass: 'title-control'
- }), 'scrubber');
- }),
-
- populateControls: function(items) {
- var view = this;
-
- this.addActionItem(items, 'reply', 'Reply', 'reply', null, function() {
- view.get('streamContent').send('goToLast');
- view.get('controller').send('reply');
- });
-
- this.addSeparatorItem(items);
-
- this.addActionItem(items, 'rename', 'Rename', 'pencil', 'discussion.canEdit', function() {
- var discussion = view.get('controller.model');
- var currentTitle = discussion.get('title');
- var title = prompt('Enter a new title for this discussion:', currentTitle);
- if (title && title !== currentTitle) {
- view.get('controller').send('rename', title);
- }
- });
-
- this.addActionItem(items, 'delete', 'Delete', 'times', 'discussion.canDelete', function() {
- if (confirm('Are you sure you want to delete this discussion?')) {
- view.get('controller').send('delete');
- }
- });
- }
-});
diff --git a/framework/core/ember/forum/app/views/index.js b/framework/core/ember/forum/app/views/index.js
deleted file mode 100644
index 532567e00..000000000
--- a/framework/core/ember/forum/app/views/index.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import Ember from 'ember';
-
-import DropdownSelect from 'flarum-forum/components/ui/dropdown-select';
-import ActionButton from 'flarum-forum/components/ui/action-button';
-import NavItem from 'flarum-forum/components/ui/nav-item';
-import WelcomeHero from 'flarum-forum/components/index/welcome-hero';
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-import config from 'flarum-forum/config/environment';
-
-var precompileTemplate = Ember.Handlebars.compile;
-var $ = Ember.$;
-
-export default Ember.View.extend(HasItemLists, {
- itemLists: ['sidebar'],
-
- didInsertElement: function() {
- this.set('hero', WelcomeHero.extend({
- title: this.get('controller.controllers.application.forumTitle'),
- description: config.welcomeDescription
- }));
-
- // Affix the sidebar so that when the user scrolls down it will stick
- // to the top of their viewport.
- var $sidebar = this.$('.index-nav');
- $sidebar.find('> ul').affix({
- offset: {
- top: function () {
- return $sidebar.offset().top - $('#header').outerHeight(true) - parseInt($sidebar.css('margin-top'));
- },
- bottom: function () {
- return (this.bottom = $('#footer').outerHeight(true));
- }
- }
- });
-
- // When viewing a discussion (for which the discussions route is the
- // parent,) the discussion list is still rendered but it becomes a
- // pane hidden on the side of the screen. When the mouse enters and
- // leaves the discussions pane, we want to show and hide the pane
- // respectively. We also create a 10px 'hot edge' on the left of the
- // screen to activate the pane.
- var controller = this.get('controller');
- this.$('.index-area').hover(function() {
- controller.send('showPane');
- }, function() {
- controller.send('hidePane');
- });
- $(document).on('mousemove.showPane', function(e) {
- if (e.pageX < 10) {
- controller.send('showPane');
- }
- });
- },
-
- willDestroyElement: function() {
- $(document).off('mousemove.showPane');
- },
-
- scrollToDiscussion: Ember.observer('controller.paned', function() {
- if (this.get('controller.paned')) {
- var view = this;
- Ember.run.scheduleOnce('afterRender', function() {
- var $index = view.$('.index-area');
- var $discussion = $index.find('.discussion-summary.active');
- if ($discussion.length) {
- var indexTop = $index.offset().top;
- var discussionTop = $discussion.offset().top;
- if (discussionTop < indexTop || discussionTop + $discussion.outerHeight() > indexTop + $index.outerHeight()) {
- $index.scrollTop($index.scrollTop() - indexTop + discussionTop);
- }
- }
- });
- }
- }),
-
- populateSidebar: function(items) {
- this.addActionItem(items, 'newDiscussion', 'Start a Discussion', 'edit')
- .reopen({className: 'btn btn-primary new-discussion', listItemClass: 'primary-control'});
-
- var nav = this.populateItemList('nav');
- items.pushObjectWithTag(DropdownSelect.extend({items: nav, listItemClass: 'title-control'}), 'nav');
- },
-
- populateNav: function(items) {
- items.pushObjectWithTag(NavItem.extend({
- label: 'All Discussions',
- icon: 'comments-o',
- layout: precompileTemplate('{{#link-to "index" (query-params filter="")}}{{fa-icon icon}} {{label}} {{badge}}{{/link-to}}')
- }), 'all');
-
- items.pushObjectWithTag(NavItem.extend({
- label: 'Private',
- icon: 'envelope-o',
- layout: precompileTemplate('{{#link-to "index" (query-params filter="private")}}{{fa-icon icon}} {{label}} {{badge}}{{/link-to}}')
- }), 'private');
-
- items.pushObjectWithTag(NavItem.extend({
- label: 'Following',
- icon: 'star',
- layout: precompileTemplate('{{#link-to "index" (query-params filter="following")}}{{fa-icon icon}} {{label}} {{badge}}{{/link-to}}')
- }), 'following');
- }
-});
diff --git a/framework/core/ember/forum/app/views/index/index.js b/framework/core/ember/forum/app/views/index/index.js
deleted file mode 100644
index bd0fbf393..000000000
--- a/framework/core/ember/forum/app/views/index/index.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.View.extend({
- didInsertElement: function() {
- this.updateTitle();
- var scrollTop = this.get('controller.scrollTop');
- $(window).scrollTop(scrollTop);
-
- var lastDiscussion = this.get('controller.lastDiscussion');
- if (lastDiscussion) {
- var $discussion = $('.index-area .discussion-summary[data-id='+lastDiscussion.get('id')+']');
- if ($discussion.length) {
- var indexTop = $('#header').outerHeight();
- var discussionTop = $discussion.offset().top;
- if (discussionTop < scrollTop + indexTop || discussionTop + $discussion.outerHeight() > scrollTop + $(window).height()) {
- $(window).scrollTop(discussionTop - indexTop);
- }
- }
- }
- },
-
- willDestroyElement: function() {
- this.set('controller.scrollTop', $(window).scrollTop());
- },
-
- updateTitle: Ember.observer('controller.searchQuery', function() {
- var q = this.get('controller.searchQuery');
- this.get('controller.controllers.application').set('pageTitle', q ? '"'+q+'"' : '');
- })
-});
diff --git a/framework/core/ember/forum/app/views/login.js b/framework/core/ember/forum/app/views/login.js
deleted file mode 100644
index 7368adc19..000000000
--- a/framework/core/ember/forum/app/views/login.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import Ember from 'ember';
-
-import ModalView from 'flarum-forum/mixins/modal-view';
-
-export default Ember.View.extend(ModalView, {
- classNames: ['modal-dialog', 'modal-sm', 'modal-login'],
- templateName: 'login',
-
- didInsertElement: function() {
- this.get('controller.session').on('sessionAuthenticationSucceeded', this, this.hide);
-
- this.get('controller').on('refocus', this, this.refocus);
- },
-
- refocus: function() {
- Ember.run.scheduleOnce('afterRender', this, function() {
- this.$('input[name=password]').select();
- });
- },
-
- willDestroyElement: function() {
- this.get('controller.session').off('sessionAuthenticationSucceeded', this, this.hide);
-
- this.get('controller').off('refocus', this, this.refocus);
- }
-});
diff --git a/framework/core/ember/forum/app/views/signup.js b/framework/core/ember/forum/app/views/signup.js
deleted file mode 100644
index 3c64ec566..000000000
--- a/framework/core/ember/forum/app/views/signup.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import Ember from 'ember';
-
-import ModalView from 'flarum-forum/mixins/modal-view';
-
-export default Ember.View.extend(ModalView, {
- classNames: ['modal-dialog', 'modal-sm', 'modal-signup'],
- templateName: 'signup',
-
- didInsertElement: function() {
- },
-
- welcomeUserDidChange: Ember.observer('controller.welcomeUser', function() {
- if (this.get('controller.welcomeUser')) {
- Ember.run.scheduleOnce('afterRender', this, function() {
- this.$('.signup-welcome').hide().fadeIn();
- });
- }
- })
-});
diff --git a/framework/core/ember/forum/app/views/user.js b/framework/core/ember/forum/app/views/user.js
deleted file mode 100644
index fd7d1afd5..000000000
--- a/framework/core/ember/forum/app/views/user.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-import NavItem from 'flarum-forum/components/ui/nav-item';
-import DropdownSelect from 'flarum-forum/components/ui/dropdown-select';
-
-var precompileTemplate = Ember.Handlebars.compile;
-
-export default Ember.View.extend(HasItemLists, {
- itemLists: ['sidebar'],
-
- didInsertElement: function() {
- // Affix the sidebar so that when the user scrolls down it will stick
- // to the top of their viewport.
- var $sidebar = this.$('.user-nav');
- $sidebar.find('> ul').affix({
- offset: {
- top: function () {
- return $sidebar.offset().top - $('#header').outerHeight(true) - parseInt($sidebar.css('margin-top'));
- },
- bottom: function () {
- return (this.bottom = $('#footer').outerHeight(true));
- }
- }
- });
- },
-
- populateSidebar: function(items) {
- var nav = this.populateItemList('nav');
- items.pushObjectWithTag(DropdownSelect.extend({items: nav, listItemClass: 'title-control'}), 'nav');
- },
-
- populateNav: function(items) {
- var HasUser = Ember.Mixin.create({
- parentController: this.get('controller'),
- user: Ember.computed.alias('parentController.model')
- });
-
- items.pushObjectWithTag(NavItem.extend(HasUser, {
- label: 'Activity',
- icon: 'user',
- layout: precompileTemplate('{{#link-to "user.activity" (query-params filter="")}}{{fa-icon icon}} {{label}}{{/link-to}}')
- }), 'activity');
-
- items.pushObjectWithTag(NavItem.extend(HasUser, {
- label: 'Discussions',
- icon: 'reorder',
- badge: Ember.computed.alias('user.discussionsCount'),
- layout: precompileTemplate('{{#link-to "user.activity" (query-params filter="discussions")}}{{fa-icon icon}} {{label}} {{badge}}{{/link-to}}')
- }), 'discussions');
-
- items.pushObjectWithTag(NavItem.extend(HasUser, {
- label: 'Posts',
- icon: 'comment-o',
- badge: Ember.computed.alias('parentController.model.commentsCount'),
- layout: precompileTemplate('{{#link-to "user.activity" (query-params filter="posts")}}{{fa-icon icon}} {{label}} {{badge}}{{/link-to}}')
- }), 'posts');
-
- this.addSeparatorItem(items);
-
- if (this.get('controller.model') === this.get('controller.session.user')) {
- items.pushObjectWithTag(NavItem.extend({
- label: 'Settings',
- icon: 'cog',
- layout: precompileTemplate('{{#link-to "user.settings"}}{{fa-icon icon}} {{label}}{{/link-to}}')
- }), 'settings');
- }
- }
-});
diff --git a/framework/core/ember/forum/app/views/user/activity.js b/framework/core/ember/forum/app/views/user/activity.js
deleted file mode 100644
index 08e850b4b..000000000
--- a/framework/core/ember/forum/app/views/user/activity.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.View.extend({
- classNames: ['user-activity']
-});
diff --git a/framework/core/ember/forum/app/views/user/settings.js b/framework/core/ember/forum/app/views/user/settings.js
deleted file mode 100644
index 3612065f5..000000000
--- a/framework/core/ember/forum/app/views/user/settings.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import Ember from 'ember';
-
-import HasItemLists from 'flarum-forum/mixins/has-item-lists';
-import NotificationGrid from 'flarum-forum/components/user/notification-grid';
-import FieldSet from 'flarum-forum/components/ui/field-set';
-import ActionButton from 'flarum-forum/components/ui/action-button';
-import SwitchInput from 'flarum-forum/components/ui/switch-input';
-
-export default Ember.View.extend(HasItemLists, {
- itemLists: ['settings'],
- classNames: ['settings'],
-
- populateSettings: function(items) {
- items.pushObjectWithTag(FieldSet.extend({
- label: 'Account',
- className: 'settings-account',
- fields: this.populateItemList('account')
- }), 'account');
-
- items.pushObjectWithTag(FieldSet.extend({
- label: 'Notifications',
- fields: [NotificationGrid.extend({
- notificationTypes: this.populateItemList('notificationTypes'),
- user: this.get('controller.model')
- })]
- }), 'notifications');
-
- items.pushObjectWithTag(FieldSet.extend({
- label: 'Privacy',
- fields: this.populateItemList('privacy')
- }), 'privacy');
- },
-
- populateAccount: function(items) {
- items.pushObjectWithTag(ActionButton.extend({
- label: 'Change Password',
- className: 'btn btn-default'
- }), 'changePassword');
-
- items.pushObjectWithTag(ActionButton.extend({
- label: 'Change Email',
- className: 'btn btn-default'
- }), 'changeEmail');
-
- items.pushObjectWithTag(ActionButton.extend({
- label: 'Delete Account',
- className: 'btn btn-default btn-danger'
- }), 'deleteAccount');
- },
-
- updateSetting: function(key) {
- var controller = this.get('controller');
- return function(value, component) {
- component.set('loading', true);
- var user = controller.get('model');
- user.set(key, value).save().then(function() {
- component.set('loading', false);
- });
- };
- },
-
- populatePrivacy: function(items) {
- var self = this;
-
- items.pushObjectWithTag(SwitchInput.extend({
- label: 'Allow others to see when I am online',
- parentController: this.get('controller'),
- toggleState: Ember.computed.alias('parentController.model.preferences.discloseOnline'),
- changed: function(value, component) {
- self.set('controller.model.lastSeenTime', null);
- self.updateSetting('preferences.discloseOnline')(value, component);
- }
- }), 'discloseOnline');
-
- items.pushObjectWithTag(SwitchInput.extend({
- label: 'Allow search engines to index my profile',
- parentController: this.get('controller'),
- toggleState: Ember.computed.alias('parentController.model.preferences.indexProfile'),
- changed: this.updateSetting('preferences.indexProfile')
- }), 'indexProfile');
- },
-
- populateNotificationTypes: function(items) {
- items.pushObjectWithTag({
- name: 'discussionRenamed',
- label: 'Someone renames a discussion I started'
- }, 'discussionRenamed');
- }
-});
diff --git a/framework/core/ember/forum/bower.json b/framework/core/ember/forum/bower.json
deleted file mode 100644
index a7070a909..000000000
--- a/framework/core/ember/forum/bower.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "name": "flarum-forum",
- "dependencies": {
- "jquery": "2.1.3",
- "ember": "1.11.0-beta.3",
- "ember-data": "1.0.0-beta.16.1",
- "ember-resolver": "~0.1.11",
- "loader.js": "ember-cli/loader.js#1.0.1",
- "ember-cli-shims": "ember-cli/ember-cli-shims#0.0.3",
- "ember-cli-test-loader": "0.1.3",
- "ember-load-initializers": "ember-cli/ember-load-initializers#0.0.2",
- "ember-qunit": "0.2.8",
- "ember-qunit-notifications": "0.0.7",
- "qunit": "~1.17.1",
- "bootstrap": "~3.3.2",
- "font-awesome": "~4",
- "spin.js": "~2.0.1",
- "moment": "~2.8.4",
- "ember-simple-auth": "0.7.2",
- "jquery.hotkeys": "jeresig/jquery.hotkeys#0.2.0",
- "blurjs": ""
- },
- "resolutions": {
- "ember-cli-test-loader": "0.1.3",
- "ember-qunit": "0.2.8",
- "ember-qunit-notifications": "0.0.7"
- }
-}
diff --git a/framework/core/ember/forum/config/environment.js b/framework/core/ember/forum/config/environment.js
deleted file mode 100644
index ce7dffa17..000000000
--- a/framework/core/ember/forum/config/environment.js
+++ /dev/null
@@ -1,53 +0,0 @@
-/* jshint node: true */
-
-module.exports = function(environment) {
- var ENV = {
- modulePrefix: 'flarum-forum',
- environment: environment,
- baseURL: '/',
- apiURL: '/api',
- locationType: 'hash',
- EmberENV: {
- FEATURES: {
- // Here you can enable experimental features on an ember canary build
- // e.g. 'with-controller': true
- }
- },
-
- APP: {
- // Here you can pass flags/options to your application instance
- // when it is created
- }
- };
-
- ENV['simple-auth'] = {
- authorizer: 'authorizer:flarum'
- };
-
- if (environment === 'development') {
- // ENV.APP.LOG_RESOLVER = true;
- // ENV.APP.LOG_ACTIVE_GENERATION = true;
- // ENV.APP.LOG_TRANSITIONS = true;
- // ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
- // ENV.APP.LOG_VIEW_LOOKUPS = true;
- }
-
- if (environment === 'test') {
- // Testem prefers this...
- ENV.baseURL = '/';
- ENV.locationType = 'none';
- ENV.apiURL = 'http://flarum.dev/api',
-
- // keep test console output quieter
- ENV.APP.LOG_ACTIVE_GENERATION = false;
- ENV.APP.LOG_VIEW_LOOKUPS = false;
-
- ENV.APP.rootElement = '#ember-testing';
- }
-
- if (environment === 'production') {
-
- }
-
- return ENV;
-};
diff --git a/framework/core/ember/forum/package.json b/framework/core/ember/forum/package.json
deleted file mode 100644
index b0ce2f6d0..000000000
--- a/framework/core/ember/forum/package.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "name": "flarum-forum",
- "version": "0.0.0",
- "private": true,
- "directories": {
- "doc": "doc",
- "test": "tests"
- },
- "scripts": {
- "start": "ember server",
- "build": "ember build",
- "test": "ember test",
- "preinstall": "sudo npm link ../common"
- },
- "repository": "",
- "engines": {
- "node": ">= 0.10.0"
- },
- "author": "",
- "license": "MIT",
- "devDependencies": {
- "ember-cli": "^0.2.0-beta.1",
- "ember-cli-app-version": "0.3.1",
- "ember-cli-babel": "^4.1.0",
- "ember-cli-content-security-policy": "0.3.0",
- "ember-cli-dependency-checker": "0.0.7",
- "ember-cli-htmlbars": "^0.7.4",
- "ember-cli-ic-ajax": "0.1.1",
- "ember-cli-inject-live-reload": "^1.3.0",
- "ember-cli-qunit": "^0.3.8",
- "ember-cli-simple-auth": "^0.7.2",
- "ember-cli-uglify": "1.0.1",
- "ember-data": "1.0.0-beta.16.1",
- "ember-export-application-global": "^1.0.2",
- "ember-json-api": "eneuhauser/ember-json-api",
- "broccoli-ember-inline-template-compiler": "tobscure/broccoli-ember-inline-template-compiler#f884d11",
- "express": "^4.8.5",
- "glob": "^4.0.5",
- "flarum-common": "*"
- }
-}
diff --git a/framework/core/ember/forum/testem.json b/framework/core/ember/forum/testem.json
deleted file mode 100644
index 42a4ddb22..000000000
--- a/framework/core/ember/forum/testem.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "framework": "qunit",
- "test_page": "tests/index.html?hidepassed",
- "launch_in_ci": [
- "PhantomJS"
- ],
- "launch_in_dev": [
- "PhantomJS",
- "Chrome"
- ]
-}
diff --git a/framework/core/ember/forum/tests/.jshintrc b/framework/core/ember/forum/tests/.jshintrc
deleted file mode 100644
index 6ebf71a02..000000000
--- a/framework/core/ember/forum/tests/.jshintrc
+++ /dev/null
@@ -1,74 +0,0 @@
-{
- "predef": [
- "document",
- "window",
- "location",
- "setTimeout",
- "$",
- "-Promise",
- "QUnit",
- "define",
- "console",
- "equal",
- "notEqual",
- "notStrictEqual",
- "test",
- "asyncTest",
- "testBoth",
- "testWithDefault",
- "raises",
- "throws",
- "deepEqual",
- "start",
- "stop",
- "ok",
- "strictEqual",
- "module",
- "moduleFor",
- "moduleForComponent",
- "moduleForModel",
- "process",
- "expect",
- "visit",
- "exists",
- "fillIn",
- "click",
- "keyEvent",
- "triggerEvent",
- "find",
- "findWithAssert",
- "wait",
- "DS",
- "isolatedContainer",
- "startApp",
- "andThen",
- "currentURL",
- "currentPath",
- "currentRouteName"
- ],
- "node": false,
- "browser": false,
- "boss": true,
- "curly": false,
- "debug": false,
- "devel": false,
- "eqeqeq": true,
- "evil": true,
- "forin": false,
- "immed": false,
- "laxbreak": false,
- "newcap": true,
- "noarg": true,
- "noempty": false,
- "nonew": false,
- "nomen": false,
- "onevar": false,
- "plusplus": false,
- "regexp": false,
- "undef": true,
- "sub": true,
- "strict": false,
- "white": false,
- "eqnull": true,
- "esnext": true
-}
diff --git a/framework/core/ember/forum/tests/helpers/resolver.js b/framework/core/ember/forum/tests/helpers/resolver.js
deleted file mode 100644
index 28f4ece46..000000000
--- a/framework/core/ember/forum/tests/helpers/resolver.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import Resolver from 'ember/resolver';
-import config from '../../config/environment';
-
-var resolver = Resolver.create();
-
-resolver.namespace = {
- modulePrefix: config.modulePrefix,
- podModulePrefix: config.podModulePrefix
-};
-
-export default resolver;
diff --git a/framework/core/ember/forum/tests/helpers/start-app.js b/framework/core/ember/forum/tests/helpers/start-app.js
deleted file mode 100644
index 16cc7c398..000000000
--- a/framework/core/ember/forum/tests/helpers/start-app.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import Ember from 'ember';
-import Application from '../../app';
-import Router from '../../router';
-import config from '../../config/environment';
-
-export default function startApp(attrs) {
- var application;
-
- var attributes = Ember.merge({}, config.APP);
- attributes = Ember.merge(attributes, attrs); // use defaults, but you can override;
-
- Ember.run(function() {
- application = Application.create(attributes);
- application.setupForTesting();
- application.injectTestHelpers();
- });
-
- return application;
-}
diff --git a/framework/core/ember/forum/tests/index.html b/framework/core/ember/forum/tests/index.html
deleted file mode 100644
index 9990b47c7..000000000
--- a/framework/core/ember/forum/tests/index.html
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
- Flarum Tests
-
-
-
- {{content-for 'head'}}
- {{content-for 'test-head'}}
-
-
-
-
-
- {{content-for 'head-footer'}}
- {{content-for 'test-head-footer'}}
-
-
-
- {{content-for 'body'}}
- {{content-for 'test-body'}}
-
-
-
-
-
-
- {{content-for 'body-footer'}}
- {{content-for 'test-body-footer'}}
-
-
diff --git a/framework/core/ember/forum/tests/integration/index-test.js b/framework/core/ember/forum/tests/integration/index-test.js
deleted file mode 100644
index 859e5657d..000000000
--- a/framework/core/ember/forum/tests/integration/index-test.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import Ember from "ember";
-import { test } from 'ember-qunit';
-import startApp from '../helpers/start-app';
-var App;
-
-module('Index', {
- setup: function() {
- App = startApp();
- },
- teardown: function() {
- Ember.run(App, App.destroy);
- }
-});
-
-test('Discussion list loading', function() {
- expect(3);
- visit('/').then(function() {
- equal(find('.discussions-list').length, 1, 'Page contains list of discussions');
- equal(find('.discussions-list li').length, 20, 'There are 20 discussions in the list');
-
- click('.control-loadMore').then(function() {
- equal(find('.discussions-list li').length, 40, 'There are 40 discussions in the list');
- });
- });
-});
-
-test('Discussion list sorting', function() {
- expect(1);
- visit('/').then(function() {
- fillIn('.control-sort select', 'replies').then(function() {
- var discussions = find('.discussions-list li');
- var good = true;
- var getCount = function(item) {
- return parseInt(item.find('.count strong').text());
- };
- var previousCount = getCount(discussions.eq(0));
- for (var i = 1; i < discussions.length; i++) {
- var count = getCount(discussions.eq(i));
- if (count > previousCount) {
- good = false;
- break;
- }
- previousCount = count;
- }
- ok(good, 'Discussions are listed in order of reply count');
- });
- });
-});
\ No newline at end of file
diff --git a/framework/core/ember/forum/tests/test-helper.js b/framework/core/ember/forum/tests/test-helper.js
deleted file mode 100644
index e6cfb70fe..000000000
--- a/framework/core/ember/forum/tests/test-helper.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import resolver from './helpers/resolver';
-import {
- setResolver
-} from 'ember-qunit';
-
-setResolver(resolver);
diff --git a/framework/core/ember/forum/tests/unit/.gitkeep b/framework/core/ember/forum/tests/unit/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/framework/core/js/admin/.gitignore b/framework/core/js/admin/.gitignore
new file mode 100644
index 000000000..bae304483
--- /dev/null
+++ b/framework/core/js/admin/.gitignore
@@ -0,0 +1,4 @@
+bower_components
+node_modules
+mithril.js
+dist
diff --git a/framework/core/js/admin/Gulpfile.js b/framework/core/js/admin/Gulpfile.js
new file mode 100644
index 000000000..9f173606d
--- /dev/null
+++ b/framework/core/js/admin/Gulpfile.js
@@ -0,0 +1,51 @@
+var gulp = require('gulp');
+var livereload = require('gulp-livereload');
+var concat = require('gulp-concat');
+var argv = require('yargs').argv;
+var uglify = require('gulp-uglify');
+var gulpif = require('gulp-if');
+var merge = require('merge-stream');
+var babel = require('gulp-babel');
+var cached = require('gulp-cached');
+var remember = require('gulp-remember');
+
+var vendorFiles = [
+ './bower_components/loader.js/loader.js',
+ './bower_components/mithril/mithril.js',
+ './bower_components/jquery/dist/jquery.js',
+ './bower_components/moment/moment.js',
+ './bower_components/bootstrap/dist/js/bootstrap.js',
+ './bower_components/spin.js/spin.js',
+ './bower_components/spin.js/jquery.spin.js'
+];
+
+var moduleFiles = [
+ 'src/**/*.js',
+ '../lib/**/*.js'
+];
+var modulePrefix = 'flarum';
+
+gulp.task('default', function() {
+ return merge(
+ gulp.src(vendorFiles),
+ gulp.src(moduleFiles)
+ .pipe(cached('scripts'))
+ .pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix }))
+ .pipe(remember('scripts'))
+ )
+ .pipe(concat('app.js'))
+ .pipe(gulpif(argv.production, uglify()))
+ .pipe(gulp.dest('dist'))
+ .pipe(livereload());
+});
+
+gulp.task('watch', ['default'], function () {
+ livereload.listen();
+ var watcher = gulp.watch(moduleFiles.concat(vendorFiles), ['default']);
+ watcher.on('change', function (event) {
+ if (event.type === 'deleted') {
+ delete cached.caches.scripts[event.path];
+ remember.forget('scripts', event.path);
+ }
+ });
+});
diff --git a/framework/core/js/admin/bower.json b/framework/core/js/admin/bower.json
new file mode 100644
index 000000000..175d2317e
--- /dev/null
+++ b/framework/core/js/admin/bower.json
@@ -0,0 +1,13 @@
+{
+ "name": "flarum-forum",
+ "dependencies": {
+ "jquery": "2.1.3",
+ "jquery.hotkeys": "jeresig/jquery.hotkeys#0.2.0",
+ "bootstrap": "~3.3.2",
+ "spin.js": "~2.0.1",
+ "moment": "~2.8.4",
+ "color-thief": "v2.0",
+ "mithril": "lhorie/mithril.js#components",
+ "loader.js": "~3.2.1"
+ }
+}
diff --git a/framework/core/js/admin/package.json b/framework/core/js/admin/package.json
new file mode 100644
index 000000000..9fc67619d
--- /dev/null
+++ b/framework/core/js/admin/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "flarum-forum",
+ "devDependencies": {
+ "gulp": "^3.8.11",
+ "gulp-babel": "^5.1.0",
+ "gulp-cached": "^1.0.4",
+ "gulp-concat": "^2.5.2",
+ "gulp-if": "^1.2.5",
+ "gulp-livereload": "^3.8.0",
+ "gulp-remember": "^0.3.0",
+ "gulp-uglify": "^1.2.0",
+ "merge-stream": "^0.1.7",
+ "yargs": "^3.7.2"
+ }
+}
diff --git a/framework/core/js/admin/src/app.js b/framework/core/js/admin/src/app.js
new file mode 100644
index 000000000..fa4c7f067
--- /dev/null
+++ b/framework/core/js/admin/src/app.js
@@ -0,0 +1,18 @@
+import App from 'flarum/utils/app';
+import store from 'flarum/initializers/store';
+import preload from 'flarum/initializers/preload';
+import session from 'flarum/initializers/session';
+import routes from 'flarum/initializers/routes';
+import timestamps from 'flarum/initializers/timestamps';
+import boot from 'flarum/initializers/boot';
+
+var app = new App();
+
+app.initializers.add('store', store);
+app.initializers.add('preload', preload);
+app.initializers.add('session', session);
+app.initializers.add('routes', routes);
+app.initializers.add('timestamps', timestamps);
+app.initializers.add('boot', boot, {last: true});
+
+export default app;
diff --git a/framework/core/js/admin/src/components/admin-nav-item.js b/framework/core/js/admin/src/components/admin-nav-item.js
new file mode 100644
index 000000000..2f05cab67
--- /dev/null
+++ b/framework/core/js/admin/src/components/admin-nav-item.js
@@ -0,0 +1,14 @@
+import Component from 'flarum/component';
+import icon from 'flarum/helpers/icon';
+import NavItem from 'flarum/components/nav-item';
+
+export default class AdminNavItem extends NavItem {
+ view() {
+ var active = this.constructor.active(this.props);
+ return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route}, [
+ icon(this.props.icon+' icon'),
+ m('span.label', this.props.label),
+ m('div.description', this.props.description)
+ ]))
+ }
+}
diff --git a/framework/core/js/admin/src/components/admin-nav.js b/framework/core/js/admin/src/components/admin-nav.js
new file mode 100644
index 000000000..bd9c042ee
--- /dev/null
+++ b/framework/core/js/admin/src/components/admin-nav.js
@@ -0,0 +1,54 @@
+import Component from 'flarum/component';
+import UserDropdown from 'flarum/components/user-dropdown';
+import AdminNavItem from 'flarum/components/admin-nav-item';
+import DropdownSelect from 'flarum/components/dropdown-select';
+
+import ItemList from 'flarum/utils/item-list';
+import listItems from 'flarum/helpers/list-items';
+
+export default class AdminNav extends Component {
+ view() {
+ return DropdownSelect.component({ items: this.items().toArray() });
+ }
+
+ items() {
+ var items = new ItemList();
+
+ items.add('dashboard', AdminNavItem.component({
+ href: app.route('dashboard'),
+ icon: 'bar-chart',
+ label: 'Dashboard',
+ description: 'Your forum at a glance.'
+ }));
+
+ items.add('basics', AdminNavItem.component({
+ href: app.route('basics'),
+ icon: 'pencil',
+ label: 'Basics',
+ description: 'Set your forum title, language, and other basic settings.'
+ }));
+
+ items.add('permissions', AdminNavItem.component({
+ href: app.route('permissions'),
+ icon: 'key',
+ label: 'Permissions',
+ description: 'Configure who can see and do what.'
+ }));
+
+ items.add('appearance', AdminNavItem.component({
+ href: app.route('appearance'),
+ icon: 'paint-brush',
+ label: 'Appearance',
+ description: 'Customize your forum\'s colors, logos, and other variables.'
+ }));
+
+ items.add('extensions', AdminNavItem.component({
+ href: app.route('extensions'),
+ icon: 'puzzle-piece',
+ label: 'Extensions',
+ description: 'Add extra functionality to your forum and make it your own.'
+ }));
+
+ return items;
+ }
+}
diff --git a/framework/core/js/admin/src/components/appearance-page.js b/framework/core/js/admin/src/components/appearance-page.js
new file mode 100644
index 000000000..5d1ddd2f1
--- /dev/null
+++ b/framework/core/js/admin/src/components/appearance-page.js
@@ -0,0 +1,7 @@
+import Component from 'flarum/component';
+
+export default class AppearancePage extends Component {
+ view() {
+ return m('div', 'appearance');
+ }
+};
diff --git a/framework/core/js/admin/src/components/basics-page.js b/framework/core/js/admin/src/components/basics-page.js
new file mode 100644
index 000000000..c5baf8a77
--- /dev/null
+++ b/framework/core/js/admin/src/components/basics-page.js
@@ -0,0 +1,7 @@
+import Component from 'flarum/component';
+
+export default class BasicsPage extends Component {
+ view() {
+ return m('div', 'basics');
+ }
+};
diff --git a/framework/core/js/admin/src/components/dashboard-page.js b/framework/core/js/admin/src/components/dashboard-page.js
new file mode 100644
index 000000000..d9dc00acb
--- /dev/null
+++ b/framework/core/js/admin/src/components/dashboard-page.js
@@ -0,0 +1,7 @@
+import Component from 'flarum/component';
+
+export default class DashboardPage extends Component {
+ view() {
+ return m('div', 'dashboard');
+ }
+};
diff --git a/framework/core/js/admin/src/components/extensions-page.js b/framework/core/js/admin/src/components/extensions-page.js
new file mode 100644
index 000000000..c1fe5e793
--- /dev/null
+++ b/framework/core/js/admin/src/components/extensions-page.js
@@ -0,0 +1,7 @@
+import Component from 'flarum/component';
+
+export default class ExtensionsPage extends Component {
+ view() {
+ return m('div', 'extensions');
+ }
+};
diff --git a/framework/core/js/admin/src/components/header-primary.js b/framework/core/js/admin/src/components/header-primary.js
new file mode 100644
index 000000000..166a7b6cc
--- /dev/null
+++ b/framework/core/js/admin/src/components/header-primary.js
@@ -0,0 +1,15 @@
+import Component from 'flarum/component';
+import ItemList from 'flarum/utils/item-list';
+import listItems from 'flarum/helpers/list-items';
+
+export default class HeaderPrimary extends Component {
+ view() {
+ return m('ul.header-controls', listItems(this.items().toArray()));
+ }
+
+ items() {
+ var items = new ItemList();
+
+ return items;
+ }
+}
diff --git a/framework/core/js/admin/src/components/header-secondary.js b/framework/core/js/admin/src/components/header-secondary.js
new file mode 100644
index 000000000..8c7be583d
--- /dev/null
+++ b/framework/core/js/admin/src/components/header-secondary.js
@@ -0,0 +1,19 @@
+import Component from 'flarum/component';
+import UserDropdown from 'flarum/components/user-dropdown';
+
+import ItemList from 'flarum/utils/item-list';
+import listItems from 'flarum/helpers/list-items';
+
+export default class HeaderSecondary extends Component {
+ view() {
+ return m('ul.header-controls', listItems(this.items().toArray()));
+ }
+
+ items() {
+ var items = new ItemList();
+
+ items.add('user', UserDropdown.component({ user: app.session.user() }));
+
+ return items;
+ }
+}
diff --git a/framework/core/js/admin/src/components/permissions-page.js b/framework/core/js/admin/src/components/permissions-page.js
new file mode 100644
index 000000000..bc4c0ba4a
--- /dev/null
+++ b/framework/core/js/admin/src/components/permissions-page.js
@@ -0,0 +1,7 @@
+import Component from 'flarum/component';
+
+export default class PermissionsPage extends Component {
+ view() {
+ return m('div', 'permissions');
+ }
+};
diff --git a/framework/core/js/admin/src/components/user-dropdown.js b/framework/core/js/admin/src/components/user-dropdown.js
new file mode 100644
index 000000000..1bbbc60f5
--- /dev/null
+++ b/framework/core/js/admin/src/components/user-dropdown.js
@@ -0,0 +1,35 @@
+import Component from 'flarum/component';
+import avatar from 'flarum/helpers/avatar';
+import username from 'flarum/helpers/username';
+import DropdownButton from 'flarum/components/dropdown-button';
+import ActionButton from 'flarum/components/action-button';
+import ItemList from 'flarum/utils/item-list';
+import Separator from 'flarum/components/separator';
+
+export default class UserDropdown extends Component {
+ view() {
+ var user = this.props.user;
+
+ return DropdownButton.component({
+ buttonClass: 'btn btn-default btn-naked btn-rounded btn-user',
+ menuClass: 'pull-right',
+ buttonContent: [avatar(user), ' ', m('span.label', username(user))],
+ items: this.items().toArray()
+ });
+ }
+
+ items() {
+ var items = new ItemList();
+ var user = this.props.user;
+
+ items.add('logOut',
+ ActionButton.component({
+ icon: 'sign-out',
+ label: 'Log Out',
+ onclick: app.session.logout.bind(app.session)
+ })
+ );
+
+ return items;
+ }
+}
diff --git a/framework/core/js/admin/src/initializers/boot.js b/framework/core/js/admin/src/initializers/boot.js
new file mode 100644
index 000000000..52e4a4574
--- /dev/null
+++ b/framework/core/js/admin/src/initializers/boot.js
@@ -0,0 +1,38 @@
+import ScrollListener from 'flarum/utils/scroll-listener';
+import mapRoutes from 'flarum/utils/map-routes';
+
+import BackButton from 'flarum/components/back-button';
+import HeaderPrimary from 'flarum/components/header-primary';
+import HeaderSecondary from 'flarum/components/header-secondary';
+import Modal from 'flarum/components/modal';
+import Alerts from 'flarum/components/alerts';
+import AdminNav from 'flarum/components/admin-nav';
+
+export default function(app) {
+ var id = id => document.getElementById(id);
+
+ app.history = {
+ back: function() {
+ window.location = 'http://flarum.dev';
+ },
+ canGoBack: function() {
+ return true;
+ }
+ };
+
+ m.mount(id('back-control'), BackButton.component({ className: 'back-control', drawer: true }));
+ m.mount(id('back-button'), BackButton.component());
+
+ m.mount(id('header-primary'), HeaderPrimary.component());
+ m.mount(id('header-secondary'), HeaderSecondary.component());
+
+ m.mount(id('admin-nav'), AdminNav.component());
+
+ app.modal = m.mount(id('modal'), Modal.component());
+ app.alerts = m.mount(id('alerts'), Alerts.component());
+
+ m.route.mode = 'hash';
+ m.route(id('content'), '/', mapRoutes(app.routes));
+
+ new ScrollListener(top => $('body').toggleClass('scrolled', top > 0)).start();
+}
diff --git a/framework/core/js/admin/src/initializers/routes.js b/framework/core/js/admin/src/initializers/routes.js
new file mode 100644
index 000000000..d0519bf82
--- /dev/null
+++ b/framework/core/js/admin/src/initializers/routes.js
@@ -0,0 +1,15 @@
+import DashboardPage from 'flarum/components/dashboard-page';
+import BasicsPage from 'flarum/components/basics-page';
+import PermissionsPage from 'flarum/components/permissions-page';
+import AppearancePage from 'flarum/components/appearance-page';
+import ExtensionsPage from 'flarum/components/extensions-page';
+
+export default function(app) {
+ app.routes = {
+ 'dashboard': ['/', DashboardPage.component()],
+ 'basics': ['/basics', BasicsPage.component()],
+ 'permissions': ['/permissions', PermissionsPage.component()],
+ 'appearance': ['/appearance', AppearancePage.component()],
+ 'extensions': ['/extensions', ExtensionsPage.component()]
+ };
+}
diff --git a/framework/core/js/forum/.gitignore b/framework/core/js/forum/.gitignore
new file mode 100644
index 000000000..bae304483
--- /dev/null
+++ b/framework/core/js/forum/.gitignore
@@ -0,0 +1,4 @@
+bower_components
+node_modules
+mithril.js
+dist
diff --git a/framework/core/js/forum/Gulpfile.js b/framework/core/js/forum/Gulpfile.js
new file mode 100644
index 000000000..ee258528a
--- /dev/null
+++ b/framework/core/js/forum/Gulpfile.js
@@ -0,0 +1,53 @@
+var gulp = require('gulp');
+var livereload = require('gulp-livereload');
+var concat = require('gulp-concat');
+var argv = require('yargs').argv;
+var uglify = require('gulp-uglify');
+var gulpif = require('gulp-if');
+var merge = require('merge-stream');
+var babel = require('gulp-babel');
+var cached = require('gulp-cached');
+var remember = require('gulp-remember');
+
+var vendorFiles = [
+ './bower_components/loader.js/loader.js',
+ './bower_components/mithril/mithril.js',
+ './bower_components/jquery/dist/jquery.js',
+ './bower_components/jquery.hotkeys/jquery.hotkeys.js',
+ './bower_components/color-thief/js/color-thief.js',
+ './bower_components/moment/moment.js',
+ './bower_components/bootstrap/dist/js/bootstrap.js',
+ './bower_components/spin.js/spin.js',
+ './bower_components/spin.js/jquery.spin.js'
+];
+
+var moduleFiles = [
+ 'src/**/*.js',
+ '../lib/**/*.js'
+];
+var modulePrefix = 'flarum';
+
+gulp.task('default', function() {
+ return merge(
+ gulp.src(vendorFiles),
+ gulp.src(moduleFiles)
+ .pipe(cached('scripts'))
+ .pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix }))
+ .pipe(remember('scripts'))
+ )
+ .pipe(concat('app.js'))
+ .pipe(gulpif(argv.production, uglify()))
+ .pipe(gulp.dest('dist'))
+ .pipe(livereload());
+});
+
+gulp.task('watch', ['default'], function () {
+ livereload.listen();
+ var watcher = gulp.watch(moduleFiles.concat(vendorFiles), ['default']);
+ watcher.on('change', function (event) {
+ if (event.type === 'deleted') {
+ delete cached.caches.scripts[event.path];
+ remember.forget('scripts', event.path);
+ }
+ });
+});
diff --git a/framework/core/js/forum/bower.json b/framework/core/js/forum/bower.json
new file mode 100644
index 000000000..175d2317e
--- /dev/null
+++ b/framework/core/js/forum/bower.json
@@ -0,0 +1,13 @@
+{
+ "name": "flarum-forum",
+ "dependencies": {
+ "jquery": "2.1.3",
+ "jquery.hotkeys": "jeresig/jquery.hotkeys#0.2.0",
+ "bootstrap": "~3.3.2",
+ "spin.js": "~2.0.1",
+ "moment": "~2.8.4",
+ "color-thief": "v2.0",
+ "mithril": "lhorie/mithril.js#components",
+ "loader.js": "~3.2.1"
+ }
+}
diff --git a/framework/core/js/forum/package.json b/framework/core/js/forum/package.json
new file mode 100644
index 000000000..9fc67619d
--- /dev/null
+++ b/framework/core/js/forum/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "flarum-forum",
+ "devDependencies": {
+ "gulp": "^3.8.11",
+ "gulp-babel": "^5.1.0",
+ "gulp-cached": "^1.0.4",
+ "gulp-concat": "^2.5.2",
+ "gulp-if": "^1.2.5",
+ "gulp-livereload": "^3.8.0",
+ "gulp-remember": "^0.3.0",
+ "gulp-uglify": "^1.2.0",
+ "merge-stream": "^0.1.7",
+ "yargs": "^3.7.2"
+ }
+}
diff --git a/framework/core/js/forum/src/app.js b/framework/core/js/forum/src/app.js
new file mode 100644
index 000000000..fedf78534
--- /dev/null
+++ b/framework/core/js/forum/src/app.js
@@ -0,0 +1,20 @@
+import App from 'flarum/utils/app';
+import store from 'flarum/initializers/store';
+import preload from 'flarum/initializers/preload';
+import session from 'flarum/initializers/session';
+import routes from 'flarum/initializers/routes';
+import components from 'flarum/initializers/components';
+import timestamps from 'flarum/initializers/timestamps';
+import boot from 'flarum/initializers/boot';
+
+var app = new App();
+
+app.initializers.add('store', store);
+app.initializers.add('preload', preload);
+app.initializers.add('session', session);
+app.initializers.add('routes', routes);
+app.initializers.add('components', components);
+app.initializers.add('timestamps', timestamps);
+app.initializers.add('boot', boot, {last: true});
+
+export default app;
diff --git a/framework/core/js/forum/src/components/activity-join.js b/framework/core/js/forum/src/components/activity-join.js
new file mode 100644
index 000000000..048c295d1
--- /dev/null
+++ b/framework/core/js/forum/src/components/activity-join.js
@@ -0,0 +1,18 @@
+import Component from 'flarum/component';
+import humanTime from 'flarum/helpers/human-time';
+import avatar from 'flarum/helpers/avatar';
+
+export default class ActivityJoin extends Component {
+ view() {
+ var activity = this.props.activity;
+ var user = activity.user();
+
+ return m('div', [
+ avatar(user, {className: 'activity-icon'}),
+ m('div.activity-info', [
+ m('strong', 'Joined the forum'),
+ humanTime(activity.time())
+ ])
+ ]);
+ }
+}
diff --git a/framework/core/js/forum/src/components/activity-page.js b/framework/core/js/forum/src/components/activity-page.js
new file mode 100644
index 000000000..20cf759c4
--- /dev/null
+++ b/framework/core/js/forum/src/components/activity-page.js
@@ -0,0 +1,84 @@
+import UserPage from 'flarum/components/user-page';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+import ActionButton from 'flarum/components/action-button';
+
+export default class ActivityPage extends UserPage {
+ /**
+
+ */
+ constructor(props) {
+ super(props);
+
+ this.user = m.prop();
+ this.loading = m.prop(true);
+ this.moreResults = m.prop(false);
+ this.activity = m.prop([]);
+
+ var username = m.route.param('username').toLowerCase();
+ var users = app.store.all('users');
+ for (var id in users) {
+ if (users[id].username().toLowerCase() == username && users[id].joinTime()) {
+ this.setupUser(users[id]);
+ break;
+ }
+ }
+
+ if (!this.user()) {
+ app.store.find('users', username).then(this.setupUser.bind(this));
+ }
+ }
+
+ setupUser(user) {
+ m.startComputation();
+ this.user(user);
+ m.endComputation();
+
+ this.refresh();
+ }
+
+ refresh() {
+ m.startComputation();
+ this.loading(true);
+ this.activity([]);
+ m.endComputation();
+ this.loadResults().then(this.parseResults.bind(this));
+ }
+
+ loadResults(start) {
+ return app.store.find('activity', {
+ users: this.user().id(),
+ start,
+ type: this.props.filter
+ })
+ }
+
+ loadMore() {
+ var self = this;
+ this.loading(true);
+ this.loadResults(this.activity().length).then((results) => this.parseResults(results, true));
+ }
+
+ parseResults(results, append) {
+ this.loading(false);
+ [].push.apply(this.activity(), results);
+ this.moreResults(!!results.length);
+ m.redraw();
+ return results;
+ }
+
+ content() {
+ return m('div.user-activity', [
+ m('ul.activity-list', this.activity().map(activity => {
+ var ActivityComponent = app.activityComponentRegistry[activity.contentType()];
+ return ActivityComponent ? m('li', ActivityComponent.component({activity})) : '';
+ })),
+ this.loading()
+ ? LoadingIndicator.component()
+ : (this.moreResults() ? m('div.load-more', ActionButton.component({
+ label: 'Load More',
+ className: 'control-loadMore btn btn-default',
+ onclick: this.loadMore.bind(this)
+ })) : '')
+ ]);
+ }
+}
diff --git a/framework/core/js/forum/src/components/activity-post.js b/framework/core/js/forum/src/components/activity-post.js
new file mode 100644
index 000000000..868594c75
--- /dev/null
+++ b/framework/core/js/forum/src/components/activity-post.js
@@ -0,0 +1,28 @@
+import Component from 'flarum/component';
+import humanTime from 'flarum/helpers/human-time';
+import avatar from 'flarum/helpers/avatar';
+
+export default class ActivityPost extends Component {
+ view() {
+ var activity = this.props.activity;
+ var user = activity.user();
+ var post = activity.post();
+ var discussion = post.discussion();
+
+ return m('div', [
+ avatar(user, {className: 'activity-icon'}),
+ m('div.activity-info', [
+ m('strong', post.number() == 1 ? 'Started a discussion' : 'Posted a reply'),
+ humanTime(activity.time())
+ ]),
+ m('a.activity-content.activity-post', {href: app.route('discussion.near', {
+ id: discussion.id(),
+ slug: discussion.slug(),
+ near: post.number()
+ }), config: m.route}, [
+ m('h3.title', discussion.title()),
+ m('div.body', m.trust(post.contentHtml()))
+ ])
+ ]);
+ }
+}
diff --git a/framework/core/js/forum/src/components/avatar-editor.js b/framework/core/js/forum/src/components/avatar-editor.js
new file mode 100644
index 000000000..cd62f9b75
--- /dev/null
+++ b/framework/core/js/forum/src/components/avatar-editor.js
@@ -0,0 +1,70 @@
+import Component from 'flarum/component';
+import avatar from 'flarum/helpers/avatar';
+import icon from 'flarum/helpers/icon';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+
+export default class AvatarEditor extends Component {
+ constructor(props) {
+ super(props);
+
+ this.loading = m.prop(false);
+ }
+
+ view() {
+ var user = this.props.user;
+
+ return m('div.avatar-editor.dropdown', {
+ className: (this.loading() ? 'loading' : '')+' '+(this.props.className || '')
+ }, [
+ avatar(user),
+ m('a.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', {onclick: this.quickUpload.bind(this)}, [
+ this.loading() ? LoadingIndicator.component() : icon('pencil')
+ ]),
+ m('ul.dropdown-menu', [
+ m('li', m('a[href=javascript:;]', {onclick: this.upload.bind(this)}, [icon('upload'), ' Upload'])),
+ m('li', m('a[href=javascript:;]', {onclick: this.remove.bind(this)}, [icon('times'), ' Remove']))
+ ])
+ ]);
+ }
+
+ quickUpload(e) {
+ if (!this.props.user.avatarUrl()) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.upload();
+ }
+ }
+
+ upload() {
+ if (this.loading()) { return; }
+
+ var $input = $('');
+ var user = this.props.user;
+ var self = this;
+ $input.appendTo('body').hide().click().on('change', function() {
+ var data = new FormData();
+ data.append('avatar', $(this)[0].files[0]);
+ self.loading(true);
+ m.redraw();
+ m.request({
+ method: 'POST',
+ url: app.config.apiURL+'/users/'+user.id()+'/avatar',
+ data: data,
+ serialize: data => data,
+ background: true,
+ config: app.session.authorize.bind(app.session)
+ }).then(function(data) {
+ self.loading(false);
+ app.store.pushPayload(data);
+ delete user.avatarColor;
+ m.redraw();
+ });
+ });
+ }
+
+ remove() {
+ this.props.user.pushData({avatarUrl: null});
+ delete this.props.user.avatarColor;
+ m.redraw();
+ }
+}
diff --git a/framework/core/js/forum/src/components/composer-body.js b/framework/core/js/forum/src/components/composer-body.js
new file mode 100644
index 000000000..41d2d8ecb
--- /dev/null
+++ b/framework/core/js/forum/src/components/composer-body.js
@@ -0,0 +1,45 @@
+import Component from 'flarum/component';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+import TextEditor from 'flarum/components/text-editor';
+import avatar from 'flarum/helpers/avatar';
+import listItems from 'flarum/helpers/list-items';
+
+export default class ComposerBody extends Component {
+ constructor(props) {
+ super(props);
+
+ this.loading = m.prop(false);
+ this.disabled = m.prop(false);
+ this.content = m.prop(this.props.originalContent);
+ }
+
+ view() {
+ return m('div', {config: this.element}, [
+ avatar(this.props.user, {className: 'composer-avatar'}),
+ m('div.composer-body', [
+ m('ul.composer-header', listItems(this.headerItems().toArray())),
+ m('div.composer-editor', TextEditor.component({
+ submitLabel: this.props.submitLabel,
+ placeholder: this.props.placeholder,
+ disabled: this.loading(),
+ onchange: this.content,
+ onsubmit: this.onsubmit.bind(this),
+ value: this.content()
+ }))
+ ]),
+ LoadingIndicator.component({className: 'composer-loading'+(this.loading() ? ' active' : '')})
+ ]);
+ }
+
+ focus() {
+ this.$().find(':input:enabled:visible:first').focus();
+ }
+
+ preventExit() {
+ return this.content() != this.props.originalContent && !confirm(this.props.confirmExit);
+ }
+
+ onsubmit(value) {
+ //
+ }
+}
diff --git a/framework/core/js/forum/src/components/composer-discussion.js b/framework/core/js/forum/src/components/composer-discussion.js
new file mode 100644
index 000000000..244f35b06
--- /dev/null
+++ b/framework/core/js/forum/src/components/composer-discussion.js
@@ -0,0 +1,71 @@
+import ItemList from 'flarum/utils/item-list';
+import ComposerBody from 'flarum/components/composer-body';
+import Alert from 'flarum/components/alert';
+import ActionButton from 'flarum/components/action-button';
+
+/**
+ 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 class ComposerDiscussion extends ComposerBody {
+ constructor(props) {
+ props.submitLabel = props.submitLabel || 'Post Discussion';
+ props.confirmExit = props.confirmExit || 'You have not posted your discussion. Do you wish to discard it?';
+ props.titlePlaceholder = props.titlePlaceholder || 'Discussion Title';
+
+ super(props);
+
+ this.title = m.prop('');
+ }
+
+ headerItems() {
+ var items = new ItemList();
+ var post = this.props.post;
+
+ items.add('title', m('h3', m('input', {
+ className: 'form-control',
+ value: this.title(),
+ onchange: m.withAttr('value', this.title),
+ placeholder: this.props.titlePlaceholder,
+ disabled: !!this.props.disabled,
+ config: function(element, isInitialized) {
+ if (isInitialized) { return; }
+ $(element).on('input', function() {
+ var $this = $(this);
+ var empty = !$this.val();
+ if (empty) { $this.val($this.attr('placeholder')); }
+ $this.css('width', 0);
+ $this.css('width', $this[0].scrollWidth);
+ if (empty) { $this.val(''); }
+ });
+ setTimeout(() => $(element).trigger('input'));
+ }
+ })));
+
+ return items;
+ }
+
+ preventExit() {
+ return (this.title() || this.content()) && !confirm(this.props.confirmExit);
+ }
+
+ onsubmit(content) {
+ this.loading(true);
+ m.redraw();
+
+ var data = {
+ title: this.title(),
+ content: content
+ };
+
+ app.store.createRecord('discussions').save(data).then(discussion => {
+ app.composer.hide();
+ app.cache.discussionList.discussions().unshift(discussion);
+ m.route(app.route('discussion', discussion));
+ }, response => {
+ this.loading(false);
+ m.redraw();
+ });
+ }
+}
diff --git a/framework/core/js/forum/src/components/composer-edit.js b/framework/core/js/forum/src/components/composer-edit.js
new file mode 100644
index 000000000..92cd29fb1
--- /dev/null
+++ b/framework/core/js/forum/src/components/composer-edit.js
@@ -0,0 +1,44 @@
+import ItemList from 'flarum/utils/item-list';
+import ComposerBody from 'flarum/components/composer-body';
+import Alert from 'flarum/components/alert';
+import ActionButton from 'flarum/components/action-button';
+
+/**
+ 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 class ComposerEdit extends ComposerBody {
+ constructor(props) {
+ props.submitLabel = props.submitLabel || 'Save Changes';
+ props.confirmExit = props.confirmExit || 'You have not saved your changes. Do you wish to discard them?';
+ props.originalContent = props.originalContent || props.post.content();
+ props.user = props.user || props.post.user();
+
+ super(props);
+ }
+
+ headerItems() {
+ var items = new ItemList();
+ var post = this.props.post;
+
+ items.add('title', m('h3', ['Editing Post #'+post.number()+' in ', m('em', post.discussion().title())]));
+
+ return items;
+ }
+
+ onsubmit(content) {
+ var post = this.props.post;
+
+ this.loading(true);
+ m.redraw();
+
+ post.save({content}).then(post => {
+ app.composer.hide();
+ m.redraw();
+ }, response => {
+ this.loading(false);
+ m.redraw();
+ });
+ }
+}
diff --git a/framework/core/js/forum/src/components/composer-reply.js b/framework/core/js/forum/src/components/composer-reply.js
new file mode 100644
index 000000000..6af8478c5
--- /dev/null
+++ b/framework/core/js/forum/src/components/composer-reply.js
@@ -0,0 +1,82 @@
+import ItemList from 'flarum/utils/item-list';
+import ComposerBody from 'flarum/components/composer-body';
+import Alert from 'flarum/components/alert';
+import ActionButton from 'flarum/components/action-button';
+
+export default class ComposerReply extends ComposerBody {
+ constructor(props) {
+ props.submitLabel = props.submitLabel || 'Post Reply';
+ props.confirmExit = props.confirmExit || 'You have not posted your reply. Do you wish to discard it?';
+
+ super(props);
+ }
+
+ headerItems() {
+ var items = new ItemList();
+
+ items.add('title', m('h3', ['Replying to ', m('em', this.props.discussion.title())]));
+
+ return items;
+ }
+
+ onsubmit(value) {
+ var discussion = this.props.discussion;
+
+ this.loading(true);
+ m.redraw();
+
+ var data = {
+ content: value,
+ links: {discussion}
+ };
+
+ app.store.createRecord('posts').save(data).then((post) => {
+ app.composer.hide();
+
+ discussion.pushData({
+ links: {
+ lastUser: post.user(),
+ lastPost: post
+ },
+ lastTime: post.time(),
+ lastPostNumber: post.number(),
+ commentsCount: discussion.commentsCount() + 1,
+ readTime: post.time(),
+ readNumber: post.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 (app.current && app.current.discussion && app.current.discussion().id() === discussion.id()) {
+ app.current.stream().addPostToEnd(post);
+ m.route(app.route('discussion.near', {
+ id: discussion.id(),
+ slug: discussion.slug(),
+ near: post.number()
+ }));
+ } 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 alert;
+ var viewButton = ActionButton.component({
+ label: 'View',
+ onclick: () => {
+ m.route(app.route('discussion.near', { id: discussion.id(), slug: discussion.slug(), near: post.number() }));
+ app.alerts.dismiss(alert);
+ }
+ });
+ app.alerts.show(
+ alert = new Alert({
+ type: 'success',
+ message: 'Your reply was posted.',
+ controls: [viewButton]
+ })
+ );
+ }
+ }, (response) => {
+ this.loading(false);
+ m.redraw();
+ });
+ }
+}
diff --git a/framework/core/js/forum/src/components/composer.js b/framework/core/js/forum/src/components/composer.js
new file mode 100644
index 000000000..a9f4b0101
--- /dev/null
+++ b/framework/core/js/forum/src/components/composer.js
@@ -0,0 +1,295 @@
+import Component from 'flarum/component';
+import ItemList from 'flarum/utils/item-list';
+import ActionButton from 'flarum/components/action-button';
+import icon from 'flarum/helpers/icon';
+import listItems from 'flarum/helpers/list-items';
+import classList from 'flarum/utils/class-list';
+import computed from 'flarum/utils/computed';
+
+class Composer extends Component {
+ constructor(props) {
+ super(props);
+
+ this.position = m.prop(Composer.PositionEnum.HIDDEN);
+ this.height = m.prop();
+
+ // Calculate the composer's current height, based on the intended height
+ // (which is set when the resizing handle is dragged), and the composer's
+ // current state.
+ this.computedHeight = computed('height', 'position', function(height, position) {
+ if (position === Composer.PositionEnum.MINIMIZED || position === Composer.PositionEnum.HIDDEN) {
+ return '';
+ } else if (position === Composer.PositionEnum.FULLSCREEN) {
+ return $(window).height();
+ } else {
+ return Math.max(200, Math.min(height, $(window).height() - $('#header').outerHeight()));
+ }
+ });
+ }
+
+ view() {
+ var classes = {
+ 'minimized': this.position() === Composer.PositionEnum.MINIMIZED,
+ 'full-screen': this.position() === Composer.PositionEnum.FULLSCREEN
+ };
+ classes.visible = this.position() === Composer.PositionEnum.NORMAL || classes.minimized || classes.fullScreen;
+
+ this.component && (this.component.props.disabled = classes.minimized);
+
+ return m('div.composer', {config: this.onload.bind(this), className: classList(classes)}, [
+ m('div.composer-handle', {config: this.configHandle.bind(this)}),
+ m('ul.composer-controls', listItems(this.controlItems().toArray())),
+ m('div.composer-content', {onclick: this.show.bind(this)}, this.component ? this.component.view() : '')
+ ]);
+ }
+
+ onload(element, isInitialized, context) {
+ this.element(element);
+
+ if (isInitialized) { return; }
+ context.retain = true;
+
+ // Hide the composer to begin with.
+ this.height(localStorage.getItem('composerHeight') || this.$().height());
+ this.$().hide();
+
+ // Modulate the view's active property/class according to the focus
+ // state of any inputs.
+ this.$().on('focus blur', ':input', (e) => this.$().toggleClass('active', e.type === 'focusin'));
+
+ // When the escape key is pressed on any inputs, close the composer.
+ this.$().on('keydown', ':input', 'esc', () => this.close());
+
+ context.onunload = this.ondestroy.bind(this);
+ this.handlers = {};
+
+ $(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
+
+ $(document)
+ .on('mousemove', this.handlers.onmousemove = this.onmousemove.bind(this))
+ .on('mouseup', this.handlers.onmouseup = this.onmouseup.bind(this));
+ }
+
+ configHandle(element, isInitialized) {
+ if (isInitialized) { return; }
+
+ var self = this;
+ $(element).css('cursor', 'row-resize')
+ .mousedown(function(e) {
+ self.mouseStart = e.clientY;
+ self.heightStart = self.$().height();
+ self.handle = $(this);
+ $('body').css('cursor', 'row-resize');
+ }).bind('dragstart mousedown', function(e) {
+ e.preventDefault();
+ });
+ }
+
+ ondestroy() {
+ $(window).off('resize', this.handlers.onresize);
+
+ $(document)
+ .off('mousemove', this.handlers.onmousemove)
+ .off('mouseup', this.handlers.onmouseup);
+ }
+
+ updateHeight() {
+ this.$().height(this.computedHeight());
+ this.setContentHeight(this.computedHeight());
+ }
+
+ onresize() {
+ this.updateHeight();
+ }
+
+ onmousemove(e) {
+ if (!this.handle) { return; }
+
+ // Work out how much the mouse has been moved, and set the height
+ // relative to the old one based on that. Then update the content's
+ // height so that it fills the height of the composer, and update the
+ // body's padding.
+ var deltaPixels = this.mouseStart - e.clientY;
+ var height = this.heightStart + deltaPixels;
+ this.height(height);
+ this.updateHeight();
+ this.updateBodyPadding();
+
+ localStorage.setItem('composerHeight', height);
+ }
+
+ onmouseup(e) {
+ if (!this.handle) { return; }
+ this.handle = null;
+ $('body').css('cursor', '');
+ }
+
+ preventExit() {
+ return this.component && this.component.preventExit();
+ }
+
+ render() {
+ // @todo this function's logic could probably use some reworking. The
+ // following line is bad because it prevents focusing on the composer
+ // input when the composer is shown when it's already being shown
+ if (this.position() === this.oldPosition) { return; }
+
+ var $composer = this.$();
+ var oldHeight = $composer.is(':visible') ? $composer.height() : 0;
+
+ if (this.position() !== Composer.PositionEnum.HIDDEN) {
+ m.redraw(true);
+ }
+
+ this.updateHeight();
+ var newHeight = $composer.height();
+
+ switch (this.position()) {
+ case Composer.PositionEnum.HIDDEN:
+ $composer.css({height: oldHeight}).animate({bottom: -newHeight}, 'fast', () => {
+ $composer.hide();
+ this.clear();
+ m.redraw();
+ });
+ break;
+
+ case Composer.PositionEnum.NORMAL:
+ if (this.oldPosition !== Composer.PositionEnum.FULLSCREEN) {
+ $composer.show();
+ $composer.css({height: oldHeight}).animate({bottom: 0, height: newHeight}, 'fast', this.component.focus.bind(this.component));
+ } else {
+ this.component.focus();
+ }
+ break;
+
+ case Composer.PositionEnum.MINIMIZED:
+ $composer.css({height: oldHeight}).animate({height: newHeight}, 'fast', this.component.focus.bind(this.component));
+ break;
+ }
+
+ if (this.position() !== Composer.PositionEnum.FULLSCREEN) {
+ this.updateBodyPadding(true);
+ } else {
+ this.component.focus();
+ }
+ $('body').toggleClass('composer-open', this.position() !== Composer.PositionEnum.HIDDEN);
+ this.oldPosition = this.position();
+ this.setContentHeight(this.computedHeight());
+ }
+
+ // Update the amount of padding-bottom on the body so that the page's
+ // content will still be visible above the composer when the page is
+ // scrolled right to the bottom.
+ updateBodyPadding(animate) {
+ // Before we change anything, work out if we're currently scrolled
+ // right to the bottom of the page. If we are, we'll want to anchor
+ // the body's scroll position to the bottom after we update the
+ // padding.
+ var scrollTop = $(window).scrollTop();
+ var anchorScroll = scrollTop > 0 && scrollTop + $(window).height() >= $(document).height();
+
+ var func = animate ? 'animate' : 'css';
+ var paddingBottom = this.position() !== Composer.PositionEnum.HIDDEN ? this.computedHeight() - parseInt($('#page').css('padding-bottom')) : 0;
+ $('#content')[func]({paddingBottom}, 'fast');
+
+ if (anchorScroll) {
+ if (animate) {
+ $('html, body').stop(true).animate({scrollTop: $(document).height()}, 'fast');
+ } else {
+ $('html, body').scrollTop($(document).height());
+ }
+ }
+ }
+
+ // Update the height of the stuff inside of the composer. There should be
+ // an element with the class .flexible-height — this element is intended
+ // to fill up the height of the composer, minus the space taken up by the
+ // composer's header/footer/etc.
+ setContentHeight(height) {
+ var content = this.$('.composer-content');
+ this.$('.flexible-height').height(height -
+ parseInt(content.css('padding-top')) -
+ parseInt(content.css('padding-bottom')) -
+ this.$('.composer-header').outerHeight(true) -
+ this.$('.text-editor-controls').outerHeight(true));
+ }
+
+ load(component) {
+ if (!this.preventExit()) {
+ this.component = component;
+ }
+ }
+
+ clear() {
+ this.component = null;
+ }
+
+ show() {
+ if ([Composer.PositionEnum.MINIMIZED, Composer.PositionEnum.HIDDEN].indexOf(this.position()) !== -1) {
+ this.position(Composer.PositionEnum.NORMAL);
+ }
+ this.render();
+ }
+
+ hide() {
+ this.position(Composer.PositionEnum.HIDDEN);
+ this.render();
+ }
+
+ close() {
+ if (!this.preventExit()) {
+ this.hide();
+ }
+ }
+
+ minimize() {
+ if (this.position() !== Composer.PositionEnum.HIDDEN) {
+ this.position(Composer.PositionEnum.MINIMIZED);
+ this.render();
+ }
+ }
+
+ fullScreen() {
+ if (this.position() !== Composer.PositionEnum.HIDDEN) {
+ this.position(Composer.PositionEnum.FULLSCREEN);
+ this.render();
+ }
+ }
+
+ exitFullScreen() {
+ if (this.position() === Composer.PositionEnum.FULLSCREEN) {
+ this.position(Composer.PositionEnum.NORMAL);
+ this.render();
+ }
+ }
+
+ control(props) {
+ props.className = 'btn btn-icon btn-link';
+ return ActionButton.component(props);
+ }
+
+ controlItems() {
+ var items = new ItemList();
+
+ if (this.position() === Composer.PositionEnum.FULLSCREEN) {
+ items.add('exitFullScreen', this.control({ icon: 'compress', title: 'Exit Full Screen', onclick: this.exitFullScreen.bind(this) }));
+ } else {
+ if (this.position() !== Composer.PositionEnum.MINIMIZED) {
+ items.add('minimize', this.control({ icon: 'minus minimize', title: 'Minimize', onclick: this.minimize.bind(this) }));
+ items.add('fullScreen', this.control({ icon: 'expand', title: 'Full Screen', onclick: this.fullScreen.bind(this) }));
+ }
+ items.add('close', this.control({ icon: 'times', title: 'Close', wrapperClass: 'back-control', onclick: this.close.bind(this) }));
+ }
+
+ return items;
+ }
+}
+
+Composer.PositionEnum = {
+ HIDDEN: 'hidden',
+ NORMAL: 'normal',
+ MINIMIZED: 'minimized',
+ FULLSCREEN: 'fullScreen'
+};
+
+export default Composer;
diff --git a/framework/core/js/forum/src/components/discussion-list.js b/framework/core/js/forum/src/components/discussion-list.js
new file mode 100644
index 000000000..cff6a7ead
--- /dev/null
+++ b/framework/core/js/forum/src/components/discussion-list.js
@@ -0,0 +1,197 @@
+import Component from 'flarum/component';
+import avatar from 'flarum/helpers/avatar';
+import listItems from 'flarum/helpers/list-items';
+import humanTime from 'flarum/utils/human-time';
+import ItemList from 'flarum/utils/item-list';
+import abbreviateNumber from 'flarum/utils/abbreviate-number';
+import ActionButton from 'flarum/components/action-button';
+import DropdownButton from 'flarum/components/dropdown-button';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+import TerminalPost from 'flarum/components/terminal-post';
+
+export default class DiscussionList extends Component {
+ constructor(props) {
+ super(props);
+
+ this.loading = m.prop(true);
+ this.moreResults = m.prop(false);
+ this.discussions = m.prop([]);
+ this.sort = m.prop(this.props.sort || 'recent');
+ this.sortOptions = m.prop([
+ {key: 'recent', value: 'Recent', sort: 'recent'},
+ {key: 'replies', value: 'Replies', sort: '-replies'},
+ {key: 'newest', value: 'Newest', sort: '-created'},
+ {key: 'oldest', value: 'Oldest', sort: 'created'}
+ ]);
+
+ this.refresh();
+
+ app.session.on('loggedIn', this.loggedInHandler = this.refresh.bind(this))
+ }
+
+ refresh() {
+ m.startComputation();
+ this.loading(true);
+ this.discussions([]);
+ m.endComputation();
+ this.loadResults().then(this.parseResults.bind(this));
+ }
+
+ onunload() {
+ app.session.off('loggedIn', this.loggedInHandler);
+ }
+
+ terminalPostType() {
+ return ['newest', 'oldest'].indexOf(this.sort()) !== -1 ? 'start' : 'last'
+ }
+
+ countType() {
+ return this.sort() === 'replies' ? 'replies' : 'unread';
+ }
+
+ loadResults(start) {
+ var self = this;
+
+ var sort = this.sortOptions()[0].sort;
+ this.sortOptions().some(function(option) {
+ if (option.key === self.sort()) {
+ sort = option.sort;
+ return true;
+ }
+ });
+
+ var params = {sort, start};
+
+ return app.store.find('discussions', params);
+ }
+
+ loadMore() {
+ var self = this;
+ this.loading(true);
+ this.loadResults(this.discussions().length).then((results) => this.parseResults(results, true));
+ }
+
+ parseResults(results, append) {
+ m.startComputation();
+ this.loading(false);
+ [].push.apply(this.discussions(), results);
+ this.moreResults(!!results.meta.moreUrl);
+ m.endComputation();
+ return results;
+ }
+
+ markAsRead(discussion) {
+ if (discussion.isUnread()) {
+ discussion.save({ readNumber: discussion.lastPostNumber() });
+ m.redraw();
+ }
+ }
+
+ delete(discussion) {
+ if (confirm('Are you sure you want to delete this discussion?')) {
+ discussion.delete();
+ this.removeDiscussion(discussion);
+ if (app.current.discussion && app.current.discussion().id() === discussion.id()) {
+ app.history.back();
+ }
+ }
+ }
+
+ removeDiscussion(discussion) {
+ var index = this.discussions().indexOf(discussion);
+ if (index !== -1) {
+ this.discussions().splice(index, 1);
+ }
+ }
+
+ view() {
+ return m('div', [
+ m('ul.discussions-list', [
+ this.discussions().map(function(discussion) {
+ var startUser = discussion.startUser()
+ var isUnread = discussion.isUnread()
+ var displayUnread = this.props.countType !== 'replies' && isUnread
+ var jumpTo = Math.min(discussion.lastPostNumber(), (discussion.readNumber() || 0) + 1)
+
+ var controls = this.controlItems(discussion).toArray();
+
+ var discussionRoute = app.route('discussion', discussion);
+ var active = m.route().substr(0, discussionRoute.length) === discussionRoute;
+
+ return m('li.discussion-summary'+(isUnread ? '.unread' : '')+(active ? '.active' : ''), {key: discussion.id()}, [
+ controls.length ? DropdownButton.component({
+ items: controls,
+ className: 'contextual-controls',
+ buttonClass: 'btn btn-default btn-icon btn-sm btn-naked',
+ menuClass: 'pull-right'
+ }) : '',
+ m('a.author', {
+ href: app.route('user', startUser),
+ config: function(element, isInitialized, context) {
+ $(element).tooltip({ placement: 'right' })
+ m.route.call(this, element)
+ },
+ title: 'Started by '+startUser.username()+' '+humanTime(discussion.startTime())
+ }, [
+ avatar(startUser, {title: ''})
+ ]),
+ m('ul.badges', listItems(discussion.badges().toArray())),
+ m('a.main', {href: app.route('discussion.near', {id: discussion.id(), slug: discussion.slug(), near: jumpTo}), config: m.route}, [
+ m('h3.title', discussion.title()),
+ m('ul.info', listItems(this.infoItems(discussion).toArray()))
+ ]),
+ m('span.count', {onclick: this.markAsRead.bind(this, discussion)}, [
+ abbreviateNumber(discussion[displayUnread ? 'unreadCount' : 'repliesCount']()),
+ m('span.label', displayUnread ? 'unread' : 'replies')
+ ])
+ ])
+ }.bind(this))
+ ]),
+ this.loading()
+ ? LoadingIndicator.component()
+ : (this.moreResults() ? m('div.load-more', ActionButton.component({
+ label: 'Load More',
+ className: 'control-loadMore btn btn-default',
+ onclick: this.loadMore.bind(this)
+ })) : '')
+ ]);
+ }
+
+ /**
+ Build an item list of info for a discussion listing. By default this is
+ just the first/last post indicator.
+
+ @return {ItemList}
+ */
+ infoItems(discussion) {
+ var items = new ItemList();
+
+ items.add('terminalPost',
+ TerminalPost.component({
+ discussion,
+ lastPost: this.props.terminalPostType !== 'start'
+ })
+ );
+
+ return items;
+ }
+
+ /**
+ Build an item list of controls for a discussion listing.
+
+ @return {ItemList}
+ */
+ controlItems(discussion) {
+ var items = new ItemList();
+
+ if (discussion.canDelete()) {
+ items.add('delete', ActionButton.component({
+ icon: 'times',
+ label: 'Delete',
+ onclick: this.delete.bind(this, discussion)
+ }));
+ }
+
+ return items;
+ }
+}
diff --git a/framework/core/js/forum/src/components/discussion-page.js b/framework/core/js/forum/src/components/discussion-page.js
new file mode 100644
index 000000000..e49c90214
--- /dev/null
+++ b/framework/core/js/forum/src/components/discussion-page.js
@@ -0,0 +1,280 @@
+import Component from 'flarum/component';
+import ItemList from 'flarum/utils/item-list';
+import IndexPage from 'flarum/components/index-page';
+import PostStream from 'flarum/utils/post-stream';
+import DiscussionList from 'flarum/components/discussion-list';
+import StreamContent from 'flarum/components/stream-content';
+import StreamScrubber from 'flarum/components/stream-scrubber';
+import ComposerReply from 'flarum/components/composer-reply';
+import ActionButton from 'flarum/components/action-button';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+import DropdownSplit from 'flarum/components/dropdown-split';
+import Separator from 'flarum/components/separator';
+import listItems from 'flarum/helpers/list-items';
+
+export default class DiscussionPage extends Component {
+ /**
+
+ */
+ constructor(props) {
+ super(props);
+
+ this.discussion = m.prop();
+
+ // Set up the stream. The stream is an object that represents the posts in
+ // a discussion, as they're displayed on the screen (i.e. missing posts
+ // are condensed into "load more" gaps).
+ this.stream = m.prop();
+
+ // Get the discussion. We may already have a copy of it in our store, so
+ // we'll start off with that. If we do have a copy of the discussion, and
+ // its posts relationship has been loaded (i.e. we've viewed this
+ // discussion before), then we can proceed with displaying it immediately.
+ // If not, we'll make an API request first.
+ app.store.find('discussions', m.route.param('id'), {
+ near: this.currentNear = m.route.param('near'),
+ include: 'posts'
+ }).then(this.setupDiscussion.bind(this));
+
+ if (app.cache.discussionList) {
+ app.pane.enable();
+ app.pane.hide();
+ m.redraw.strategy('diff'); // otherwise pane redraws and mouseenter even is triggered so it doesn't hide
+ }
+
+ app.history.push('discussion');
+ app.current = this;
+ app.composer.minimize();
+ }
+
+ /*
+
+ */
+ setupDiscussion(discussion) {
+ this.discussion(discussion);
+
+ var includedPosts = [];
+ discussion.payload.included.forEach(record => {
+ if (record.type === 'posts') {
+ includedPosts.push(record.id);
+ }
+ });
+
+ // Set up the post stream for this discussion, and add all of the posts we
+ // have loaded so far.
+ this.stream(new PostStream(discussion));
+ this.stream().addPosts(discussion.posts().filter(value => value && includedPosts.indexOf(value.id()) !== -1));
+ this.streamContent = new StreamContent({
+ stream: this.stream(),
+ className: 'discussion-posts posts',
+ positionChanged: this.positionChanged.bind(this)
+ });
+
+ // Hold up there skippy! If the slug in the URL doesn't match up, we'll
+ // redirect so we have the correct one.
+ if (m.route.param('id') === discussion.id() && m.route.param('slug') !== discussion.slug()) {
+ var params = m.route.param();
+ params.slug = discussion.slug();
+ params.near = params.near || '';
+ m.route(app.route('discussion.near', params), null, true);
+ return;
+ }
+
+ this.streamContent.goToNumber(this.currentNear, true);
+ }
+
+ onload(element, isInitialized, context) {
+ if (isInitialized) { return; }
+
+ context.retain = true;
+
+ $('body').addClass('discussion-page');
+ context.onunload = function() {
+ $('body').removeClass('discussion-page');
+ }
+ }
+
+ /**
+
+ */
+ onunload(e) {
+ // If we have routed to the same discussion as we were viewing previously,
+ // cancel the unloading of this controller and instead prompt the post
+ // stream to jump to the new 'near' param.
+ var discussion = this.discussion();
+ if (discussion) {
+ var discussionRoute = app.route('discussion', discussion);
+ if (m.route().substr(0, discussionRoute.length) === discussionRoute) {
+ e.preventDefault();
+ if (m.route.param('near') != this.currentNear) {
+ this.streamContent.goToNumber(m.route.param('near'));
+ }
+ return;
+ }
+ }
+
+ app.pane.disable();
+ }
+
+ /**
+
+ */
+ view() {
+ var discussion = this.discussion();
+
+ return m('div', {config: this.onload.bind(this)}, [
+ app.cache.discussionList ? m('div.index-area.paned', {config: this.configIndex.bind(this)}, app.cache.discussionList.view()) : '',
+ m('div.discussion-area', discussion ? [
+ m('header.hero.discussion-hero', [
+ m('div.container', [
+ m('ul.badges', listItems(discussion.badges().toArray())), ' ',
+ m('h2.discussion-title', discussion.title())
+ ])
+ ]),
+ m('div.container', [
+ m('nav.discussion-nav', [
+ m('ul', listItems(this.sidebarItems().toArray()))
+ ]),
+ this.streamContent.view()
+ ])
+ ] : LoadingIndicator.component({className: 'loading-indicator-block'}))
+ ]);
+ }
+
+ /**
+
+ */
+ configIndex(element, isInitialized, context) {
+ if (isInitialized) { return; }
+
+ context.retain = true;
+
+ // When viewing a discussion (for which the discussions route is the
+ // parent,) the discussion list is still rendered but it becomes a
+ // pane hidden on the side of the screen. When the mouse enters and
+ // leaves the discussions pane, we want to show and hide the pane
+ // respectively. We also create a 10px 'hot edge' on the left of the
+ // screen to activate the pane.
+ var pane = app.pane;
+ $(element).hover(pane.show.bind(pane), pane.onmouseleave.bind(pane));
+
+ var hotEdge = function(e) {
+ if (e.pageX < 10) { pane.show(); }
+ };
+ $(document).on('mousemove', hotEdge);
+ context.onunload = function() {
+ $(document).off('mousemove', hotEdge);
+ };
+ }
+
+ /**
+
+ */
+ sidebarItems() {
+ var items = new ItemList();
+
+ items.add('controls',
+ DropdownSplit.component({
+ items: this.controlItems().toArray(),
+ icon: 'reply',
+ buttonClass: 'btn btn-primary',
+ wrapperClass: 'primary-control'
+ })
+ );
+
+ items.add('scrubber',
+ StreamScrubber.component({
+ streamContent: this.streamContent,
+ wrapperClass: 'title-control'
+ })
+ );
+
+ return items;
+ }
+
+ /**
+
+ */
+ controlItems() {
+ var items = new ItemList();
+ var discussion = this.discussion();
+
+ items.add('reply', ActionButton.component({ icon: 'reply', label: 'Reply', onclick: this.reply.bind(this) }));
+
+ items.add('separator', Separator.component());
+
+ if (discussion.canEdit()) {
+ items.add('rename', ActionButton.component({ icon: 'pencil', label: 'Rename', onclick: this.rename.bind(this) }));
+ }
+
+ if (discussion.canDelete()) {
+ items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.delete.bind(this) }));
+ }
+
+ return items;
+ }
+
+ reply() {
+ if (app.session.user()) {
+ this.streamContent.goToLast();
+
+ if (!this.composer || app.composer.component !== this.composer) {
+ this.composer = new ComposerReply({
+ user: app.session.user(),
+ discussion: this.discussion()
+ });
+ app.composer.load(this.composer);
+ }
+ app.composer.show();
+ } else {
+ // signup
+ }
+ }
+
+ delete() {
+ if (confirm('Are you sure you want to delete this discussion?')) {
+ var discussion = this.discussion();
+ discussion.delete();
+ if (app.cache.discussionList) {
+ app.cache.discussionList.removeDiscussion(discussion);
+ }
+ app.history.back();
+ }
+ }
+
+ rename() {
+ var discussion = this.discussion();
+ var currentTitle = discussion.title();
+ var title = prompt('Enter a new title for this discussion:', currentTitle);
+ if (title && title !== currentTitle) {
+ discussion.save({title}).then(discussion => {
+ discussion.addedPosts().forEach(post => this.stream().addPostToEnd(post));
+ m.redraw();
+ });
+ }
+ }
+
+ /**
+
+ */
+ positionChanged(startNumber, endNumber) {
+ var discussion = this.discussion();
+
+ var url = app.route('discussion.near', {
+ id: discussion.id(),
+ slug: discussion.slug(),
+ near: this.currentNear = startNumber
+ });
+
+ // https://github.com/lhorie/mithril.js/issues/559
+ m.route(url, true);
+ window.history.replaceState(null, document.title, (m.route.mode === 'hash' ? '#' : '')+url);
+
+ app.history.push('discussion');
+
+ if (app.session.user() && endNumber > discussion.readNumber()) {
+ discussion.save({readNumber: endNumber});
+ m.redraw();
+ }
+ }
+}
diff --git a/framework/core/js/forum/src/components/footer-primary.js b/framework/core/js/forum/src/components/footer-primary.js
new file mode 100644
index 000000000..f23c93e70
--- /dev/null
+++ b/framework/core/js/forum/src/components/footer-primary.js
@@ -0,0 +1,15 @@
+import Component from 'flarum/component';
+import ItemList from 'flarum/utils/item-list';
+import listItems from 'flarum/helpers/list-items';
+
+export default class FooterPrimary extends Component {
+ view() {
+ return m('ul.footer-controls', listItems(this.items().toArray()));
+ }
+
+ items() {
+ var items = new ItemList();
+
+ return items;
+ }
+}
diff --git a/framework/core/js/forum/src/components/footer-secondary.js b/framework/core/js/forum/src/components/footer-secondary.js
new file mode 100644
index 000000000..60fc4d434
--- /dev/null
+++ b/framework/core/js/forum/src/components/footer-secondary.js
@@ -0,0 +1,17 @@
+import Component from 'flarum/component';
+import ItemList from 'flarum/utils/item-list';
+import listItems from 'flarum/helpers/list-items';
+
+export default class FooterSecondary extends Component {
+ view() {
+ return m('ul.footer-controls', listItems(this.items().toArray()));
+ }
+
+ items() {
+ var items = new ItemList();
+
+ items.add('poweredBy', m('a[href=http://flarum.org][target=_blank]', 'Powered by Flarum'));
+
+ return items;
+ }
+}
diff --git a/framework/core/js/forum/src/components/header-primary.js b/framework/core/js/forum/src/components/header-primary.js
new file mode 100644
index 000000000..166a7b6cc
--- /dev/null
+++ b/framework/core/js/forum/src/components/header-primary.js
@@ -0,0 +1,15 @@
+import Component from 'flarum/component';
+import ItemList from 'flarum/utils/item-list';
+import listItems from 'flarum/helpers/list-items';
+
+export default class HeaderPrimary extends Component {
+ view() {
+ return m('ul.header-controls', listItems(this.items().toArray()));
+ }
+
+ items() {
+ var items = new ItemList();
+
+ return items;
+ }
+}
diff --git a/framework/core/js/forum/src/components/header-secondary.js b/framework/core/js/forum/src/components/header-secondary.js
new file mode 100644
index 000000000..900002e56
--- /dev/null
+++ b/framework/core/js/forum/src/components/header-secondary.js
@@ -0,0 +1,44 @@
+import Component from 'flarum/component';
+import ActionButton from 'flarum/components/action-button';
+import LoginModal from 'flarum/components/login-modal';
+import SignupModal from 'flarum/components/signup-modal';
+import UserDropdown from 'flarum/components/user-dropdown';
+import UserNotifications from 'flarum/components/user-notifications';
+
+import ItemList from 'flarum/utils/item-list';
+import listItems from 'flarum/helpers/list-items';
+
+export default class HeaderSecondary extends Component {
+ view() {
+ return m('ul.header-controls', listItems(this.items().toArray()));
+ }
+
+ items() {
+ var items = new ItemList();
+
+ if (app.session.user()) {
+ items.add('notifications', UserNotifications.component({ user: app.session.user() }))
+ items.add('user', UserDropdown.component({ user: app.session.user() }));
+ }
+
+ else {
+ items.add('signUp',
+ ActionButton.component({
+ label: 'Sign Up',
+ className: 'btn btn-link',
+ onclick: () => app.modal.show(new SignupModal())
+ })
+ );
+
+ items.add('logIn',
+ ActionButton.component({
+ label: 'Log In',
+ className: 'btn btn-link',
+ onclick: () => app.modal.show(new LoginModal())
+ })
+ );
+ }
+
+ return items;
+ }
+}
diff --git a/framework/core/js/forum/src/components/index-page.js b/framework/core/js/forum/src/components/index-page.js
new file mode 100644
index 000000000..99d38f2f0
--- /dev/null
+++ b/framework/core/js/forum/src/components/index-page.js
@@ -0,0 +1,173 @@
+import Component from 'flarum/component';
+import ItemList from 'flarum/utils/item-list';
+import listItems from 'flarum/helpers/list-items';
+import Discussion from 'flarum/models/discussion';
+import mixin from 'flarum/utils/mixin';
+
+import DiscussionList from 'flarum/components/discussion-list';
+import WelcomeHero from 'flarum/components/welcome-hero';
+import ComposerDiscussion from 'flarum/components/composer-discussion';
+
+import SelectInput from 'flarum/components/select-input';
+import ActionButton from 'flarum/components/action-button';
+import NavItem from 'flarum/components/nav-item';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+import DropdownSelect from 'flarum/components/dropdown-select';
+
+export default class IndexPage extends Component {
+ constructor(props) {
+ super(props);
+
+ if (app.cache.discussionList) {
+ if (app.cache.discussionList.props.sort !== m.route.param('sort')) {
+ app.cache.discussionList = null;
+ }
+ }
+ if (!app.cache.discussionList) {
+ app.cache.discussionList = new DiscussionList({
+ sort: m.route.param('sort')
+ });
+ }
+
+ app.history.push('index');
+ app.current = this;
+ app.composer.minimize();
+ }
+
+ reorder(sort) {
+ var filter = m.route.param('filter') || '';
+ var params = sort !== 'recent' ? {sort} : {};
+ m.route(app.route('index.filter', {filter}, params));
+ }
+
+ /**
+ Render the component.
+
+ @method view
+ @return void
+ */
+ view() {
+ return m('div.index-area', {config: this.onload.bind(this)}, [
+ WelcomeHero.component(),
+ m('div.container', [
+ m('nav.side-nav.index-nav', {config: this.affixSidebar}, [
+ m('ul', listItems(this.sidebarItems().toArray()))
+ ]),
+ m('div.offset-content.index-results', [
+ m('div.index-toolbar', [
+ m('div.index-toolbar-view', [
+ SelectInput.component({
+ options: app.cache.discussionList.sortOptions(),
+ value: app.cache.discussionList.sort(),
+ onchange: this.reorder.bind(this)
+ }),
+ ]),
+ m('div.index-toolbar-action', [
+ ActionButton.component({
+ title: 'Mark All as Read',
+ icon: 'check',
+ className: 'control-markAllAsRead btn btn-default btn-icon',
+ onclick: this.markAllAsRead.bind(this)
+ })
+ ])
+ ]),
+ app.cache.discussionList.view()
+ ])
+ ])
+ ])
+ }
+
+ onload(element, isInitialized, context) {
+ if (isInitialized) { return; }
+
+ this.element(element);
+
+ $('body').addClass('index-page');
+ context.onunload = function() {
+ $('body').removeClass('index-page');
+ }
+ }
+
+ newDiscussion() {
+ if (app.session.user()) {
+ app.composer.load(new ComposerDiscussion({ user: app.session.user() }));
+ app.composer.show();
+ } else {
+ // signup
+ }
+ }
+
+ markAllAsRead() {
+ app.session.user().save({ readTime: new Date() });
+ }
+
+ /**
+ Build an item list for the sidebar of the index page. By default this is a
+ "New Discussion" button, and then a DropdownSelect component containing a
+ list of navigation items (see this.navItems).
+
+ @return {ItemList}
+ */
+ sidebarItems() {
+ var items = new ItemList();
+
+ items.add('newDiscussion',
+ ActionButton.component({
+ label: 'Start a Discussion',
+ icon: 'edit',
+ className: 'btn btn-primary new-discussion',
+ wrapperClass: 'primary-control',
+ onclick: this.newDiscussion.bind(this)
+ })
+ );
+
+ items.add('nav',
+ DropdownSelect.component({
+ items: this.navItems(this).toArray(),
+ wrapperClass: 'title-control'
+ })
+ );
+
+ return items;
+ }
+
+ /**
+ Build an item list for the navigation in the sidebar of the index page. By
+ default this is just the 'All Discussions' link.
+
+ @return {ItemList}
+ */
+ navItems() {
+ var items = new ItemList();
+ var params = {sort: m.route.param('sort')};
+
+ items.add('allDiscussions',
+ NavItem.component({
+ href: app.route('index', {}, params),
+ label: 'All Discussions',
+ icon: 'comments-o'
+ })
+ );
+
+ return items;
+ }
+
+ /**
+ Setup the sidebar DOM element to be affixed to the top of the viewport
+ using Bootstrap's affix plugin.
+
+ @param {DOMElement} element
+ @param {Boolean} isInitialized
+ @return {void}
+ */
+ affixSidebar(element, isInitialized) {
+ if (isInitialized) { return; }
+ var $sidebar = $(element);
+ $sidebar.find('> ul').affix({
+ offset: {
+ top: () => $sidebar.offset().top - $('.global-header').outerHeight(true) - parseInt($sidebar.css('margin-top')),
+ bottom: () => (this.bottom = $('.global-footer').outerHeight(true))
+ }
+ });
+ }
+};
diff --git a/framework/core/js/forum/src/components/login-modal.js b/framework/core/js/forum/src/components/login-modal.js
new file mode 100644
index 000000000..0cccca498
--- /dev/null
+++ b/framework/core/js/forum/src/components/login-modal.js
@@ -0,0 +1,57 @@
+import Component from 'flarum/component';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+import icon from 'flarum/helpers/icon';
+
+export default class LoginModal extends Component {
+ constructor(props) {
+ super(props);
+
+ this.email = m.prop();
+ this.password = m.prop();
+ this.loading = m.prop(false);
+ }
+
+ view() {
+ return m('div.modal-dialog.modal-sm.modal-login', [
+ m('div.modal-content', [
+ m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')),
+ m('form', {onsubmit: this.login.bind(this)}, [
+ m('div.modal-header', m('h3.title-control', 'Log In')),
+ m('div.modal-body', [
+ m('div.form-centered', [
+ m('div.form-group', [
+ m('input.form-control[name=email][placeholder=Username or Email]', {onchange: m.withAttr('value', this.email)})
+ ]),
+ m('div.form-group', [
+ m('input.form-control[type=password][name=password][placeholder=Password]', {onchange: m.withAttr('value', this.password)})
+ ]),
+ m('div.form-group', [
+ m('button.btn.btn-primary.btn-block[type=submit]', 'Log In')
+ ])
+ ])
+ ]),
+ m('div.modal-footer', [
+ m('p.forgot-password-link', m('a[href=javascript:;]', 'Forgot password?')),
+ m('p.sign-up-link', ['Don\'t have an account? ', m('a[href=javascript:;]', {onclick: app.signup}, 'Sign Up')])
+ ])
+ ])
+ ]),
+ LoadingIndicator.component({className: 'modal-loading'+(this.loading() ? ' active' : '')})
+ ])
+ }
+
+ ready($modal) {
+ $modal.find('[name=email]').focus();
+ }
+
+ login(e) {
+ e.preventDefault();
+ this.loading(true);
+ app.session.login(this.email(), this.password()).then(function() {
+ app.modal.close();
+ }, (response) => {
+ this.loading(false);
+ m.redraw();
+ });
+ }
+}
diff --git a/framework/core/js/forum/src/components/notification-discussion-renamed.js b/framework/core/js/forum/src/components/notification-discussion-renamed.js
new file mode 100644
index 000000000..a1d19025e
--- /dev/null
+++ b/framework/core/js/forum/src/components/notification-discussion-renamed.js
@@ -0,0 +1,31 @@
+import Notification from 'flarum/components/notification';
+import avatar from 'flarum/helpers/avatar';
+import icon from 'flarum/helpers/icon';
+import username from 'flarum/helpers/username';
+import humanTime from 'flarum/helpers/human-time';
+
+export default class NotificationDiscussionRenamed extends Notification {
+ content() {
+ var notification = this.props.notification;
+ var discussion = notification.subject();
+
+ return m('a', {href: app.route('discussion.near', {
+ id: discussion.id(),
+ slug: discussion.slug(),
+ near: notification.content().number
+ }), config: m.route}, [
+ avatar(notification.sender()),
+ m('h3.notification-title', notification.content().oldTitle),
+ m('div.notification-info', [
+ icon('pencil'),
+ ' Renamed by ', username(notification.sender()),
+ notification.additionalUnreadCount() ? ' and '+notification.additionalUnreadCount()+' others' : '',
+ ' ', humanTime(notification.time())
+ ])
+ ]);
+ }
+
+ read() {
+ this.props.notification.save({isRead: true});
+ }
+}
diff --git a/framework/core/js/forum/src/components/notification-grid.js b/framework/core/js/forum/src/components/notification-grid.js
new file mode 100644
index 000000000..ee89179be
--- /dev/null
+++ b/framework/core/js/forum/src/components/notification-grid.js
@@ -0,0 +1,97 @@
+import Component from 'flarum/component';
+import YesNoInput from 'flarum/components/yesno-input';
+import icon from 'flarum/helpers/icon';
+
+export default class NotificationGrid extends Component {
+ constructor(props) {
+ super(props);
+
+ this.methods = [
+ { name: 'alert', icon: 'bell', label: 'Alert' },
+ { name: 'email', icon: 'envelope-o', label: 'Email' }
+ ];
+
+ this.inputs = {};
+ this.props.types.forEach(type => {
+ this.methods.forEach(method => {
+ var key = this.key(type.name, method.name);
+ var preference = this.props.user.preferences()[key];
+ this.inputs[key] = new YesNoInput({
+ state: !!preference,
+ disabled: typeof preference == 'undefined',
+ onchange: () => this.toggle([key])
+ });
+ });
+ });
+ }
+
+ key(type, method) {
+ return 'notify_'+type+'_'+method;
+ }
+
+ view() {
+ return m('div.notification-grid', {config: this.onload.bind(this)}, [
+ m('table', [
+ m('thead', [
+ m('tr', [
+ m('td'),
+ this.methods.map(method => m('th.toggle-group', {onclick: this.toggleMethod.bind(this, method.name)}, [icon(method.icon), ' ', method.label]))
+ ])
+ ]),
+ m('tbody', [
+ this.props.types.map(type => m('tr', [
+ m('td.toggle-group', {onclick: this.toggleType.bind(this, type.name)}, type.label),
+ this.methods.map(method => {
+ var key = this.key(type.name, method.name);
+ return m('td.yesno-cell', this.inputs[key].view());
+ })
+ ]))
+ ])
+ ])
+ ]);
+ }
+
+ onload(element, isInitialized) {
+ if (isInitialized) { return; }
+
+ this.element(element);
+
+ var self = this;
+ this.$('thead .toggle-group').bind('mouseenter mouseleave', function(e) {
+ var i = parseInt($(this).index()) + 1;
+ self.$('table').find('td:nth-child('+i+')').toggleClass('highlighted', e.type === 'mouseenter');
+ });
+ this.$('tbody .toggle-group').bind('mouseenter mouseleave', function(e) {
+ $(this).parent().find('td').toggleClass('highlighted', e.type === 'mouseenter');
+ });
+ }
+
+ toggle(keys) {
+ var user = this.props.user;
+ var preferences = user.preferences();
+ var enabled = !preferences[keys[0]];
+ keys.forEach(key => {
+ var control = this.inputs[key];
+ if (!control.props.disabled) {
+ control.loading(true);
+ preferences[key] = control.props.state = enabled;
+ }
+ });
+ m.redraw();
+
+ user.save({preferences}).then(() => {
+ keys.forEach(key => this.inputs[key].loading(false));
+ m.redraw();
+ });
+ }
+
+ toggleMethod(method) {
+ var keys = this.props.types.map(type => this.key(type.name, method));
+ this.toggle(keys);
+ }
+
+ toggleType(type) {
+ var keys = this.methods.map(method => this.key(type, method.name));
+ this.toggle(keys);
+ }
+}
diff --git a/framework/core/js/forum/src/components/notification.js b/framework/core/js/forum/src/components/notification.js
new file mode 100644
index 000000000..3d5082ae0
--- /dev/null
+++ b/framework/core/js/forum/src/components/notification.js
@@ -0,0 +1,20 @@
+import Component from 'flarum/component';
+
+export default class Notification extends Component {
+ view() {
+ var notification = this.props.notification;
+
+ return m('div.notification', {
+ classNames: !notification.isRead ? 'unread' : '',
+ onclick: this.read.bind(this)
+ }, this.content());
+ }
+
+ content() {
+ //
+ }
+
+ read() {
+ this.props.notification.save({isRead: true});
+ }
+}
diff --git a/framework/core/js/forum/src/components/post-comment.js b/framework/core/js/forum/src/components/post-comment.js
new file mode 100644
index 000000000..49f58f9d5
--- /dev/null
+++ b/framework/core/js/forum/src/components/post-comment.js
@@ -0,0 +1,133 @@
+import Component from 'flarum/component';
+import classList from 'flarum/utils/class-list';
+import ComposerEdit from 'flarum/components/composer-edit';
+import PostHeaderUser from 'flarum/components/post-header-user';
+import PostHeaderMeta from 'flarum/components/post-header-meta';
+import PostHeaderEdited from 'flarum/components/post-header-edited';
+import PostHeaderToggle from 'flarum/components/post-header-toggle';
+import ItemList from 'flarum/utils/item-list';
+import ActionButton from 'flarum/components/action-button';
+import DropdownButton from 'flarum/components/dropdown-button';
+import SubtreeRetainer from 'flarum/utils/subtree-retainer';
+import listItems from 'flarum/helpers/list-items';
+
+/**
+ 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 class PostComment extends Component {
+ constructor(props) {
+ super(props);
+
+ this.postHeaderUser = new PostHeaderUser({post: this.props.post});
+
+ this.subtree = new SubtreeRetainer(
+ () => this.props.post.freshness,
+ () => this.props.post.user().freshness,
+ this.postHeaderUser.showCard
+ );
+ }
+
+ view() {
+ var post = this.props.post;
+
+ var classes = {
+ 'is-hidden': post.isHidden(),
+ 'is-edited': post.isEdited(),
+ 'reveal-content': this.revealContent
+ };
+
+ var controls = this.controlItems().toArray();
+
+ // @todo Having to wrap children in a div isn't nice
+ return m('article.post.post-comment', {className: classList(classes)}, this.subtree.retain() || m('div', [
+ controls.length ? DropdownButton.component({
+ items: controls,
+ className: 'contextual-controls',
+ buttonClass: 'btn btn-default btn-icon btn-sm btn-naked',
+ menuClass: 'pull-right'
+ }) : '',
+ m('header.post-header', m('ul', listItems(this.headerItems().toArray()))),
+ m('div.post-body', m.trust(post.contentHtml())),
+ m('aside.post-footer', m('ul', listItems(this.footerItems().toArray()))),
+ m('aside.post-actions', m('ul', listItems(this.actionItems().toArray())))
+ ]));
+ }
+
+ toggleContent() {
+ this.revealContent = !this.revealContent;
+ }
+
+ headerItems() {
+ var items = new ItemList();
+ var post = this.props.post;
+ var props = {post};
+
+ items.add('user', this.postHeaderUser.view(), {first: true});
+ items.add('meta', PostHeaderMeta.component(props));
+
+ if (post.isEdited() && !post.isHidden()) {
+ items.add('edited', PostHeaderEdited.component(props));
+ }
+
+ if (post.isHidden()) {
+ items.add('toggle', PostHeaderToggle.component({toggle: this.toggleContent.bind(this)}));
+ }
+
+ return items;
+ }
+
+ controlItems() {
+ var items = new ItemList();
+ var post = this.props.post;
+
+ if (post.isHidden()) {
+ if (post.canEdit()) {
+ items.add('restore', ActionButton.component({ icon: 'reply', label: 'Restore', onclick: this.restore.bind(this) }));
+ }
+ if (post.canDelete()) {
+ items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete Forever', onclick: this.delete.bind(this) }));
+ }
+ } else if (post.canEdit()) {
+ items.add('edit', ActionButton.component({ icon: 'pencil', label: 'Edit', onclick: this.edit.bind(this) }));
+ items.add('hide', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.hide.bind(this) }));
+ }
+
+ return items;
+ }
+
+ footerItems() {
+ return new ItemList();
+ }
+
+ actionItems() {
+ return new ItemList();
+ }
+
+ edit() {
+ if (!this.composer || app.composer.component !== this.composer) {
+ this.composer = new ComposerEdit({ post: this.props.post });
+ app.composer.load(this.composer);
+ }
+ app.composer.show();
+ }
+
+ hide() {
+ var post = this.props.post;
+ post.save({ isHidden: true });
+ post.pushData({ hideTime: new Date(), hideUser: app.session.user() });
+ }
+
+ restore() {
+ var post = this.props.post;
+ post.save({ isHidden: false });
+ post.pushData({ hideTime: null, hideUser: null });
+ }
+
+ delete() {
+ var post = this.props.post;
+ post.delete();
+ this.props.ondelete && this.props.ondelete(post);
+ }
+}
diff --git a/framework/core/js/forum/src/components/post-discussion-renamed.js b/framework/core/js/forum/src/components/post-discussion-renamed.js
new file mode 100644
index 000000000..eb7457429
--- /dev/null
+++ b/framework/core/js/forum/src/components/post-discussion-renamed.js
@@ -0,0 +1,59 @@
+import Component from 'flarum/component';
+import icon from 'flarum/helpers/icon';
+import username from 'flarum/helpers/username';
+import humanTime from 'flarum/utils/human-time';
+import SubtreeRetainer from 'flarum/utils/subtree-retainer';
+import ItemList from 'flarum/utils/item-list';
+import ActionButton from 'flarum/components/action-button';
+import DropdownButton from 'flarum/components/dropdown-button';
+
+export default class PostDiscussionRenamed extends Component {
+ constructor(props) {
+ super(props);
+
+ this.subtree = new SubtreeRetainer(
+ () => this.props.post.freshness,
+ () => this.props.post.user().freshness
+ );
+ }
+
+ view(ctrl) {
+ var controls = this.controlItems().toArray();
+
+ var post = this.props.post;
+ var oldTitle = post.content()[0];
+ var newTitle = post.content()[1];
+
+ return m('article.post.post-activity.post-discussion-renamed', this.subtree.retain() || m('div', [
+ controls.length ? DropdownButton.component({
+ items: controls,
+ className: 'contextual-controls',
+ buttonClass: 'btn btn-default btn-icon btn-sm btn-naked',
+ menuClass: 'pull-right'
+ }) : '',
+ icon('pencil post-icon'),
+ m('div.post-activity-info', [
+ m('a.post-user', {href: app.route('user', post.user()), config: m.route}, username(post.user())),
+ ' changed the title from ', m('strong.old-title', oldTitle), ' to ', m('strong.new-title', newTitle), '.'
+ ]),
+ m('div.post-activity-time', humanTime(post.time()))
+ ]));
+ }
+
+ controlItems() {
+ var items = new ItemList();
+ var post = this.props.post;
+
+ if (post.canDelete()) {
+ items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.delete.bind(this) }));
+ }
+
+ return items;
+ }
+
+ delete() {
+ var post = this.props.post;
+ post.delete();
+ this.props.ondelete && this.props.ondelete(post);
+ }
+}
diff --git a/framework/core/js/forum/src/components/post-header-edited.js b/framework/core/js/forum/src/components/post-header-edited.js
new file mode 100644
index 000000000..381c0d11d
--- /dev/null
+++ b/framework/core/js/forum/src/components/post-header-edited.js
@@ -0,0 +1,20 @@
+import Component from 'flarum/component';
+import icon from 'flarum/helpers/icon';
+import humanTime from 'flarum/utils/human-time';
+
+/**
+ 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 class PostHeaderEdited extends Component {
+ view() {
+ var post = this.props.post;
+
+ var title = 'Edited '+(post.editUser() ? 'by '+post.editUser().username()+' ' : '')+humanTime(post.editTime());
+
+ return m('span.post-edited', {
+ title: title,
+ config: (element) => $(element).tooltip()
+ }, icon('pencil'));
+ }
+}
diff --git a/framework/core/js/forum/src/components/post-header-meta.js b/framework/core/js/forum/src/components/post-header-meta.js
new file mode 100644
index 000000000..22dbd5a15
--- /dev/null
+++ b/framework/core/js/forum/src/components/post-header-meta.js
@@ -0,0 +1,42 @@
+import Component from 'flarum/component';
+import humanTime from 'flarum/helpers/human-time';
+import fullTime from 'flarum/helpers/full-time';
+
+/**
+ 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 class PostHeaderMeta extends Component {
+ view() {
+ var post = this.props.post;
+ var discussion = post.discussion();
+
+ var params = {
+ id: discussion.id(),
+ slug: discussion.slug(),
+ near: post.number()
+ };
+ var permalink = window.location.origin+app.route('discussion.near', params);
+ var touch = 'ontouchstart' in document.documentElement;
+
+ // When the dropdown menu is shown, select the contents of the permalink
+ // input so that the user can quickly copy the URL.
+ var selectPermalink = function() {
+ var input = $(this).parent().find('.permalink');
+ setTimeout(() => input.select());
+ m.redraw.strategy('none');
+ }
+
+ return m('span.dropdown',
+ m('a.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', {onclick: selectPermalink}, humanTime(post.time())),
+ m('div.dropdown-menu.post-meta', [
+ m('span.number', 'Post #'+post.number()),
+ m('span.time', fullTime(post.time())),
+ touch
+ ? m('a.btn.btn-default.permalink', {href: permalink}, permalink)
+ : m('input.form-control.permalink', {value: permalink, onclick: (e) => e.stopPropagation()})
+ ])
+ );
+ }
+}
diff --git a/framework/core/js/forum/src/components/post-header-toggle.js b/framework/core/js/forum/src/components/post-header-toggle.js
new file mode 100644
index 000000000..541c150d9
--- /dev/null
+++ b/framework/core/js/forum/src/components/post-header-toggle.js
@@ -0,0 +1,13 @@
+import Component from 'flarum/component';
+import icon from 'flarum/helpers/icon';
+
+/**
+ 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 class PostHeaderToggle extends Component {
+ view() {
+ return m('a.btn.btn-default.btn-more[href=javascript:;]', {onclick: this.props.toggle}, icon('ellipsis-h'));
+ }
+}
diff --git a/framework/core/js/forum/src/components/post-header-user.js b/framework/core/js/forum/src/components/post-header-user.js
new file mode 100644
index 000000000..e92fca14a
--- /dev/null
+++ b/framework/core/js/forum/src/components/post-header-user.js
@@ -0,0 +1,61 @@
+import Component from 'flarum/component';
+import UserCard from 'flarum/components/user-card';
+import avatar from 'flarum/helpers/avatar';
+import username from 'flarum/helpers/username';
+import listItems from 'flarum/helpers/list-items';
+
+/**
+ Component for the username/avatar in a post header.
+ */
+export default class PostHeaderUser extends Component {
+ constructor(props) {
+ super(props);
+
+ this.showCard = m.prop(false);
+ }
+
+ view() {
+ var post = this.props.post;
+ var user = post.user();
+
+ return m('div.post-user', {config: this.onload.bind(this)}, [
+ m('h3',
+ user ? [
+ m('a', {href: app.route('user', user), config: m.route}, [
+ avatar(user),
+ username(user)
+ ]),
+ m('ul.badges', listItems(user.badges().toArray()))
+ ] : [
+ avatar(),
+ username()
+ ]
+ ),
+ this.showCard() ? UserCard.component({user, className: 'user-card-popover fade', controlsButtonClass: 'btn btn-default btn-icon btn-sm btn-naked'}) : ''
+ ]);
+ }
+
+ onload(element, isInitialized) {
+ if (isInitialized) { return; }
+
+ this.element(element);
+
+ var component = this;
+ var timeout;
+ this.$().bind('mouseover', '> a, .user-card', function() {
+ clearTimeout(timeout);
+ timeout = setTimeout(function() {
+ component.showCard(true);
+ m.redraw();
+ setTimeout(() => component.$('.user-card').addClass('in'));
+ }, 250);
+ }).bind('mouseout', '> a, .user-card', function() {
+ clearTimeout(timeout);
+ timeout = setTimeout(function() {
+ component.$('.user-card').removeClass('in').one('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function() {
+ component.showCard(false);
+ });
+ }, 250);
+ });
+ }
+}
diff --git a/framework/core/js/forum/src/components/settings-page.js b/framework/core/js/forum/src/components/settings-page.js
new file mode 100644
index 000000000..68c75d547
--- /dev/null
+++ b/framework/core/js/forum/src/components/settings-page.js
@@ -0,0 +1,126 @@
+import UserPage from 'flarum/components/user-page';
+import ItemList from 'flarum/utils/item-list';
+import SwitchInput from 'flarum/components/switch-input';
+import ActionButton from 'flarum/components/action-button';
+import FieldSet from 'flarum/components/field-set';
+import NotificationGrid from 'flarum/components/notification-grid';
+import listItems from 'flarum/helpers/list-items';
+
+export default class SettingsPage extends UserPage {
+ /**
+
+ */
+ constructor(props) {
+ super(props);
+
+ this.user = app.session.user;
+ }
+
+ content() {
+ return m('div.settings', [
+ m('ul', listItems(this.settingsItems().toArray()))
+ ]);
+ }
+
+ settingsItems() {
+ var items = new ItemList();
+
+ items.add('account',
+ FieldSet.component({
+ label: 'Account',
+ className: 'settings-account',
+ fields: this.accountItems().toArray()
+ })
+ );
+
+ items.add('notifications',
+ FieldSet.component({
+ label: 'Notifications',
+ className: 'settings-account',
+ fields: [NotificationGrid.component({
+ types: this.notificationTypes().toArray(),
+ user: this.user()
+ })]
+ })
+ );
+
+ items.add('privacy',
+ FieldSet.component({
+ label: 'Privacy',
+ fields: this.privacyItems().toArray()
+ })
+ );
+
+ return items;
+ }
+
+ accountItems() {
+ var items = new ItemList();
+
+ items.add('changePassword',
+ ActionButton.component({
+ label: 'Change Password',
+ className: 'btn btn-default'
+ })
+ );
+
+ items.add('changeEmail',
+ ActionButton.component({
+ label: 'Change Email',
+ className: 'btn btn-default'
+ })
+ );
+
+ items.add('deleteAccount',
+ ActionButton.component({
+ label: 'Delete Account',
+ className: 'btn btn-default btn-danger'
+ })
+ );
+
+ return items;
+ }
+
+ save(key) {
+ return (value, control) => {
+ var preferences = this.user().preferences();
+ preferences[key] = value;
+
+ control.loading(true);
+ m.redraw();
+
+ this.user().save({preferences}).then(() => {
+ control.loading(false);
+ m.redraw();
+ });
+ };
+ }
+
+ privacyItems() {
+ var items = new ItemList();
+
+ items.add('discloseOnline',
+ SwitchInput.component({
+ label: 'Allow others to see when I am online',
+ state: this.user().preferences().discloseOnline,
+ onchange: (value, component) => {
+ this.user().pushData({lastSeenTime: null});
+ this.save('discloseOnline')(value, component);
+ }
+ })
+ );
+
+ return items;
+ }
+
+ notificationTypes() {
+ var items = new ItemList();
+
+ items.add('discussionRenamed', {
+ name: 'discussionRenamed',
+ label: 'Someone renames a discussion I started'
+ });
+
+ return items;
+ }
+}
diff --git a/framework/core/js/forum/src/components/signup-modal.js b/framework/core/js/forum/src/components/signup-modal.js
new file mode 100644
index 000000000..e050d3a6a
--- /dev/null
+++ b/framework/core/js/forum/src/components/signup-modal.js
@@ -0,0 +1,88 @@
+import Component from 'flarum/component';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+import icon from 'flarum/helpers/icon';
+import avatar from 'flarum/helpers/avatar';
+
+export default class SignupModal extends Component {
+ constructor(props) {
+ super(props);
+
+ this.username = m.prop();
+ this.email = m.prop();
+ this.password = m.prop();
+ this.welcomeUser = m.prop();
+ this.loading = m.prop(false);
+ }
+
+ view() {
+ var welcomeUser = this.welcomeUser();
+ var emailProviderName = welcomeUser && welcomeUser.email().split('@')[1];
+
+ return m('div.modal-dialog.modal-sm.modal-signup', [
+ m('div.modal-content', [
+ m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')),
+ m('form', {onsubmit: this.signup.bind(this)}, [
+ m('div.modal-header', m('h3.title-control', 'Sign Up')),
+ m('div.modal-body', [
+ m('div.form-centered', [
+ m('div.form-group', [
+ m('input.form-control[name=username][placeholder=Username]', {onchange: m.withAttr('value', this.username)})
+ ]),
+ m('div.form-group', [
+ m('input.form-control[name=email][placeholder=Email]', {onchange: m.withAttr('value', this.email)})
+ ]),
+ m('div.form-group', [
+ m('input.form-control[type=password][name=password][placeholder=Password]', {onchange: m.withAttr('value', this.password)})
+ ]),
+ m('div.form-group', [
+ m('button.btn.btn-primary.btn-block[type=submit]', 'Sign Up')
+ ])
+ ])
+ ]),
+ m('div.modal-footer', [
+ m('p.log-in-link', ['Already have an account? ', m('a[href=javascript:;]', {onclick: app.login}, 'Log In')])
+ ])
+ ])
+ ]),
+ LoadingIndicator.component({className: 'modal-loading'+(this.loading() ? ' active' : '')}),
+ welcomeUser ? m('div.signup-welcome', {style: 'background: '+this.welcomeUser().color(), config: this.fadeIn}, [
+ avatar(welcomeUser),
+ m('h3', 'Welcome, '+welcomeUser.username()+'!'),
+ !welcomeUser.isConfirmed()
+ ? [
+ m('p', ['We\'ve sent a confirmation email to ', m('strong', welcomeUser.email()), '. If it doesn\'t arrive soon, check your spam folder.']),
+ m('p', m('a.btn.btn-default', {href: 'http://'+emailProviderName}, 'Go to '+emailProviderName))
+ ]
+ : ''
+ ]) : ''
+ ])
+ }
+
+ fadeIn(element, isInitialized) {
+ if (isInitialized) { return; }
+ $(element).hide().fadeIn();
+ }
+
+ ready($modal) {
+ $modal.find('[name=username]').focus();
+ }
+
+ signup(e) {
+ e.preventDefault();
+ this.loading(true);
+ var self = this;
+
+ app.store.createRecord('users').save({
+ username: this.username(),
+ email: this.email(),
+ password: this.password()
+ }).then(user => {
+ this.welcomeUser(user);
+ this.loading(false);
+ m.redraw();
+ }, response => {
+ this.loading(false);
+ m.redraw();
+ });
+ }
+}
diff --git a/framework/core/js/forum/src/components/stream-content.js b/framework/core/js/forum/src/components/stream-content.js
new file mode 100644
index 000000000..6acd5bf10
--- /dev/null
+++ b/framework/core/js/forum/src/components/stream-content.js
@@ -0,0 +1,343 @@
+import Component from 'flarum/component';
+import StreamItem from 'flarum/components/stream-item';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+import ScrollListener from 'flarum/utils/scroll-listener';
+import mixin from 'flarum/utils/mixin';
+import evented from 'flarum/utils/evented';
+
+/**
+
+ */
+export default class StreamContent extends mixin(Component, evented) {
+ /**
+
+ */
+ constructor(props) {
+ super(props);
+
+ this.loaded = () => this.props.stream.loadedCount();
+ this.paused = m.prop(false);
+ this.active = () => this.loaded() && !this.paused();
+
+ this.scrollListener = new ScrollListener(this.onscroll.bind(this));
+
+ this.on('loadingIndex', this.loadingIndex.bind(this));
+ this.on('loadedIndex', this.loadedIndex.bind(this));
+
+ this.on('loadingNumber', this.loadingNumber.bind(this));
+ this.on('loadedNumber', this.loadedNumber.bind(this));
+ }
+
+ /**
+
+ */
+ view() {
+ var stream = this.props.stream;
+
+ return m('div', {className: 'stream '+(this.props.className || ''), config: this.onload.bind(this)},
+ stream ? stream.content.map(item => StreamItem.component({
+ key: item.start+'-'+item.end,
+ item: item,
+ loadRange: stream.loadRange.bind(stream),
+ ondelete: this.ondelete.bind(this)
+ }))
+ : LoadingIndicator.component());
+ }
+
+ /**
+
+ */
+ onload(element, isInitialized, context) {
+ this.element(element);
+
+ if (isInitialized) { return; }
+
+ context.onunload = this.ondestroy.bind(this);
+ this.scrollListener.start();
+ }
+
+ ondelete(post) {
+ this.props.stream.removePost(post);
+ }
+
+ /**
+
+ */
+ ondestroy() {
+ this.scrollListener.stop();
+ clearTimeout(this.positionChangedTimeout);
+ }
+
+ /**
+
+ */
+ onscroll(top) {
+ if (!this.active()) { return; }
+
+ var $items = this.$('.item');
+
+ var marginTop = this.getMarginTop();
+ var $window = $(window);
+ var viewportHeight = $window.height() - marginTop;
+ var scrollTop = top + marginTop;
+ var loadAheadDistance = 300;
+ var startNumber;
+ var 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();
+
+ // 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 first = $this.is(':first-child');
+ var last = $this.is(':last-child');
+ var item = $this[0].instance.props.item;
+ if ((first || last) && !item.loading) {
+ item.direction = first ? 'up' : 'down';
+ $this[0].instance.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.
+ clearTimeout(this.positionChangedTimeout);
+ this.positionChangedTimeout = setTimeout(() => this.props.positionChanged(startNumber || 1, endNumber), 500);
+ }
+
+ /**
+ 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() {
+ return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top'));
+ }
+
+ /**
+ Scroll down to a certain post by number (or the gap which we think the
+ post is in) and highlight it.
+ */
+ scrollToNumber(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.)
+ */
+ scrollToIndex(index, noAnimation) {
+ var $item = this.findNearestToIndex(index);
+ return this.scrollToItem($item, noAnimation);
+ }
+
+ /**
+
+ */
+ scrollToItem($item, noAnimation) {
+ var $container = $('html, body').stop(true);
+ if ($item.length) {
+ var scrollTop = $item.is(':first-child') ? 0 : $item.offset().top - this.getMarginTop();
+ if (noAnimation) {
+ $container.scrollTop(scrollTop);
+ } else if (scrollTop !== $(document).scrollTop()) {
+ $container.animate({scrollTop: scrollTop}, 'fast');
+ }
+ }
+ 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(number) {
+ var $nearestItem = $();
+ this.$('.item').each(function() {
+ var $this = $(this);
+ if ($this.data('number') > number) {
+ return false;
+ }
+ $nearestItem = $this;
+ });
+ return $nearestItem;
+ }
+
+ /**
+
+ */
+ findNearestToIndex(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;
+ }
+
+ /**
+
+ */
+ loadingIndex(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.paused(true);
+ this.scrollToIndex(index, noAnimation);
+ }
+
+ /**
+
+ */
+ loadedIndex(index, noAnimation) {
+ m.redraw(true);
+
+ // The post at this index has been loaded. After we scroll to this post,
+ // we want to resume scroll events.
+ this.scrollToIndex(index, noAnimation).done(this.unpause.bind(this));
+ }
+
+ /**
+
+ */
+ loadingNumber(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.paused(true);
+ if (this.$()) {
+ this.scrollToNumber(number, noAnimation);
+ }
+ }
+
+ /**
+
+ */
+ loadedNumber(number, noAnimation) {
+ m.redraw(true);
+
+ // The post with this number has been loaded. After we scroll to this
+ // post, we want to resume scroll events.
+ this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
+ }
+
+ /**
+
+ */
+ unpause() {
+ this.paused(false);
+ this.scrollListener.update(true);
+ this.trigger('unpaused');
+ }
+
+ /**
+
+ */
+ goToNumber(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 promise = this.props.stream.loadNearNumber(number);
+ m.redraw();
+
+ return promise.then(() => this.trigger('loadedNumber', number, noAnimation));
+ }
+
+ /**
+
+ */
+ goToIndex(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 promise = this.props.stream.loadNearIndex(index, backwards);
+ m.redraw();
+
+ return promise.then(() => this.trigger('loadedIndex', index, noAnimation));
+ }
+
+ /**
+
+ */
+ goToFirst() {
+ return this.goToIndex(0);
+ }
+
+ /**
+
+ */
+ goToLast() {
+ var promise = this.goToIndex(this.props.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.
+ var items = this.props.stream.content;
+ if (!items[items.length - 1].post) {
+ promise.then(() => $('html, body').stop(true).scrollTop($('body').height()));
+ }
+
+ return promise;
+ }
+}
diff --git a/framework/core/js/forum/src/components/stream-item.js b/framework/core/js/forum/src/components/stream-item.js
new file mode 100644
index 000000000..c8fd194db
--- /dev/null
+++ b/framework/core/js/forum/src/components/stream-item.js
@@ -0,0 +1,112 @@
+import Component from 'flarum/component';
+import classList from 'flarum/utils/class-list';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+
+export default class StreamItem extends Component {
+ /**
+
+ */
+ constructor(props) {
+ super(props);
+
+ this.element = m.prop();
+ }
+
+ /**
+
+ */
+ view() {
+ var component = this;
+ var item = this.props.item;
+
+ var gap = !item.post;
+ var direction = item.direction;
+ var loading = item.loading;
+ var count = item.end - item.start + 1;
+ var classes = { item: true, gap, loading, direction };
+
+ var attributes = {
+ className: classList(classes),
+ config: this.element,
+ 'data-start': item.start,
+ 'data-end': item.end
+ };
+ if (!gap) {
+ attributes['data-time'] = item.post.time();
+ attributes['data-number'] = item.post.number();
+ } else {
+ attributes['config'] = (element) => {
+ this.element(element);
+ element.instance = this;
+ };
+ attributes['onclick'] = this.load.bind(this);
+ attributes['onmouseenter'] = function(e) {
+ if (!item.loading) {
+ var $this = $(this);
+ var up = e.clientY > $this.offset().top - $(document).scrollTop() + $this.outerHeight(true) / 2;
+ $this.removeClass('up down').addClass(item.direction = up ? 'up' : 'down');
+ }
+ m.redraw.strategy('none');
+ };
+ }
+
+ var content;
+ if (gap) {
+ content = m('span', loading ? LoadingIndicator.component() : count+' more post'+(count !== 1 ? 's' : ''));
+ } else {
+ var PostComponent = app.postComponentRegistry[item.post.contentType()];
+ if (PostComponent) {
+ content = PostComponent.component({post: item.post, ondelete: this.props.ondelete});
+ }
+ }
+
+ return m('div', attributes, content);
+ }
+
+ /**
+
+ */
+ load() {
+ var item = this.props.item;
+
+ // If this item is not a gap, or if we're already loading its posts,
+ // then we don't need to do anything.
+ if (item.post || item.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 = item.direction === 'up' ? 'nextAll' : 'prevAll';
+ var anchor = this.$()[siblingFunc]('.item:first');
+
+ // 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.props.loadRange(item.start, item.end, item.direction === 'up').then(function() {
+ // 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.
+ if (anchor.length) {
+ var scrollOffset = anchor.offset().top - $(document).scrollTop();
+ }
+
+ m.redraw(true);
+
+ // 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.
+ $('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
+ });
+
+ m.redraw();
+ }
+}
diff --git a/framework/core/ember/forum/app/components/discussion/stream-scrubber.js b/framework/core/js/forum/src/components/stream-scrubber.js
similarity index 50%
rename from framework/core/ember/forum/app/components/discussion/stream-scrubber.js
rename to framework/core/js/forum/src/components/stream-scrubber.js
index e9dd1b2a7..bf18af92d 100644
--- a/framework/core/ember/forum/app/components/discussion/stream-scrubber.js
+++ b/framework/core/js/forum/src/components/stream-scrubber.js
@@ -1,248 +1,108 @@
-import Ember from 'ember';
-
-var $ = Ember.$;
+import Component from 'flarum/component';
+import icon from 'flarum/helpers/icon';
+import ScrollListener from 'flarum/utils/scroll-listener';
+import SubtreeRetainer from 'flarum/utils/subtree-retainer';
+import computed from 'flarum/utils/computed';
/**
- 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: ['stream-scrubber', 'dropdown'],
- classNameBindings: ['disabled'],
+export default class StreamScrubber extends Component {
+ /**
- // The stream-content component to which this scrubber is linked.
- streamContent: null,
+ */
+ constructor(props) {
+ super(props);
- // 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;
+ var streamContent = this.props.streamContent;
+ this.handlers = {};
// 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);
+ streamContent.on('loadingIndex', this.handlers.loadingIndex = this.loadingIndex.bind(this));
+ streamContent.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this));
- // 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();
+ /**
+ 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.
+ */
+ this.disabled = () => !streamContent.loaded() || this.visible() >= this.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).
+ */
+ this.visibleIndex = computed('index', 'visible', 'count', function(index, visible, count) {
+ return Math.min(count, Math.ceil(Math.max(0, index) + visible));
+ });
+
+ this.count = () => this.props.streamContent.props.stream.count();
+ this.index = m.prop(-1);
+ this.visible = m.prop(1);
+ this.description = m.prop();
// Define a handler to update the state of the scrollbar to reflect the
// current scroll position of the page.
- $(window).on('scroll', {view: this}, this.windowWasScrolled);
+ this.scrollListener = new ScrollListener(this.onscroll.bind(this));
- // When any part of the whole scrollbar is clicked, we want to jump to
- // that position.
- this.$('.scrubber-scrollbar')
- .bind('click touchstart', function(e) {
- if (!view.get('streamContent.active')) { return; }
+ this.subtree = new SubtreeRetainer(() => true);
+ }
- // 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 || e.originalEvent.touches[0].clientY) - $this.offset().top + $('body').scrollTop();
- var offsetPercent = offsetPixels / $this.outerHeight() * 100;
+ unpaused() {
+ this.update(window.pageYOffset);
+ this.renderScrollbar(true);
+ }
- // 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));
+ */
+ view() {
+ var retain = this.subtree.retain();
+ var streamContent = this.props.streamContent;
- view.$().removeClass('open');
- });
+ return m('div.stream-scrubber.dropdown'+(this.disabled() ? '.disabled' : ''), {config: this.onload.bind(this)}, [
+ m('a.btn.btn-default.dropdown-toggle[href=javascript:;][data-toggle=dropdown]', [
+ m('span.index', retain || this.visibleIndex()), ' of ', m('span.count', this.count()), ' posts ',
+ icon('sort icon-glyph')
+ ]),
+ m('div.dropdown-menu', [
+ m('div.scrubber', [
+ m('a.scrubber-first[href=javascript:;]', {onclick: streamContent.goToFirst.bind(streamContent)}, [icon('angle-double-up'), ' Original Post']),
+ m('div.scrubber-scrollbar', [
+ m('div.scrubber-before'),
+ m('div.scrubber-slider', [
+ m('div.scrubber-handle'),
+ m('div.scrubber-info', [
+ m('strong', [m('span.index', retain || this.visibleIndex()), ' of ', m('span.count', this.count()), ' posts']),
+ m('span.description', retain || this.description())
+ ])
+ ]),
+ m('div.scrubber-after')
+ ]),
+ m('a.scrubber-last[href=javascript:;]', {onclick: streamContent.goToLast.bind(streamContent)}, [icon('angle-double-down'), ' Now'])
+ ])
+ ])
+ ])
+ }
- // 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 touchstart', function(e) {
- e.preventDefault();
- });
+ onscroll(top) {
+ var streamContent = this.props.streamContent;
- // 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')
- .bind('mousedown touchstart', function(e) {
- dragData.mouseStart = e.clientY || e.originalEvent.touches[0].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();
- });
+ if (!streamContent.active() || !streamContent.$()) { return; }
- // 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 touchmove', dragData, this.mouseWasMoved)
- .on('mouseup touchend', dragData, this.mouseWasReleased);
+ this.update(top);
+ this.renderScrollbar();
+ }
- // 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 touchmove', this.mouseWasMoved)
- .off('mouseup touchend', 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() - parseInt($('#page').css('padding-bottom')));
- },
-
- 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.originalEvent.touches[0].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();
-
- if (! view.$().is('.open')) {
- 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 (view.$().is('.open')) {
- view.scrollToIndex(view.get('index'));
- view.$().removeClass('open');
- }
-
- // 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; }
+ /**
+ Update the index/visible/description properties according to the window's
+ current scroll position.
+ */
+ update(top) {
+ var streamContent = this.props.streamContent;
var $window = $(window);
- var marginTop = this.get('streamContent').getMarginTop();
+ var marginTop = streamContent.getMarginTop();
var scrollTop = $window.scrollTop() + marginTop;
var windowHeight = $window.height() - marginTop;
@@ -250,7 +110,7 @@ export default Ember.Component.extend({
// 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 $items = streamContent.$('.item');
var index = $items.first().data('end') - 1;
var visible = 0;
var period = '';
@@ -306,54 +166,252 @@ export default Ember.Component.extend({
}
});
- this.set('index', index);
- this.set('visible', visible);
- this.set('description', period ? moment(period).format('MMMM YYYY') : '');
- },
+ this.index(index);
+ this.visible(visible);
+ this.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) {
+ /**
+
+ */
+ onload(element, isInitialized, context) {
+ this.element(element);
+
+ if (isInitialized) { return; }
+
+ this.renderScrollbar();
+
+ context.onunload = this.ondestroy.bind(this);
+ this.scrollListener.start();
+
+ // Whenever the window is resized, adjust the height of the scrollbar
+ // so that it fills the height of the sidebar.
+ $(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
+
+ var self = this;
+
+ // When any part of the whole scrollbar is clicked, we want to jump to
+ // that position.
+ this.$('.scrubber-scrollbar')
+ .bind('click touchstart', function(e) {
+ if (!self.props.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 || e.originalEvent.touches[0].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 / self.percentPerPost().index;
+ offsetIndex = Math.max(0, Math.min(self.count() - 1, offsetIndex));
+ self.props.streamContent.goToIndex(Math.floor(offsetIndex));
+
+ self.$().removeClass('open');
+ });
+
+ // 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 touchstart', 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.
+ this.mouseStart = 0;
+ this.indexStart = 0;
+ this.handle = null;
+
+ this.$('.scrubber-slider')
+ .css('cursor', 'move')
+ .bind('mousedown touchstart', function(e) {
+ self.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
+ self.indexStart = self.index();
+ self.handle = $(this);
+ self.props.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 touchmove', this.handlers.onmousemove = this.onmousemove.bind(this))
+ .on('mouseup touchend', this.handlers.onmouseup = this.onmouseup.bind(this));
+ }
+
+ ondestroy() {
+ this.scrollListener.stop();
+
+ this.props.streamContent.off('loadingIndex', this.handlers.loadingIndex);
+ this.props.streamContent.off('unpaused', this.handlers.unpaused);
+
+ $(window)
+ .off('resize', this.handlers.onresize);
+
+ $(document)
+ .off('mousemove touchmove', this.handlers.onmousemove)
+ .off('mouseup touchend', this.handlers.onmouseup);
+ }
+
+ /**
+ Update the scrollbar's position to reflect the current values of the
+ index/visible properties.
+ */
+ renderScrollbar(animate) {
var percentPerPost = this.percentPerPost();
- var index = this.get('index');
- var count = this.get('count');
- var visible = this.get('visible');
+ var index = this.index();
+ var count = this.count();
+ var visible = this.visible();
+
+ var $scrubber = this.$();
+ $scrubber.find('.index').text(this.visibleIndex());
+ // $scrubber.find('.count').text(count);
+ $scrubber.find('.description').text(this.description());
+ $scrubber.toggleClass('disabled', this.disabled());
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]+'%'});
+ $part.stop(true, true)[func]({height: heights[part]+'%'}, 'fast');
- // jQuery likes to put overflow:hidden, but because the scrollbar
- // handle has a negative margin-left, we need to override.
+ // 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);
+ /**
+
+ */
+ percentPerPost() {
+ var count = this.count() || 1;
+ var visible = this.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
+ };
+ }
+
+ /*
+ When the stream-content component begins loading posts at a certain
+ index, we want our scrubber scrollbar to jump to that position.
+ */
+ loadingIndex(index) {
+ this.index(index);
+ this.renderScrollbar(true);
+ }
+
+ onresize(event) {
+ this.scrollListener.update(true);
+
+ // Adjust the height of the scrollbar so that it fills the height of
+ // the sidebar and doesn't overlap the footer.
+ var scrollbar = this.$('.scrubber-scrollbar');
+ scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - parseInt($('.global-page').css('padding-bottom')));
+ }
+
+ onmousemove(event) {
+ if (! this.handle) { return; }
+
+ // 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.originalEvent.touches[0].clientY) - this.mouseStart;
+ var deltaPercent = deltaPixels / this.$('.scrubber-scrollbar').outerHeight() * 100;
+ var deltaIndex = deltaPercent / this.percentPerPost().index;
+ var newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
+
+ this.index(Math.max(0, newIndex));
+ this.renderScrollbar();
+
+ if (! this.$().is('.open')) {
+ this.scrollToIndex(newIndex);
+ }
+ }
+
+ onmouseup(event) {
+ if (!this.handle) { return; }
+ this.mouseStart = 0;
+ this.indexStart = 0;
+ this.handle = null;
+ $('body').css('cursor', '');
+
+ if (this.$().is('.open')) {
+ this.scrollToIndex(this.index());
+ this.$().removeClass('open');
+ }
+
+ // 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(this.index());
+ if (!this.props.streamContent.props.stream.findNearestToIndex(intIndex).content) {
+ this.props.streamContent.goToIndex(intIndex);
+ } else {
+ this.props.streamContent.paused(false);
+ }
+ }
+
+ /**
+ 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(index) {
+ var streamContent = this.props.streamContent;
+
+ index = Math.min(index, this.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);
+ var $nearestItem = 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();
+ var pos = $nearestItem.offset().top - streamContent.getMarginTop();
if ($nearestItem.is('.gap')) {
$nearestItem.addClass('active');
} else {
@@ -365,38 +423,8 @@ export default Ember.Component.extend({
}
// Remove the 'active' class from other gaps.
- this.get('streamContent').$().find('.gap').not($nearestItem).removeClass('active');
+ streamContent.$().find('.gap').not($nearestItem).removeClass('active');
$('html, body').scrollTop(pos);
- },
-
- percentPerPost: function() {
- var count = this.get('count') || 1;
- var visible = this.get('visible');
-
- // To stop the slider of the scrollbar from getting too small when there
- // are many posts, we define a minimum percentage height for the slider
- // calculated from a 50 pixel limit. From this, we can calculate the
- // minimum percentage per visible post. If this is greater than the
- // actual percentage per post, then we need to adjust the 'before'
- // percentage to account for it.
- var minPercentVisible = 50 / this.$('.scrubber-scrollbar').outerHeight() * 100;
- var percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible);
- var percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible);
-
- return {
- index: percentPerPost,
- visible: percentPerVisiblePost
- };
- },
-
- actions: {
- first: function() {
- this.get('streamContent').send('goToFirst');
- },
-
- last: function() {
- this.get('streamContent').send('goToLast');
- }
}
-});
+}
diff --git a/framework/core/js/forum/src/components/terminal-post.js b/framework/core/js/forum/src/components/terminal-post.js
new file mode 100644
index 000000000..7897f2e8d
--- /dev/null
+++ b/framework/core/js/forum/src/components/terminal-post.js
@@ -0,0 +1,24 @@
+import Component from 'flarum/component';
+import humanTime from 'flarum/utils/human-time';
+
+/**
+ Displays information about a the first or last post in a discussion.
+
+ @prop discussion {Discussion} The discussion to display the post for
+ @prop lastPost {Boolean} Whether or not to display the last/start post
+ @class TerminalPost
+ @constructor
+ @extends Component
+ */
+export default class TerminalPost extends Component {
+ view() {
+ var discussion = this.props.discussion;
+ var lastPost = this.props.lastPost && discussion.repliesCount();
+
+ return m('li', [
+ m('span.username', discussion[lastPost ? 'lastUser' : 'startUser']().username()),
+ lastPost ? ' replied ' : ' started ',
+ m('time', humanTime(discussion[lastPost ? 'lastTime' : 'startTime']()))
+ ])
+ }
+}
diff --git a/framework/core/js/forum/src/components/user-bio.js b/framework/core/js/forum/src/components/user-bio.js
new file mode 100644
index 000000000..09ea34b1e
--- /dev/null
+++ b/framework/core/js/forum/src/components/user-bio.js
@@ -0,0 +1,64 @@
+import Component from 'flarum/component';
+import humanTime from 'flarum/utils/human-time';
+import ItemList from 'flarum/utils/item-list';
+import classList from 'flarum/utils/class-list';
+import avatar from 'flarum/helpers/avatar';
+import username from 'flarum/helpers/username';
+import icon from 'flarum/helpers/icon';
+import DropdownButton from 'flarum/components/dropdown-button';
+import ActionButton from 'flarum/components/action-button';
+import listItems from 'flarum/helpers/list-items';
+
+export default class UserBio extends Component {
+ constructor(props) {
+ super(props);
+
+ this.editing = m.prop(false);
+ }
+
+ view() {
+ var user = this.props.user;
+
+ return m('div.user-bio', {
+ className: classList({editable: this.isEditable(), editing: this.editing()}),
+ onclick: this.edit.bind(this),
+ config: this.element
+ }, [
+ this.editing()
+ ? m('textarea.form-control', {value: user.bio()})
+ : m('div.bio-content', [
+ user.bioHtml()
+ ? m.trust(user.bioHtml())
+ : (this.props.editable ? m('p', 'Write something about yourself...') : '')
+ ])
+ ]);
+ }
+
+ isEditable() {
+ return this.props.user.canEdit() && this.props.editable;
+ }
+
+ edit() {
+ if (!this.isEditable()) { return; }
+
+ this.editing(true);
+ var height = this.$().height();
+
+ m.redraw();
+
+ var self = this;
+ var save = function(e) {
+ if (e.shiftKey) { return; }
+ e.preventDefault();
+ self.save($(this).val());
+ };
+ this.$('textarea').css('height', height).focus().bind('blur', save).bind('keydown', 'return', save);
+ }
+
+ save(value) {
+ this.editing(false);
+
+ this.props.user.save({bio: value}).then(() => m.redraw());
+ m.redraw();
+ }
+}
diff --git a/framework/core/js/forum/src/components/user-card.js b/framework/core/js/forum/src/components/user-card.js
new file mode 100644
index 000000000..4a25a3e19
--- /dev/null
+++ b/framework/core/js/forum/src/components/user-card.js
@@ -0,0 +1,76 @@
+import Component from 'flarum/component';
+import humanTime from 'flarum/utils/human-time';
+import ItemList from 'flarum/utils/item-list';
+import avatar from 'flarum/helpers/avatar';
+import username from 'flarum/helpers/username';
+import icon from 'flarum/helpers/icon';
+import DropdownButton from 'flarum/components/dropdown-button';
+import ActionButton from 'flarum/components/action-button';
+import UserBio from 'flarum/components/user-bio';
+import AvatarEditor from 'flarum/components/avatar-editor';
+import listItems from 'flarum/helpers/list-items';
+
+export default class UserCard extends Component {
+ view() {
+ var user = this.props.user;
+ var controls = this.controlItems().toArray();
+
+ return m('div.user-card', {className: this.props.className, style: 'background-color: '+user.color()}, [
+ m('div.darken-overlay'),
+ m('div.container', [
+ controls.length ? DropdownButton.component({
+ items: controls,
+ className: 'contextual-controls',
+ menuClass: 'pull-right',
+ buttonClass: this.props.controlsButtonClass
+ }) : '',
+ m('div.user-profile', [
+ m('h2.user-identity', this.props.editable
+ ? [AvatarEditor.component({user, className: 'user-avatar'}), username(user)]
+ : m('a', {href: app.route('user', user), config: m.route}, [
+ avatar(user, {className: 'user-avatar'}),
+ username(user)
+ ])
+ ),
+ m('ul.user-badges.badges', listItems(user.badges().toArray())),
+ m('ul.user-info', listItems(this.infoItems().toArray()))
+ ])
+ ])
+ ]);
+ }
+
+ controlItems() {
+ var items = new ItemList();
+
+ items.add('edit', ActionButton.component({ icon: 'pencil', label: 'Edit' }));
+ items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete' }));
+
+ return items;
+ }
+
+ infoItems() {
+ var items = new ItemList();
+ var user = this.props.user;
+ var online = user.online();
+
+ items.add('bio',
+ UserBio.component({
+ user,
+ editable: this.props.editable,
+ wrapperClass: 'block-item'
+ })
+ );
+
+ if (user.lastSeenTime()) {
+ items.add('lastSeen',
+ m('span.user-last-seen', {className: online ? 'online' : ''}, online
+ ? [icon('circle'), ' Online']
+ : [icon('clock-o'), ' ', humanTime(user.lastSeenTime())])
+ );
+ }
+
+ items.add('joined', ['Joined ', humanTime(user.joinTime())]);
+
+ return items;
+ }
+}
diff --git a/framework/core/js/forum/src/components/user-dropdown.js b/framework/core/js/forum/src/components/user-dropdown.js
new file mode 100644
index 000000000..c475183f8
--- /dev/null
+++ b/framework/core/js/forum/src/components/user-dropdown.js
@@ -0,0 +1,65 @@
+import Component from 'flarum/component';
+import avatar from 'flarum/helpers/avatar';
+import username from 'flarum/helpers/username';
+import DropdownButton from 'flarum/components/dropdown-button';
+import ActionButton from 'flarum/components/action-button';
+import ItemList from 'flarum/utils/item-list';
+import Separator from 'flarum/components/separator';
+
+export default class UserDropdown extends Component {
+ view() {
+ var user = this.props.user;
+
+ return DropdownButton.component({
+ buttonClass: 'btn btn-default btn-naked btn-rounded btn-user',
+ menuClass: 'pull-right',
+ buttonContent: [avatar(user), ' ', m('span.label', username(user))],
+ items: this.items().toArray()
+ });
+ }
+
+ items() {
+ var items = new ItemList();
+ var user = this.props.user;
+
+ items.add('profile',
+ ActionButton.component({
+ icon: 'user',
+ label: 'Profile',
+ href: app.route('user', user),
+ config: m.route
+ })
+ );
+
+ items.add('settings',
+ ActionButton.component({
+ icon: 'cog',
+ label: 'Settings',
+ href: app.route('settings'),
+ config: m.route
+ })
+ );
+
+ if (user.groups().some((group) => group.id() == 1)) {
+ items.add('administration',
+ ActionButton.component({
+ icon: 'wrench',
+ label: 'Administration',
+ href: app.config.baseURL+'/admin'
+ })
+ );
+ }
+
+ items.add('separator', Separator.component());
+
+ items.add('logOut',
+ ActionButton.component({
+ icon: 'sign-out',
+ label: 'Log Out',
+ onclick: app.session.logout.bind(app.session)
+ })
+ );
+
+ return items;
+ }
+}
diff --git a/framework/core/js/forum/src/components/user-notifications.js b/framework/core/js/forum/src/components/user-notifications.js
new file mode 100644
index 000000000..d406ea570
--- /dev/null
+++ b/framework/core/js/forum/src/components/user-notifications.js
@@ -0,0 +1,72 @@
+import Component from 'flarum/component';
+import avatar from 'flarum/helpers/avatar';
+import icon from 'flarum/helpers/icon';
+import username from 'flarum/helpers/username';
+import DropdownButton from 'flarum/components/dropdown-button';
+import ActionButton from 'flarum/components/action-button';
+import ItemList from 'flarum/utils/item-list';
+import Separator from 'flarum/components/separator';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+
+export default class UserNotifications extends Component {
+ constructor(props) {
+ super(props);
+
+ this.loading = m.prop(false);
+ }
+
+ view() {
+ var user = this.props.user;
+
+ return DropdownButton.component({
+ className: 'notifications'+(user.unreadNotificationsCount() ? ' unread' : ''),
+ buttonClass: 'btn btn-default btn-rounded btn-naked btn-icon',
+ menuClass: 'pull-right',
+ buttonContent: [
+ m('span.notifications-icon', user.unreadNotificationsCount() || icon('bell icon-glyph')),
+ m('span.label', 'Notifications')
+ ],
+ buttonClick: this.load.bind(this),
+ menuContent: [
+ m('div.notifications-header', [
+ ActionButton.component({
+ className: 'btn btn-icon btn-link btn-sm',
+ icon: 'check',
+ title: 'Mark All as Read',
+ onclick: this.markAllAsRead.bind(this)
+ }),
+ m('h4', 'Notifications')
+ ]),
+ m('ul.notifications-list', app.cache.notifications
+ ? app.cache.notifications.map(notification => {
+ var NotificationComponent = app.notificationComponentRegistry[notification.contentType()];
+ return NotificationComponent ? m('li', NotificationComponent.component({notification})) : '';
+ })
+ : (!this.loading() ? m('li.no-notifications', 'No Notifications') : '')),
+ this.loading() ? LoadingIndicator.component() : ''
+ ]
+ });
+ }
+
+ load() {
+ if (!app.cache.notifications) {
+ var component = this;
+ this.loading(true);
+ m.redraw();
+ app.store.find('notifications').then(notifications => {
+ this.props.user.pushData({unreadNotificationsCount: 0});
+ this.loading(false);
+ app.cache.notifications = notifications;
+ m.redraw();
+ })
+ }
+ }
+
+ markAllAsRead() {
+ app.cache.notifications.forEach(function(notification) {
+ if (!notification.isRead()) {
+ notification.save({isRead: true});
+ }
+ })
+ }
+}
diff --git a/framework/core/js/forum/src/components/user-page.js b/framework/core/js/forum/src/components/user-page.js
new file mode 100644
index 000000000..919335a16
--- /dev/null
+++ b/framework/core/js/forum/src/components/user-page.js
@@ -0,0 +1,147 @@
+import Component from 'flarum/component';
+import ItemList from 'flarum/utils/item-list';
+import IndexPage from 'flarum/components/index-page';
+import DiscussionList from 'flarum/components/discussion-list';
+import StreamContent from 'flarum/components/stream-content';
+import StreamScrubber from 'flarum/components/stream-scrubber';
+import UserCard from 'flarum/components/user-card';
+import ComposerReply from 'flarum/components/composer-reply';
+import ActionButton from 'flarum/components/action-button';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+import DropdownSplit from 'flarum/components/dropdown-split';
+import DropdownSelect from 'flarum/components/dropdown-select';
+import NavItem from 'flarum/components/nav-item';
+import Separator from 'flarum/components/separator';
+import listItems from 'flarum/helpers/list-items';
+
+export default class UserPage extends Component {
+ /**
+
+ */
+ constructor(props) {
+ super(props);
+
+ app.history.push('user');
+ app.current = this;
+ }
+
+ /*
+
+ */
+ setupUser(user) {
+ this.user(user);
+ }
+
+ onload(element, isInitialized, context) {
+ if (isInitialized) { return; }
+
+ $('body').addClass('user-page');
+ context.onunload = function() {
+ $('body').removeClass('user-page');
+ }
+ }
+
+ /**
+
+ */
+ view() {
+ var user = this.user();
+
+ return m('div', {config: this.onload.bind(this)}, user ? [
+ UserCard.component({user, className: 'hero user-hero', editable: true, controlsButtonClass: 'btn btn-default'}),
+ m('div.container', [
+ m('nav.side-nav.user-nav', {config: this.affixSidebar}, [
+ m('ul', listItems(this.sidebarItems().toArray()))
+ ]),
+ m('div.offset-content.user-content', this.content())
+ ])
+ ] : LoadingIndicator.component({className: 'loading-indicator-block'}));
+ }
+
+ /**
+
+ */
+ sidebarItems() {
+ var items = new ItemList();
+
+ items.add('nav',
+ DropdownSelect.component({
+ items: this.navItems().toArray(),
+ wrapperClass: 'title-control'
+ })
+ );
+
+ return items;
+ }
+
+ /**
+ Build an item list for the navigation in the sidebar of the index page. By
+ default this is just the 'All Discussions' link.
+
+ @return {ItemList}
+ */
+ navItems() {
+ var items = new ItemList();
+ var user = this.user();
+
+ items.add('activity',
+ NavItem.component({
+ href: app.route('user.activity', user),
+ label: 'Activity',
+ icon: 'user'
+ })
+ );
+
+ items.add('discussions',
+ NavItem.component({
+ href: app.route('user.discussions', user),
+ label: 'Discussions',
+ icon: 'reorder',
+ badge: user.discussionsCount()
+ })
+ );
+
+ items.add('posts',
+ NavItem.component({
+ href: app.route('user.posts', user),
+ label: 'Posts',
+ icon: 'comment-o',
+ badge: user.commentsCount()
+ })
+ );
+
+ if (app.session.user() === user) {
+ items.add('separator', Separator.component());
+ items.add('settings',
+ NavItem.component({
+ href: app.route('settings'),
+ label: 'Settings',
+ icon: 'cog'
+ })
+ );
+ }
+
+ return items;
+ }
+
+ /**
+ Setup the sidebar DOM element to be affixed to the top of the viewport
+ using Bootstrap's affix plugin.
+
+ @param {DOMElement} element
+ @param {Boolean} isInitialized
+ @return {void}
+ */
+ affixSidebar(element, isInitialized, context) {
+ if (isInitialized) { return; }
+
+ var $sidebar = $(element);
+ console.log($sidebar.find('> ul'), $sidebar.find('> ul').data('bs.affix'));
+ $sidebar.find('> ul').affix({
+ offset: {
+ top: $sidebar.offset().top - $('.global-header').outerHeight(true) - parseInt($sidebar.css('margin-top')),
+ bottom: $('.global-footer').outerHeight(true)
+ }
+ });
+ }
+}
diff --git a/framework/core/js/forum/src/components/welcome-hero.js b/framework/core/js/forum/src/components/welcome-hero.js
new file mode 100644
index 000000000..91c02524c
--- /dev/null
+++ b/framework/core/js/forum/src/components/welcome-hero.js
@@ -0,0 +1,32 @@
+import Component from 'flarum/component';
+
+export default class WelcomeHero extends Component {
+ constructor(props) {
+ super(props);
+
+ this.title = m.prop('Mithril Forum')
+ this.description = m.prop('Hello')
+ this.hidden = m.prop(localStorage.getItem('welcomeHidden'))
+ }
+
+ hide() {
+ localStorage.setItem('welcomeHidden', 'true')
+ this.hidden(true)
+ }
+
+ view() {
+ var root = m.prop()
+ var self = this;
+ return this.hidden() ? m('') : m('header.hero.welcome-hero', {config: root}, [
+ m('div.container', [
+ m('button.close.btn.btn-icon.btn-link', {onclick: function() {
+ $(root()).slideUp(self.hide.bind(self))
+ }}, m('i.fa.fa-times')),
+ m('div.container-narrow', [
+ m('h2', this.title()),
+ m('p', this.description())
+ ])
+ ])
+ ])
+ }
+}
diff --git a/framework/core/js/forum/src/initializers/boot.js b/framework/core/js/forum/src/initializers/boot.js
new file mode 100644
index 000000000..569ebccbc
--- /dev/null
+++ b/framework/core/js/forum/src/initializers/boot.js
@@ -0,0 +1,43 @@
+import ScrollListener from 'flarum/utils/scroll-listener';
+import History from 'flarum/utils/history';
+import Pane from 'flarum/utils/pane';
+import mapRoutes from 'flarum/utils/map-routes';
+
+import BackButton from 'flarum/components/back-button';
+import HeaderPrimary from 'flarum/components/header-primary';
+import HeaderSecondary from 'flarum/components/header-secondary';
+import FooterPrimary from 'flarum/components/footer-primary';
+import FooterSecondary from 'flarum/components/footer-secondary';
+import Composer from 'flarum/components/composer';
+import Modal from 'flarum/components/modal';
+import Alerts from 'flarum/components/alerts';
+import SignupModal from 'flarum/components/signup-modal';
+import LoginModal from 'flarum/components/login-modal';
+
+export default function(app) {
+ var id = id => document.getElementById(id);
+
+ app.history = new History();
+ app.pane = new Pane(id('page'));
+ app.cache = {};
+
+ app.signup = () => app.modal.show(new SignupModal());
+ app.login = () => app.modal.show(new LoginModal());
+
+ m.mount(id('back-control'), BackButton.component({ className: 'back-control', drawer: true }));
+ m.mount(id('back-button'), BackButton.component());
+
+ m.mount(id('header-primary'), HeaderPrimary.component());
+ m.mount(id('header-secondary'), HeaderSecondary.component());
+ m.mount(id('footer-primary'), FooterPrimary.component());
+ m.mount(id('footer-secondary'), FooterSecondary.component());
+
+ app.composer = m.mount(id('composer'), Composer.component());
+ app.modal = m.mount(id('modal'), Modal.component());
+ app.alerts = m.mount(id('alerts'), Alerts.component());
+
+ m.route.mode = 'hash';
+ m.route(id('content'), '/', mapRoutes(app.routes));
+
+ new ScrollListener(top => $('body').toggleClass('scrolled', top > 0)).start();
+}
diff --git a/framework/core/js/forum/src/initializers/components.js b/framework/core/js/forum/src/initializers/components.js
new file mode 100644
index 000000000..8fa216c92
--- /dev/null
+++ b/framework/core/js/forum/src/initializers/components.js
@@ -0,0 +1,21 @@
+import PostComment from 'flarum/components/post-comment';
+import PostDiscussionRenamed from 'flarum/components/post-discussion-renamed';
+import ActivityPost from 'flarum/components/activity-post';
+import ActivityJoin from 'flarum/components/activity-join';
+import NotificationDiscussionRenamed from 'flarum/components/notification-discussion-renamed';
+
+export default function(app) {
+ app.postComponentRegistry = {
+ comment: PostComment,
+ discussionRenamed: PostDiscussionRenamed
+ };
+
+ app.activityComponentRegistry = {
+ post: ActivityPost,
+ join: ActivityJoin
+ };
+
+ app.notificationComponentRegistry = {
+ discussionRenamed: NotificationDiscussionRenamed
+ };
+}
diff --git a/framework/core/js/forum/src/initializers/routes.js b/framework/core/js/forum/src/initializers/routes.js
new file mode 100644
index 000000000..52a4352aa
--- /dev/null
+++ b/framework/core/js/forum/src/initializers/routes.js
@@ -0,0 +1,21 @@
+import IndexPage from 'flarum/components/index-page';
+import DiscussionPage from 'flarum/components/discussion-page';
+import ActivityPage from 'flarum/components/activity-page';
+import SettingsPage from 'flarum/components/settings-page';
+
+export default function(app) {
+ app.routes = {
+ 'index': ['/', IndexPage.component()],
+ 'index.filter': ['/:filter', IndexPage.component()],
+
+ 'discussion': ['/d/:id/:slug', DiscussionPage.component()],
+ 'discussion.near': ['/d/:id/:slug/:near', DiscussionPage.component()],
+
+ 'user': ['/u/:username', ActivityPage.component()],
+ 'user.activity': ['/u/:username', ActivityPage.component()],
+ 'user.discussions': ['/u/:username/discussions', ActivityPage.component({filter: 'discussion'})],
+ 'user.posts': ['/u/:username/posts', ActivityPage.component({filter: 'post'})],
+
+ 'settings': ['/settings', SettingsPage.component()]
+ };
+}
diff --git a/framework/core/js/forum/src/utils/history.js b/framework/core/js/forum/src/utils/history.js
new file mode 100644
index 000000000..d51e5e948
--- /dev/null
+++ b/framework/core/js/forum/src/utils/history.js
@@ -0,0 +1,43 @@
+export default class History {
+ constructor() {
+ this.stack = [];
+ this.push('index', '/');
+ }
+
+ top() {
+ return this.stack[this.stack.length - 1];
+ }
+
+ push(name, url) {
+ var url = url || m.route();
+
+ // maybe? prevents browser back button from breaking history
+ var secondTop = this.stack[this.stack.length - 2];
+ if (secondTop && secondTop.name === name) {
+ this.stack.pop();
+ }
+
+ var top = this.top();
+ if (top && top.name === name) {
+ top.url = url;
+ } else {
+ this.stack.push({name: name, url: url});
+ }
+ }
+
+ canGoBack() {
+ return this.stack.length > 1;
+ }
+
+ back() {
+ this.stack.pop();
+ var top = this.top();
+ m.route(top.url);
+ }
+
+ home() {
+ this.stack.splice(1);
+ var top = this.top();
+ m.route(top.url);
+ }
+}
diff --git a/framework/core/js/forum/src/utils/pane.js b/framework/core/js/forum/src/utils/pane.js
new file mode 100644
index 000000000..a1bac964f
--- /dev/null
+++ b/framework/core/js/forum/src/utils/pane.js
@@ -0,0 +1,50 @@
+export default class Pane {
+ constructor(element) {
+ this.pinnedKey = 'panePinned';
+
+ this.$element = $(element);
+
+ this.pinned = localStorage.getItem(this.pinnedKey) !== 'false';
+ this.active = false;
+ this.showing = false;
+ this.render();
+ }
+
+ enable() {
+ this.active = true;
+ this.render();
+ }
+
+ disable() {
+ this.active = false;
+ this.showing = false;
+ this.render();
+ }
+
+ show() {
+ clearTimeout(this.hideTimeout);
+ this.showing = true;
+ this.render();
+ }
+
+ hide() {
+ this.showing = false;
+ this.render();
+ }
+
+ onmouseleave() {
+ this.hideTimeout = setTimeout(this.hide.bind(this), 250);
+ }
+
+ togglePinned() {
+ localStorage.setItem(this.pinnedKey, (this.pinned = !this.pinned) ? 'true' : 'false');
+ this.render();
+ }
+
+ render() {
+ this.$element
+ .toggleClass('pane-pinned', this.pinned)
+ .toggleClass('has-pane', this.active)
+ .toggleClass('pane-showing', this.showing);
+ }
+}
diff --git a/framework/core/js/forum/src/utils/post-stream.js b/framework/core/js/forum/src/utils/post-stream.js
new file mode 100644
index 000000000..903b3b544
--- /dev/null
+++ b/framework/core/js/forum/src/utils/post-stream.js
@@ -0,0 +1,155 @@
+export default class PostStream {
+ constructor(discussion) {
+ this.discussion = discussion
+ this.ids = this.discussion.data().links.posts.linkage.map((link) => link.id)
+
+ var item = this.makeItem(0, this.ids.length - 1)
+ item.loading = true
+ this.content = [item]
+
+ this.postLoadCount = 20
+ }
+
+ count() {
+ return this.ids.length;
+ }
+
+ loadedCount() {
+ return this.content.filter((item) => item.post).length;
+ }
+
+ loadRange(start, end, backwards) {
+ // Find the appropriate gap objects in the post stream. When we find
+ // one, we will turn on its loading flag.
+ this.content.forEach(function(item) {
+ if (!item.post && ((item.start >= start && item.start <= end) || (item.end >= start && item.end <= end))) {
+ item.loading = true
+ item.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.ids.slice(start, end + 1);
+ var limit = this.postLoadCount
+ ids = backwards ? ids.slice(-limit) : ids.slice(0, limit)
+
+ return this.loadPosts(ids)
+ }
+
+ loadPosts(ids) {
+ if (!ids.length) {
+ return m.deferred().resolve().promise;
+ }
+
+ return app.store.find('posts', ids).then(this.addPosts.bind(this));
+ }
+
+ loadNearNumber(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.post && item.post.number() === number) {
+ return m.deferred().resolve([item.post]).promise;
+ } else if (!item.post) {
+ item.direction = 'down'
+ item.loading = true;
+ }
+ }
+
+ var stream = this
+ return app.store.find('posts', {
+ discussions: this.discussion.id(),
+ near: number,
+ count: this.postLoadCount
+ }).then(this.addPosts.bind(this))
+ }
+
+ loadNearIndex(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.post) {
+ return m.deferred().resolve([item.post]).promise;
+ }
+ return this.loadRange(Math.max(item.start, index - this.postLoadCount / 2), item.end, backwards);
+ }
+ }
+
+ addPosts(posts) {
+ posts.forEach(this.addPost.bind(this))
+ }
+
+ addPost(post) {
+ var index = this.ids.indexOf(post.id())
+ var content = this.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.start <= index && item.end >= index) {
+ var newItems = []
+ if (item.start < index) {
+ newItems.push(makeItem(item.start, index - 1))
+ }
+ newItems.push(makeItem(index, index, post))
+ if (item.end > index) {
+ newItems.push(makeItem(index + 1, item.end))
+ }
+ var args = [i, 1].concat(newItems);
+ [].splice.apply(content, args)
+ return true
+ }
+ })
+ }
+
+ addPostToEnd(post) {
+ var index = this.ids.length
+ this.ids.push(post.id())
+ this.content.push(this.makeItem(index, index, post))
+ }
+
+ removePost(post) {
+ this.ids.splice(this.ids.indexOf(post.id()), 1);
+ this.content.some((item, i) => {
+ if (item.post === post) {
+ this.content.splice(i, 1);
+ return true;
+ }
+ });
+ }
+
+ makeItem(start, end, post) {
+ var item = {start, end}
+ if (post) {
+ item.post = post
+ }
+ return item
+ }
+
+ findNearestTo(index, property) {
+ var nearestItem
+ this.content.some(function(item) {
+ if (property(item) > index) { return true }
+ nearestItem = item
+ })
+ return nearestItem
+ }
+
+ findNearestToNumber(number) {
+ return this.findNearestTo(number, (item) => item.post && item.post.number())
+ }
+
+ findNearestToIndex(index) {
+ return this.findNearestTo(index, (item) => item.start)
+ }
+}
diff --git a/framework/core/js/lib/component.js b/framework/core/js/lib/component.js
new file mode 100644
index 000000000..1709592f5
--- /dev/null
+++ b/framework/core/js/lib/component.js
@@ -0,0 +1,42 @@
+/**
+
+ */
+export default class Component {
+ /**
+
+ */
+ constructor(props) {
+ this.props = props || {};
+
+ this.element = m.prop();
+ }
+
+ /**
+
+ */
+ $(selector) {
+ return selector ? $(this.element()).find(selector) : $(this.element());
+ }
+
+ /**
+
+ */
+ static component(props) {
+ props = props || {};
+ var view = function(component) {
+ component.props = props;
+ return component.view();
+ };
+ view.$original = this.prototype.view;
+ var output = {
+ props: props,
+ component: this,
+ controller: this.bind(undefined, props),
+ view: view
+ };
+ if (props.key) {
+ output.attrs = {key: props.key};
+ }
+ return output;
+ }
+}
diff --git a/framework/core/js/lib/components/action-button.js b/framework/core/js/lib/components/action-button.js
new file mode 100644
index 000000000..2e48cb83b
--- /dev/null
+++ b/framework/core/js/lib/components/action-button.js
@@ -0,0 +1,21 @@
+import Component from 'flarum/component';
+import icon from 'flarum/helpers/icon';
+
+export default class ActionButton extends Component {
+ view() {
+ var attrs = {};
+ for (var i in this.props) { attrs[i] = this.props[i]; }
+
+ var iconName = attrs.icon;
+ delete attrs.icon;
+
+ var label = attrs.label;
+ delete attrs.label;
+
+ attrs.href = attrs.href || 'javascript:;';
+ return m('a', attrs, [
+ iconName ? icon(iconName+' icon-glyph') : '',
+ m('span.label', label)
+ ]);
+ }
+}
diff --git a/framework/core/js/lib/components/alert.js b/framework/core/js/lib/components/alert.js
new file mode 100644
index 000000000..9445ece86
--- /dev/null
+++ b/framework/core/js/lib/components/alert.js
@@ -0,0 +1,33 @@
+import Component from 'flarum/component';
+import ActionButton from 'flarum/components/action-button';
+import listItems from 'flarum/helpers/list-items';
+
+export default class Alert extends Component {
+ view() {
+ var attrs = {};
+ for (var i in this.props) { attrs[i] = this.props[i]; }
+
+ attrs.className = (attrs.className || '') + ' alert-'+attrs.type;
+ delete attrs.type;
+
+ var message = attrs.message;
+ delete attrs.message;
+
+ var controlItems = attrs.controls.slice() || [];
+ delete attrs.controls;
+
+ if (attrs.dismissible || attrs.dismissible === undefined) {
+ controlItems.push(ActionButton.component({
+ icon: 'times',
+ className: 'btn btn-icon btn-link',
+ onclick: attrs.ondismiss.bind(this)
+ }));
+ }
+ delete attrs.dismissible;
+
+ return m('div.alert', attrs, [
+ m('span.alert-text', message),
+ m('ul.alert-controls', listItems(controlItems))
+ ]);
+ }
+}
diff --git a/framework/core/js/lib/components/alerts.js b/framework/core/js/lib/components/alerts.js
new file mode 100644
index 000000000..bf336a014
--- /dev/null
+++ b/framework/core/js/lib/components/alerts.js
@@ -0,0 +1,34 @@
+import Component from 'flarum/component';
+
+export default class Alerts extends Component {
+ constructor(props) {
+ super(props);
+
+ this.components = [];
+ }
+
+ view() {
+ return m('div.alerts', this.components.map((component) => {
+ component.props.ondismiss = this.dismiss.bind(this, component);
+ return m('div.alert-wrapper', component);
+ }));
+ }
+
+ show(component) {
+ this.components.push(component);
+ m.redraw();
+ }
+
+ dismiss(component) {
+ var index = this.components.indexOf(component);
+ if (index !== -1) {
+ this.components.splice(index, 1);
+ }
+ m.redraw();
+ }
+
+ clear() {
+ this.components = [];
+ m.redraw();
+ }
+}
diff --git a/framework/core/js/lib/components/back-button.js b/framework/core/js/lib/components/back-button.js
new file mode 100644
index 000000000..fe2e6222c
--- /dev/null
+++ b/framework/core/js/lib/components/back-button.js
@@ -0,0 +1,32 @@
+import Component from 'flarum/component';
+import icon from 'flarum/helpers/icon';
+
+/**
+ The back/pin button group in the top-left corner of Flarum's interface.
+ */
+export default class BackButton extends Component {
+ view() {
+ var history = app.history;
+ var pane = app.pane;
+
+ return m('div.back-button', {
+ className: this.props.className || '',
+ onmouseenter: pane && pane.show.bind(pane),
+ onmouseleave: pane && pane.onmouseleave.bind(pane),
+ config: this.onload.bind(this)
+ }, history.canGoBack() ? m('div.btn-group', [
+ m('button.btn.btn-default.btn-icon.back', {onclick: history.back.bind(history)}, icon('chevron-left icon-glyph')),
+ pane && pane.active ? m('button.btn.btn-default.btn-icon.pin'+(pane.active ? '.active' : ''), {onclick: pane.togglePinned.bind(pane)}, icon('thumb-tack icon-glyph')) : '',
+ ]) : (this.props.drawer ? [
+ m('button.btn.btn-default.btn-icon.drawer-toggle', {onclick: this.toggleDrawer.bind(this)}, icon('reorder icon-glyph'))
+ ] : ''));
+ }
+
+ onload(element, isInitialized, context) {
+ context.retain = true;
+ }
+
+ toggleDrawer() {
+ $('body').toggleClass('drawer-open');
+ }
+}
diff --git a/framework/core/js/lib/components/badge.js b/framework/core/js/lib/components/badge.js
new file mode 100644
index 000000000..abc0b5b10
--- /dev/null
+++ b/framework/core/js/lib/components/badge.js
@@ -0,0 +1,19 @@
+import Component from 'flarum/component';
+import icon from 'flarum/helpers/icon';
+
+export default class Badge extends Component {
+ view(ctrl) {
+ var iconName = this.props.icon;
+ var label = this.props.title = this.props.label;
+ delete this.props.icon, this.props.label;
+ this.props.config = function(element) {
+ $(element).tooltip();
+ };
+ this.props.className = 'badge '+(this.props.className || '');
+
+ return m('span', this.props, [
+ icon(iconName+' icon-glyph'),
+ m('span.label', label)
+ ]);
+ }
+}
diff --git a/framework/core/js/lib/components/dropdown-button.js b/framework/core/js/lib/components/dropdown-button.js
new file mode 100644
index 000000000..94afe36e4
--- /dev/null
+++ b/framework/core/js/lib/components/dropdown-button.js
@@ -0,0 +1,20 @@
+import Component from 'flarum/component';
+import icon from 'flarum/helpers/icon';
+import listItems from 'flarum/helpers/list-items';
+
+export default class DropdownButton extends Component {
+ view() {
+ return m('div', {className: 'dropdown btn-group '+(this.props.items ? 'item-count-'+this.props.items.length : '')+' '+(this.props.className || '')}, [
+ m('a[href=javascript:;]', {
+ className: 'dropdown-toggle '+(this.props.buttonClass || 'btn btn-default'),
+ 'data-toggle': 'dropdown',
+ onclick: this.props.buttonClick
+ }, this.props.buttonContent || [
+ icon((this.props.icon || 'ellipsis-v')+' icon-glyph'),
+ m('span.label', this.props.label || 'Controls'),
+ icon('caret-down icon-caret')
+ ]),
+ m(this.props.menuContent ? 'div' : 'ul', {className: 'dropdown-menu '+(this.props.menuClass || '')}, this.props.menuContent || listItems(this.props.items))
+ ]);
+ }
+}
diff --git a/framework/core/js/lib/components/dropdown-select.js b/framework/core/js/lib/components/dropdown-select.js
new file mode 100644
index 000000000..86e2eb61a
--- /dev/null
+++ b/framework/core/js/lib/components/dropdown-select.js
@@ -0,0 +1,18 @@
+import Component from 'flarum/component'
+import icon from 'flarum/helpers/icon'
+import listItems from 'flarum/helpers/list-items';
+
+export default class DropdownSelect extends Component {
+ view() {
+ var activeItem = this.props.items.filter((item) => item.component.active && item.component.active(item.props))[0];
+ var label = activeItem && activeItem.props.label;
+
+ return m('div', {className: 'dropdown dropdown-select btn-group item-count-'+this.props.items.length+' '+this.props.className}, [
+ m('a[href=javascript:;]', {className: 'dropdown-toggle '+(this.props.buttonClass || 'btn btn-default'), 'data-toggle': 'dropdown'}, [
+ m('span.label', label), ' ',
+ icon('sort icon-caret')
+ ]),
+ m('ul', {className: 'dropdown-menu '+this.props.menuClass}, listItems(this.props.items, true))
+ ])
+ }
+}
diff --git a/framework/core/js/lib/components/dropdown-split.js b/framework/core/js/lib/components/dropdown-split.js
new file mode 100644
index 000000000..d52b9037a
--- /dev/null
+++ b/framework/core/js/lib/components/dropdown-split.js
@@ -0,0 +1,30 @@
+import Component from 'flarum/component';
+import icon from 'flarum/helpers/icon';
+import listItems from 'flarum/helpers/list-items';
+import ActionButton from 'flarum/components/action-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 class DropdownSplit extends Component {
+ view() {
+ var firstItem = this.props.items[0];
+ var items = listItems(this.props.items);
+
+ var buttonProps = { className: this.props.buttonClass || 'btn btn-default' };
+ for (var i in firstItem.props) {
+ buttonProps[i] = firstItem.props[i];
+ }
+
+ return m('div', {className: 'dropdown dropdown-split btn-group item-count-'+(items.length)+' '+this.props.className}, [
+ ActionButton.component(buttonProps),
+ m('a[href=javascript:;]', {className: 'dropdown-toggle '+this.props.buttonClass, 'data-toggle': 'dropdown'}, [
+ icon('caret-down icon-caret'),
+ icon((this.props.icon || 'ellipsis-v')+' icon-glyph'),
+ ]),
+ m('ul', {className: 'dropdown-menu '+(this.props.menuClass || 'pull-right')}, items)
+ ])
+ }
+}
diff --git a/framework/core/js/lib/components/field-set.js b/framework/core/js/lib/components/field-set.js
new file mode 100644
index 000000000..98b01c3d4
--- /dev/null
+++ b/framework/core/js/lib/components/field-set.js
@@ -0,0 +1,11 @@
+import Component from 'flarum/component';
+import listItems from 'flarum/helpers/list-items';
+
+export default class FieldSet extends Component {
+ view() {
+ return m('fieldset', {className: this.props.className}, [
+ m('legend', this.props.label),
+ m('ul', listItems(this.props.fields))
+ ]);
+ }
+}
diff --git a/framework/core/js/lib/components/loading-indicator.js b/framework/core/js/lib/components/loading-indicator.js
new file mode 100644
index 000000000..67b02f417
--- /dev/null
+++ b/framework/core/js/lib/components/loading-indicator.js
@@ -0,0 +1,15 @@
+import Component from 'flarum/component';
+
+export default class LoadingIndicator extends Component {
+ view() {
+ var size = this.props.size || 'small';
+ delete this.props.size;
+
+ this.props.config = function(element) {
+ $.fn.spin.presets[size].zIndex = 'auto';
+ $(element).spin(size);
+ };
+
+ return m('div.loading-indicator', this.props, m.trust(' '));
+ }
+}
diff --git a/framework/core/js/lib/components/modal.js b/framework/core/js/lib/components/modal.js
new file mode 100644
index 000000000..27e818e94
--- /dev/null
+++ b/framework/core/js/lib/components/modal.js
@@ -0,0 +1,35 @@
+import Component from 'flarum/component';
+
+export default class Modal extends Component {
+ view() {
+ return m('div.modal.fade', {config: this.onload.bind(this)}, this.component && this.component.view())
+ }
+
+ onload(element, isInitialized) {
+ if (isInitialized) { return; }
+
+ this.element(element);
+
+ this.$()
+ .on('hidden.bs.modal', this.destroy.bind(this))
+ .on('shown.bs.modal', this.ready.bind(this));
+ }
+
+ show(component) {
+ this.component = component;
+ m.redraw(true);
+ this.$().modal('show');
+ }
+
+ close() {
+ this.$().modal('hide');
+ }
+
+ destroy() {
+ this.component = null;
+ }
+
+ ready() {
+ this.component && this.component.ready && this.component.ready(this.$());
+ }
+}
diff --git a/framework/core/js/lib/components/nav-item.js b/framework/core/js/lib/components/nav-item.js
new file mode 100644
index 000000000..711f9f6a9
--- /dev/null
+++ b/framework/core/js/lib/components/nav-item.js
@@ -0,0 +1,17 @@
+import Component from 'flarum/component'
+import icon from 'flarum/helpers/icon'
+
+export default class NavItem extends Component {
+ view() {
+ var active = NavItem.active(this.props);
+ return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route}, [
+ icon(this.props.icon),
+ this.props.label, ' ',
+ m('span.count', this.props.badge)
+ ]))
+ }
+
+ static active(props) {
+ return typeof props.active !== 'undefined' ? props.active : m.route() === props.href;
+ }
+}
diff --git a/framework/core/js/lib/components/select-input.js b/framework/core/js/lib/components/select-input.js
new file mode 100644
index 000000000..aa4081ed8
--- /dev/null
+++ b/framework/core/js/lib/components/select-input.js
@@ -0,0 +1,13 @@
+import Component from 'flarum/component'
+import icon from 'flarum/helpers/icon';
+
+export default class SelectInput extends Component {
+ view(ctrl) {
+ return m('span.select-input', [
+ m('select.form-control', {onchange: m.withAttr('value', this.props.onchange.bind(ctrl)), value: this.props.value}, [
+ this.props.options.map(function(option) { return m('option', {value: option.key}, option.value) })
+ ]),
+ icon('sort')
+ ])
+ }
+}
diff --git a/framework/core/js/lib/components/separator.js b/framework/core/js/lib/components/separator.js
new file mode 100644
index 000000000..4a2ce0a61
--- /dev/null
+++ b/framework/core/js/lib/components/separator.js
@@ -0,0 +1,14 @@
+import Component from 'flarum/component';
+
+/**
+
+ */
+class Separator extends Component {
+ view() {
+ return m('span');
+ }
+}
+
+Separator.wrapperClass = 'divider';
+
+export default Separator;
diff --git a/framework/core/js/lib/components/switch-input.js b/framework/core/js/lib/components/switch-input.js
new file mode 100644
index 000000000..7c2460bc3
--- /dev/null
+++ b/framework/core/js/lib/components/switch-input.js
@@ -0,0 +1,30 @@
+import Component from 'flarum/component';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+
+export default class SwitchInput extends Component {
+ constructor(props) {
+ super(props);
+
+ this.loading = m.prop(false);
+ }
+
+ view() {
+ return m('div.checkbox.checkbox-switch', [
+ m('label', [
+ m('div.switch-control', [
+ m('input[type=checkbox]', {
+ checked: this.props.state,
+ onchange: m.withAttr('checked', this.onchange.bind(this))
+ }),
+ m('div.switch', {className: this.loading() && 'loading'})
+ ]),
+ this.props.label, ' ',
+ this.loading() ? LoadingIndicator.component({size: 'tiny'}) : ''
+ ])
+ ])
+ }
+
+ onchange(checked) {
+ this.props.onchange && this.props.onchange(checked, this);
+ }
+}
diff --git a/framework/core/js/lib/components/text-editor.js b/framework/core/js/lib/components/text-editor.js
new file mode 100644
index 000000000..5f4e5ba80
--- /dev/null
+++ b/framework/core/js/lib/components/text-editor.js
@@ -0,0 +1,65 @@
+import Component from 'flarum/component';
+import ItemList from 'flarum/utils/item-list';
+import listItems from 'flarum/helpers/list-items';
+import ActionButton from 'flarum/components/action-button';
+
+/**
+ A text editor. Contains a textarea and an item list of `controls`, including
+ a submit button.
+ */
+export default class TextEditor extends Component {
+ constructor(props) {
+ props.submitLabel = props.submitLabel || 'Submit';
+
+ super(props);
+
+ this.value = m.prop(this.props.value || '');
+ }
+
+ view() {
+ return m('div.text-editor', {config: this.element}, [
+ m('textarea.form-control.flexible-height', {
+ config: this.configTextarea.bind(this),
+ onkeyup: m.withAttr('value', this.onkeyup.bind(this)),
+ placeholder: this.props.placeholder || '',
+ disabled: !!this.props.disabled,
+ value: this.props.value || ''
+ }),
+ m('ul.text-editor-controls.fade', listItems(this.controlItems().toArray()))
+ ]);
+ }
+
+ configTextarea(element, isInitialized) {
+ if (isInitialized) { return; }
+
+ $(element).bind('keydown', 'meta+return', this.onsubmit.bind(this));
+ }
+
+ controlItems() {
+ var items = new ItemList();
+
+ items.add('submit',
+ ActionButton.component({
+ label: this.props.submitLabel,
+ icon: 'check',
+ className: 'btn btn-primary',
+ wrapperClass: 'primary-control',
+ onclick: this.onsubmit.bind(this)
+ })
+ );
+
+ return items;
+ }
+
+ onkeyup(value) {
+ this.value(value);
+ this.props.onchange(this.value());
+ this.$('.text-editor-controls').toggleClass('in', !!value);
+
+ m.redraw.strategy('none');
+ }
+
+ onsubmit() {
+ this.props.onsubmit(this.value());
+ }
+}
diff --git a/framework/core/js/lib/components/yesno-input.js b/framework/core/js/lib/components/yesno-input.js
new file mode 100644
index 000000000..2594fbf9c
--- /dev/null
+++ b/framework/core/js/lib/components/yesno-input.js
@@ -0,0 +1,35 @@
+import Component from 'flarum/component';
+import LoadingIndicator from 'flarum/components/loading-indicator';
+import classList from 'flarum/utils/class-list';
+import icon from 'flarum/helpers/icon';
+
+export default class YesNoInput extends Component {
+ constructor(props) {
+ super(props);
+
+ this.loading = m.prop(false);
+ }
+
+ view() {
+ return m('label.yesno-control', [
+ m('input[type=checkbox]', {
+ checked: this.props.state,
+ disabled: this.props.disabled,
+ onchange: m.withAttr('checked', this.onchange.bind(this))
+ }),
+ m('div.yesno', {className: classList({
+ loading: this.loading(),
+ disabled: this.props.disabled,
+ state: this.props.state ? 'yes' : 'no'
+ })}, [
+ this.loading()
+ ? LoadingIndicator.component({size: 'tiny'})
+ : icon(this.props.state ? 'check' : 'times')
+ ])
+ ]);
+ }
+
+ onchange(checked) {
+ this.props.onchange && this.props.onchange(checked, this);
+ }
+}
diff --git a/framework/core/js/lib/extension-utils.js b/framework/core/js/lib/extension-utils.js
new file mode 100644
index 000000000..c54bcfc06
--- /dev/null
+++ b/framework/core/js/lib/extension-utils.js
@@ -0,0 +1,8 @@
+export function extend(object, func, extension) {
+ var oldFunc = object[func];
+ object[func] = function() {
+ var value = oldFunc.apply(this, arguments);
+ var args = [].slice.apply(arguments);
+ return extension.apply(this, [value].concat(args));
+ }
+};
diff --git a/framework/core/js/lib/helpers/avatar.js b/framework/core/js/lib/helpers/avatar.js
new file mode 100644
index 000000000..b64b34740
--- /dev/null
+++ b/framework/core/js/lib/helpers/avatar.js
@@ -0,0 +1,26 @@
+export default function avatar(user, args) {
+ args = args || {}
+ args.className = 'avatar '+(args.className || '')
+ var content = ''
+
+ var title = typeof args.title === 'undefined' || args.title
+ if (!title) { delete args.title }
+
+ if (user) {
+ var username = user.username() || '?'
+
+ if (title) { args.title = args.title || username }
+
+ var avatarUrl = user.avatarUrl()
+ if (avatarUrl) {
+ args.src = avatarUrl
+ return m('img', args)
+ }
+
+ content = username.charAt(0).toUpperCase()
+ args.style = {background: user.color()}
+ }
+
+ if (!args.title) { delete args.title }
+ return m('span', args, content)
+}
diff --git a/framework/core/js/lib/helpers/full-time.js b/framework/core/js/lib/helpers/full-time.js
new file mode 100644
index 000000000..806831c2c
--- /dev/null
+++ b/framework/core/js/lib/helpers/full-time.js
@@ -0,0 +1,7 @@
+export default function fullTime(time) {
+ var time = moment(time);
+ var datetime = time.format();
+ var full = time.format('LLLL');
+
+ return m('time', {pubdate: '', datetime}, full);
+}
diff --git a/framework/core/js/lib/helpers/human-time.js b/framework/core/js/lib/helpers/human-time.js
new file mode 100644
index 000000000..d39663acc
--- /dev/null
+++ b/framework/core/js/lib/helpers/human-time.js
@@ -0,0 +1,11 @@
+import humanTime from 'flarum/utils/human-time';
+
+export default function humanTimeHelper(time) {
+ var time = moment(time);
+ var datetime = time.format();
+ var full = time.format('LLLL');
+
+ var ago = humanTime(time);
+
+ return m('time', {pubdate: '', datetime, title: full, 'data-humantime': ''}, ago);
+}
diff --git a/framework/core/js/lib/helpers/icon.js b/framework/core/js/lib/helpers/icon.js
new file mode 100644
index 000000000..d66df971a
--- /dev/null
+++ b/framework/core/js/lib/helpers/icon.js
@@ -0,0 +1,3 @@
+export default function icon(icon) {
+ return m('i.fa.fa-fw.fa-'+icon)
+}
diff --git a/framework/core/js/lib/helpers/list-items.js b/framework/core/js/lib/helpers/list-items.js
new file mode 100644
index 000000000..ef719fb59
--- /dev/null
+++ b/framework/core/js/lib/helpers/list-items.js
@@ -0,0 +1,21 @@
+import Separator from 'flarum/components/separator';
+
+function isSeparator(item) {
+ return item && item.component === Separator;
+}
+
+export default function listItems(array, noWrap) {
+ // Remove duplicate/unnecessary separators
+ var prevItem;
+ var newArray = [];
+ array.forEach(function(item, i) {
+ if ((!prevItem || isSeparator(prevItem) || i === array.length - 1) && isSeparator(item)) {
+
+ } else {
+ prevItem = item;
+ newArray.push(item);
+ }
+ });
+
+ return newArray.map(item => [(noWrap && !isSeparator(item)) ? item : m('li', {className: (item.props && item.props.wrapperClass) || (item.component && item.component.wrapperClass) || ''}, item), ' ']);
+};
diff --git a/framework/core/js/lib/helpers/username.js b/framework/core/js/lib/helpers/username.js
new file mode 100644
index 000000000..c3e5c17d6
--- /dev/null
+++ b/framework/core/js/lib/helpers/username.js
@@ -0,0 +1,5 @@
+export default function username(user) {
+ var username = (user && user.username()) || '[deleted]';
+
+ return m('span.username', username);
+}
diff --git a/framework/core/js/lib/initializers/preload.js b/framework/core/js/lib/initializers/preload.js
new file mode 100644
index 000000000..fb3c2b0a3
--- /dev/null
+++ b/framework/core/js/lib/initializers/preload.js
@@ -0,0 +1,5 @@
+export default function(app) {
+ if (app.preload.data) {
+ app.store.pushPayload({data: app.preload.data});
+ }
+};
diff --git a/framework/core/js/lib/initializers/session.js b/framework/core/js/lib/initializers/session.js
new file mode 100644
index 000000000..bc5d74b10
--- /dev/null
+++ b/framework/core/js/lib/initializers/session.js
@@ -0,0 +1,10 @@
+import Session from 'flarum/session';
+
+export default function(app) {
+ app.session = new Session();
+
+ if (app.preload.session) {
+ app.session.token(app.preload.session.token);
+ app.session.user(app.store.getById('users', app.preload.session.userId));
+ }
+}
diff --git a/framework/core/js/lib/initializers/store.js b/framework/core/js/lib/initializers/store.js
new file mode 100644
index 000000000..ac48edf52
--- /dev/null
+++ b/framework/core/js/lib/initializers/store.js
@@ -0,0 +1,18 @@
+import Store from 'flarum/store';
+import User from 'flarum/models/user';
+import Discussion from 'flarum/models/discussion';
+import Post from 'flarum/models/post';
+import Group from 'flarum/models/group';
+import Activity from 'flarum/models/activity';
+import Notification from 'flarum/models/notification';
+
+export default function(app) {
+ app.store = new Store();
+
+ app.store.model('users', User);
+ app.store.model('discussions', Discussion);
+ app.store.model('posts', Post);
+ app.store.model('groups', Group);
+ app.store.model('activity', Activity);
+ app.store.model('notifications', Notification);
+};
diff --git a/framework/core/js/lib/initializers/timestamps.js b/framework/core/js/lib/initializers/timestamps.js
new file mode 100644
index 000000000..98e3bb1e6
--- /dev/null
+++ b/framework/core/js/lib/initializers/timestamps.js
@@ -0,0 +1,138 @@
+import humanTime from 'flarum/utils/human-time';
+
+export default function(app) {
+ // Livestamp.js / v1.1.2 / (c) 2012 Matt Bradley / MIT License
+ // @todo rewrite this to be simpler and cleaner
+ (function($, moment) {
+ var updateInterval = 1e3,
+ paused = false,
+ $livestamps = $([]),
+
+ init = function() {
+ livestampGlobal.resume();
+ },
+
+ prep = function($el, timestamp) {
+ var oldData = $el.data('livestampdata');
+ if (typeof timestamp == 'number')
+ timestamp *= 1e3;
+
+ $el.removeAttr('data-humantime')
+ .removeData('humantime');
+
+ timestamp = moment(timestamp);
+ if (moment().diff(timestamp) > 60 * 60) {
+ return;
+ }
+ if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
+ var newData = $.extend({ }, { 'original': $el.contents() }, oldData);
+ newData.moment = moment(timestamp);
+
+ $el.data('livestampdata', newData).empty();
+ $livestamps.push($el[0]);
+ }
+ },
+
+ run = function() {
+ if (paused) return;
+ livestampGlobal.update();
+ setTimeout(run, updateInterval);
+ },
+
+ livestampGlobal = {
+ update: function() {
+ $('[data-humantime]').each(function() {
+ var $this = $(this);
+ prep($this, $this.attr('datetime'));
+ });
+
+ var toRemove = [];
+ $livestamps.each(function() {
+ var $this = $(this),
+ data = $this.data('livestampdata');
+
+ if (data === undefined)
+ toRemove.push(this);
+ else if (moment.isMoment(data.moment)) {
+ var from = $this.html(),
+ to = humanTime(data.moment);
+ // to = data.moment.fromNow();
+
+ if (from != to) {
+ var e = $.Event('change.livestamp');
+ $this.trigger(e, [from, to]);
+ if (!e.isDefaultPrevented())
+ $this.html(to);
+ }
+ }
+ });
+
+ $livestamps = $livestamps.not(toRemove);
+ },
+
+ pause: function() {
+ paused = true;
+ },
+
+ resume: function() {
+ paused = false;
+ run();
+ },
+
+ interval: function(interval) {
+ if (interval === undefined)
+ return updateInterval;
+ updateInterval = interval;
+ }
+ },
+
+ livestampLocal = {
+ add: function($el, timestamp) {
+ if (typeof timestamp == 'number')
+ timestamp *= 1e3;
+ timestamp = moment(timestamp);
+
+ if (moment.isMoment(timestamp) && !isNaN(+timestamp)) {
+ $el.each(function() {
+ prep($(this), timestamp);
+ });
+ livestampGlobal.update();
+ }
+
+ return $el;
+ },
+
+ destroy: function($el) {
+ $livestamps = $livestamps.not($el);
+ $el.each(function() {
+ var $this = $(this),
+ data = $this.data('livestampdata');
+
+ if (data === undefined)
+ return $el;
+
+ $this
+ .html(data.original ? data.original : '')
+ .removeData('livestampdata');
+ });
+
+ return $el;
+ },
+
+ isLivestamp: function($el) {
+ return $el.data('livestampdata') !== undefined;
+ }
+ };
+
+ $.livestamp = livestampGlobal;
+ $(init);
+ $.fn.livestamp = function(method, options) {
+ if (!livestampLocal[method]) {
+ options = method;
+ method = 'add';
+ }
+
+ return livestampLocal[method](this, options);
+ };
+ })(jQuery, moment);
+}
diff --git a/framework/core/js/lib/model.js b/framework/core/js/lib/model.js
new file mode 100644
index 000000000..9dd901af4
--- /dev/null
+++ b/framework/core/js/lib/model.js
@@ -0,0 +1,88 @@
+export default class Model {
+ constructor(data, store) {
+ this.data = m.prop(data || {});
+ this.freshness = new Date();
+ this.exists = false;
+ this.store = store;
+ }
+
+ pushData(newData) {
+ var data = this.data();
+
+ for (var i in newData) {
+ if (i === 'links') {
+ data[i] = data[i] || {};
+ for (var j in newData[i]) {
+ if (newData[i][j] instanceof Model) {
+ newData[i][j] = {linkage: {type: newData[i][j].data().type, id: newData[i][j].data().id}};
+ }
+ data[i][j] = newData[i][j];
+ }
+ } else {
+ data[i] = newData[i];
+ }
+ }
+
+ this.freshness = new Date();
+ }
+
+ save(data) {
+ if (data.links) {
+ for (var i in data.links) {
+ var model = data.links[i];
+ data.links[i] = {linkage: {type: model.data().type, id: model.data().id}};
+ }
+ }
+
+ this.pushData(data);
+
+ return m.request({
+ method: this.exists ? 'PUT' : 'POST',
+ url: app.config.apiURL+'/'+this.data().type+(this.exists ? '/'+this.data().id : ''),
+ data: {data},
+ background: true,
+ config: app.session.authorize.bind(app.session)
+ }).then(payload => {
+ this.store.data[payload.data.type][payload.data.id] = this;
+ return this.store.pushPayload(payload);
+ });
+ }
+
+ delete() {
+ if (!this.exists) { return; }
+
+ return m.request({
+ method: 'DELETE',
+ url: app.config.apiURL+'/'+this.data().type+'/'+this.data().id,
+ background: true,
+ config: app.session.authorize.bind(app.session)
+ }).then(() => this.exists = false);
+ }
+
+ static prop(name, transform) {
+ return function() {
+ var data = this.data()[name];
+ return transform ? transform(data) : data
+ }
+ }
+
+ static one(name) {
+ return function() {
+ var link = this.data().links[name];
+ return link && app.store.getById(link.linkage.type, link.linkage.id)
+ }
+ }
+
+ static many(name) {
+ return function() {
+ var link = this.data().links[name];
+ return link && link.linkage.map(function(link) {
+ return app.store.getById(link.type, link.id)
+ })
+ }
+ }
+
+ static date(data) {
+ return data ? new Date(data) : null;
+ }
+}
diff --git a/framework/core/js/lib/models/activity.js b/framework/core/js/lib/models/activity.js
new file mode 100644
index 000000000..0d620b3bb
--- /dev/null
+++ b/framework/core/js/lib/models/activity.js
@@ -0,0 +1,14 @@
+import Model from 'flarum/model';
+
+class Activity extends Model {}
+
+Activity.prototype.id = Model.prop('id');
+Activity.prototype.contentType = Model.prop('contentType');
+Activity.prototype.content = Model.prop('content');
+Activity.prototype.time = Model.prop('time', Model.date);
+
+Activity.prototype.user = Model.one('user');
+Activity.prototype.sender = Model.one('sender');
+Activity.prototype.post = Model.one('post');
+
+export default Activity;
diff --git a/framework/core/js/lib/models/discussion.js b/framework/core/js/lib/models/discussion.js
new file mode 100644
index 000000000..5aae82450
--- /dev/null
+++ b/framework/core/js/lib/models/discussion.js
@@ -0,0 +1,45 @@
+import Model from 'flarum/model';
+import computed from 'flarum/utils/computed';
+import ItemList from 'flarum/utils/item-list';
+
+class Discussion extends Model {}
+
+Discussion.prototype.id = Model.prop('id');
+Discussion.prototype.title = Model.prop('title');
+Discussion.prototype.slug = computed('title', title => title.toLowerCase().replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').replace(/-$|^-/g, ''));
+
+Discussion.prototype.startTime = Model.prop('startTime', Model.date);
+Discussion.prototype.startUser = Model.one('startUser');
+Discussion.prototype.startPost = Model.one('startPost');
+
+Discussion.prototype.lastTime = Model.prop('lastTime', Model.date);
+Discussion.prototype.lastUser = Model.one('lastUser');
+Discussion.prototype.lastPost = Model.one('lastPost');
+Discussion.prototype.lastPostNumber = Model.prop('lastPostNumber');
+
+Discussion.prototype.canReply = Model.prop('canReply');
+Discussion.prototype.canEdit = Model.prop('canEdit');
+Discussion.prototype.canDelete = Model.prop('canDelete');
+
+Discussion.prototype.commentsCount = Model.prop('commentsCount');
+Discussion.prototype.repliesCount = computed('commentsCount', commentsCount => commentsCount - 1);
+
+Discussion.prototype.posts = Model.many('posts');
+Discussion.prototype.relevantPosts = Model.many('relevantPosts');
+Discussion.prototype.addedPosts = Model.many('addedPosts');
+
+Discussion.prototype.readTime = Model.prop('readTime', Model.date);
+Discussion.prototype.readNumber = Model.prop('readNumber');
+
+Discussion.prototype.unreadCount = function() {
+ var user = app.session.user();
+ if (user && user.readTime() < this.lastTime()) {
+ return Math.max(0, this.lastPostNumber() - (this.readNumber() || 0))
+ }
+ return 0
+};
+Discussion.prototype.isUnread = computed('unreadCount', unreadCount => !!unreadCount);
+
+Discussion.prototype.badges = () => new ItemList();
+
+export default Discussion;
diff --git a/framework/core/js/lib/models/group.js b/framework/core/js/lib/models/group.js
new file mode 100644
index 000000000..8b8623d61
--- /dev/null
+++ b/framework/core/js/lib/models/group.js
@@ -0,0 +1,8 @@
+import Model from 'flarum/model';
+
+class Group extends Model {}
+
+Group.prototype.id = Model.prop('id');
+Group.prototype.name = Model.prop('name');
+
+export default Group;
diff --git a/framework/core/js/lib/models/notification.js b/framework/core/js/lib/models/notification.js
new file mode 100644
index 000000000..b6e4120c9
--- /dev/null
+++ b/framework/core/js/lib/models/notification.js
@@ -0,0 +1,19 @@
+import Model from 'flarum/model';
+import computed from 'flarum/utils/computed';
+
+class Notification extends Model {}
+
+Notification.prototype.id = Model.prop('id');
+Notification.prototype.contentType = Model.prop('contentType');
+Notification.prototype.subjectId = Model.prop('subjectId');
+Notification.prototype.content = Model.prop('content');
+Notification.prototype.time = Model.prop('time', Model.date);
+Notification.prototype.isRead = Model.prop('isRead');
+Notification.prototype.unreadCount = Model.prop('unreadCount');
+Notification.prototype.additionalUnreadCount = computed('unreadCount', unreadCount => Math.max(0, unreadCount - 1));
+
+Notification.prototype.user = Model.one('user');
+Notification.prototype.sender = Model.one('sender');
+Notification.prototype.subject = Model.one('subject');
+
+export default Notification;
diff --git a/framework/core/js/lib/models/post.js b/framework/core/js/lib/models/post.js
new file mode 100644
index 000000000..5cb27fad8
--- /dev/null
+++ b/framework/core/js/lib/models/post.js
@@ -0,0 +1,27 @@
+import Model from 'flarum/model';
+import computed from 'flarum/utils/computed';
+
+class Post extends Model {}
+
+Post.prototype.id = Model.prop('id');
+Post.prototype.number = Model.prop('number');
+Post.prototype.discussion = Model.one('discussion');
+
+Post.prototype.time = Model.prop('time');
+Post.prototype.user = Model.one('user');
+Post.prototype.contentType = Model.prop('contentType');
+Post.prototype.content = Model.prop('content');
+Post.prototype.contentHtml = Model.prop('contentHtml');
+
+Post.prototype.editTime = Model.prop('editTime', Model.date);
+Post.prototype.editUser = Model.one('editUser');
+Post.prototype.isEdited = computed('editTime', editTime => !!editTime);
+
+Post.prototype.hideTime = Model.prop('hideTime', Model.date);
+Post.prototype.hideUser = Model.one('hideUser');
+Post.prototype.isHidden = computed('hideTime', hideTime => !!hideTime);
+
+Post.prototype.canEdit = Model.prop('canEdit');
+Post.prototype.canDelete = Model.prop('canDelete');
+
+export default Post;
diff --git a/framework/core/js/lib/models/user.js b/framework/core/js/lib/models/user.js
new file mode 100644
index 000000000..c083065db
--- /dev/null
+++ b/framework/core/js/lib/models/user.js
@@ -0,0 +1,53 @@
+import Model from 'flarum/model'
+import stringToColor from 'flarum/utils/string-to-color';
+import ItemList from 'flarum/utils/item-list';
+import computed from 'flarum/utils/computed';
+
+class User extends Model {}
+
+User.prototype.id = Model.prop('id');
+User.prototype.username = Model.prop('username');
+User.prototype.email = Model.prop('email');
+User.prototype.isConfirmed = Model.prop('isConfirmed');
+User.prototype.password = Model.prop('password');
+User.prototype.avatarUrl = Model.prop('avatarUrl');
+User.prototype.bio = Model.prop('bio');
+User.prototype.bioHtml = Model.prop('bioHtml');
+User.prototype.preferences = Model.prop('preferences');
+
+User.prototype.groups = Model.many('groups');
+
+User.prototype.joinTime = Model.prop('joinTime', Model.date);
+User.prototype.lastSeenTime = Model.prop('lastSeenTime', Model.date);
+User.prototype.online = function() { return this.lastSeenTime() > moment().subtract(5, 'minutes').toDate(); };
+User.prototype.readTime = Model.prop('readTime', Model.date);
+User.prototype.unreadNotificationsCount = Model.prop('unreadNotificationsCount');
+
+User.prototype.discussionsCount = Model.prop('discussionsCount');
+User.prototype.commentsCount = Model.prop('commentsCount');
+;
+User.prototype.canEdit = Model.prop('canEdit');
+User.prototype.canDelete = Model.prop('canDelete');
+
+User.prototype.color = computed('username', 'avatarUrl', 'avatarColor', function(username, avatarUrl, avatarColor) {
+ if (avatarColor) {
+ return 'rgb('+avatarColor[0]+', '+avatarColor[1]+', '+avatarColor[2]+')';
+ } else if (avatarUrl) {
+ var image = new Image();
+ var user = this;
+ image.onload = function() {
+ var colorThief = new ColorThief();
+ user.avatarColor = colorThief.getColor(this);
+ user.freshness = new Date();
+ m.redraw();
+ };
+ image.src = avatarUrl;
+ return '';
+ } else {
+ return '#'+stringToColor(username);
+ }
+});
+
+User.prototype.badges = () => new ItemList();
+
+export default User;
diff --git a/framework/core/js/lib/session.js b/framework/core/js/lib/session.js
new file mode 100644
index 000000000..d7c94b610
--- /dev/null
+++ b/framework/core/js/lib/session.js
@@ -0,0 +1,41 @@
+import mixin from 'flarum/utils/mixin';
+import evented from 'flarum/utils/evented';
+
+export default class Session extends mixin(class {}, evented) {
+ constructor() {
+ super();
+ this.user = m.prop();
+ this.token = m.prop();
+ }
+
+ login(identification, password) {
+ var deferred = m.deferred();
+ var self = this;
+ m.request({
+ method: 'POST',
+ url: app.config.baseURL+'/login',
+ data: {identification, password},
+ background: true
+ }).then(function(response) {
+ self.token(response.token);
+ m.startComputation();
+ app.store.find('users', response.userId).then(function(user) {
+ self.user(user);
+ deferred.resolve(user);
+ self.trigger('loggedIn', user);
+ m.endComputation();
+ });
+ }, function(response) {
+ deferred.reject(response);
+ });
+ return deferred.promise;
+ }
+
+ logout() {
+ window.location = app.config.baseURL+'/logout';
+ }
+
+ authorize(xhr) {
+ xhr.setRequestHeader('Authorization', 'Token '+this.token());
+ }
+}
diff --git a/framework/core/js/lib/store.js b/framework/core/js/lib/store.js
new file mode 100644
index 000000000..e2e10404b
--- /dev/null
+++ b/framework/core/js/lib/store.js
@@ -0,0 +1,67 @@
+export default class Store {
+ constructor() {
+ this.data = {}
+ this.models = {}
+ }
+
+ pushPayload(payload) {
+ payload.included && payload.included.map(this.pushObject.bind(this))
+ var result = payload.data instanceof Array ? payload.data.map(this.pushObject.bind(this)) : this.pushObject(payload.data);
+ result.meta = payload.meta;
+ result.payload = payload;
+ return result;
+ }
+
+ pushObject(data) {
+ if (!this.models[data.type]) { return; }
+ var type = this.data[data.type] = this.data[data.type] || {};
+
+ if (type[data.id]) {
+ type[data.id].pushData(data);
+ } else {
+ type[data.id] = this.createRecord(data.type, data);
+ }
+ type[data.id].exists = true;
+ return type[data.id];
+ }
+
+ find(type, id, query) {
+ var endpoint = type
+ var params = {}
+ if (id instanceof Array) {
+ endpoint += '?ids[]='+id.join('&ids[]=');
+ params = query
+ } else if (typeof id === 'object') {
+ params = id
+ } else if (id) {
+ endpoint += '/'+id
+ params = query
+ }
+ return m.request({
+ method: 'GET',
+ url: app.config.apiURL+'/'+endpoint,
+ data: params,
+ background: true,
+ config: app.session.authorize.bind(app.session)
+ }).then(this.pushPayload.bind(this));
+ }
+
+ getById(type, id) {
+ return this.data[type] && this.data[type][id];
+ }
+
+ all(type) {
+ return this.data[type] || {};
+ }
+
+ model(type, Model) {
+ this.models[type] = Model;
+ }
+
+ createRecord(type, data) {
+ data = data || {};
+ data.type = data.type || type;
+
+ return new (this.models[type])(data, this);
+ }
+}
diff --git a/framework/core/js/lib/utils/abbreviate-number.js b/framework/core/js/lib/utils/abbreviate-number.js
new file mode 100644
index 000000000..c7aa47963
--- /dev/null
+++ b/framework/core/js/lib/utils/abbreviate-number.js
@@ -0,0 +1,3 @@
+export default function(number) {
+ return ''+number; // todo
+}
diff --git a/framework/core/js/lib/utils/app.js b/framework/core/js/lib/utils/app.js
new file mode 100644
index 000000000..46200f2ca
--- /dev/null
+++ b/framework/core/js/lib/utils/app.js
@@ -0,0 +1,21 @@
+import ItemList from 'flarum/utils/item-list';
+
+class App {
+ constructor() {
+ this.initializers = new ItemList();
+ this.cache = {};
+ }
+
+ boot() {
+ this.initializers.toArray().forEach((initializer) => initializer(this));
+ }
+
+ route(name, args, queryParams) {
+ var queryString = m.route.buildQueryString(queryParams);
+ return this.routes[name][0].replace(/:([^\/]+)/g, function(m, t) {
+ return typeof args[t] === 'function' ? args[t]() : args[t];
+ }) + (queryString ? '?'+queryString : '');
+ }
+}
+
+export default App;
diff --git a/framework/core/js/lib/utils/class-list.js b/framework/core/js/lib/utils/class-list.js
new file mode 100644
index 000000000..26b6315f3
--- /dev/null
+++ b/framework/core/js/lib/utils/class-list.js
@@ -0,0 +1,12 @@
+export default function classList(classes) {
+ var classNames = [];
+ for (var i in classes) {
+ var value = classes[i];
+ if (value === true) {
+ classNames.push(i);
+ } else if (value) {
+ classNames.push(value);
+ }
+ }
+ return classNames.join(' ');
+}
diff --git a/framework/core/js/lib/utils/computed.js b/framework/core/js/lib/utils/computed.js
new file mode 100644
index 000000000..42bbcff9b
--- /dev/null
+++ b/framework/core/js/lib/utils/computed.js
@@ -0,0 +1,22 @@
+export default function computed() {
+ var args = [].slice.apply(arguments);
+ var keys = args.slice(0, -1);
+ var compute = args.slice(-1)[0];
+
+ var values = {};
+ var computed;
+ return function() {
+ var recompute = false;
+ keys.forEach(function(key) {
+ var value = typeof this[key] === 'function' ? this[key]() : this[key];
+ if (values[key] !== value) {
+ recompute = true;
+ values[key] = value;
+ }
+ }.bind(this));
+ if (recompute) {
+ computed = compute.apply(this, keys.map((key) => values[key]));
+ }
+ return computed;
+ }
+};
diff --git a/framework/core/js/lib/utils/evented.js b/framework/core/js/lib/utils/evented.js
new file mode 100644
index 000000000..572900ef6
--- /dev/null
+++ b/framework/core/js/lib/utils/evented.js
@@ -0,0 +1,36 @@
+export default {
+ handlers: null,
+
+ /**
+
+ */
+ getHandlers(event) {
+ this.handlers = this.handlers || {};
+ return this.handlers[event] = this.handlers[event] || [];
+ },
+
+ /**
+
+ */
+ trigger(event, ...args) {
+ this.getHandlers(event).forEach((handler) => handler.apply(this, args));
+ },
+
+ /**
+
+ */
+ on(event, handler) {
+ this.getHandlers(event).push(handler);
+ },
+
+ /**
+
+ */
+ off(event, handler) {
+ var handlers = this.getHandlers(event);
+ var index = handlers.indexOf(handler);
+ if (index !== -1) {
+ handlers.splice(index, 1);
+ }
+ }
+}
diff --git a/framework/core/ember/common/app/utils/human-time.js b/framework/core/js/lib/utils/human-time.js
similarity index 78%
rename from framework/core/ember/common/app/utils/human-time.js
rename to framework/core/js/lib/utils/human-time.js
index 4bff800a2..3b991fb32 100644
--- a/framework/core/ember/common/app/utils/human-time.js
+++ b/framework/core/js/lib/utils/human-time.js
@@ -1,5 +1,3 @@
-import Ember from 'ember';
-
moment.locale('en', {
relativeTime : {
future: "in %s",
@@ -18,7 +16,7 @@ moment.locale('en', {
}
});
-export default function(time) {
+export default function humanTime(time) {
var m = moment(time);
var minute = 6e4;
@@ -26,10 +24,10 @@ export default function(time) {
var day = 864e5;
var ago = null;
- var diff = m.diff(moment(new Date));
+ var diff = m.diff(moment());
if (diff < -30 * day) {
- if (m.year() === moment(new Date).year()) {
+ if (m.year() === moment().year()) {
ago = m.format('D MMM');
} else {
ago = m.format('MMM \'YY');
diff --git a/framework/core/js/lib/utils/item-list.js b/framework/core/js/lib/utils/item-list.js
new file mode 100644
index 000000000..ad5186f88
--- /dev/null
+++ b/framework/core/js/lib/utils/item-list.js
@@ -0,0 +1,55 @@
+export class Item {
+ constructor(content, position) {
+ this.content = content;
+ this.position = position;
+ }
+}
+
+export default class ItemList {
+ add(key, content, position) {
+ this[key] = new Item(content, position);
+ }
+
+ toArray() {
+ var items = [];
+ for (var i in this) {
+ if (this.hasOwnProperty(i) && this[i] instanceof Item) {
+ items.push(this[i]);
+ }
+ }
+
+ var array = [];
+
+ var addItems = function(method, position) {
+ items = items.filter(function(item) {
+ if ((position && item.position && item.position[position]) || (!position && !item.position)) {
+ array[method](item);
+ } else {
+ return true;
+ }
+ });
+ };
+ addItems('unshift', 'first');
+ addItems('push', false);
+ addItems('push', 'last');
+
+ items = items.filter(function(item) {
+ var key = item.position.before || item.position.after;
+ var type = item.position.before ? 'before' : 'after';
+ if (key) {
+ var index = array.indexOf(this[key]);
+ if (index === -1) {
+ console.log("Can't find item with key '"+key+"' to insert "+type+", inserting at end instead");
+ return true;
+ } else {
+ array.splice(array.indexOf(this[key]) + (type === 'after' ? 1 : 0), 0, item);
+ }
+ }
+ }.bind(this));
+
+ array = array.concat(items);
+
+ return array.map((item) => item.content);
+ }
+}
+
diff --git a/framework/core/js/lib/utils/map-routes.js b/framework/core/js/lib/utils/map-routes.js
new file mode 100644
index 000000000..4c66f3652
--- /dev/null
+++ b/framework/core/js/lib/utils/map-routes.js
@@ -0,0 +1,7 @@
+export default function mapRoutes(routes) {
+ var map = {};
+ for (var r in routes) {
+ map[routes[r][0]] = routes[r][1];
+ }
+ return map;
+}
diff --git a/framework/core/js/lib/utils/mixin.js b/framework/core/js/lib/utils/mixin.js
new file mode 100644
index 000000000..3a6a344f2
--- /dev/null
+++ b/framework/core/js/lib/utils/mixin.js
@@ -0,0 +1,11 @@
+export default function mixin(Parent, ...mixins) {
+ class Mixed extends Parent {}
+ for (var i in mixins) {
+ var keys = Object.keys(mixins[i]);
+ for (var j in keys) {
+ var prop = keys[j];
+ Mixed.prototype[prop] = mixins[i][prop];
+ }
+ }
+ return Mixed;
+}
diff --git a/framework/core/js/lib/utils/scroll-listener.js b/framework/core/js/lib/utils/scroll-listener.js
new file mode 100644
index 000000000..007a444a9
--- /dev/null
+++ b/framework/core/js/lib/utils/scroll-listener.js
@@ -0,0 +1,43 @@
+var scroll = window.requestAnimationFrame ||
+ window.webkitRequestAnimationFrame ||
+ window.mozRequestAnimationFrame ||
+ window.msRequestAnimationFrame ||
+ window.oRequestAnimationFrame ||
+ function(callback) { window.setTimeout(callback, 1000/60) };
+
+export default class ScrollListener {
+ constructor(callback) {
+ this.callback = callback;
+ this.lastTop = -1;
+ }
+
+ loop() {
+ if (!this.active) {
+ return;
+ }
+
+ this.update();
+
+ scroll(this.loop.bind(this));
+ }
+
+ update(force) {
+ var top = window.pageYOffset;
+
+ if (this.lastTop !== top || force) {
+ this.callback(top);
+ this.lastTop = top;
+ }
+ }
+
+ stop() {
+ this.active = false;
+ }
+
+ start() {
+ if (!this.active) {
+ this.active = true;
+ this.loop();
+ }
+ }
+}
diff --git a/framework/core/ember/common/app/utils/string-to-color.js b/framework/core/js/lib/utils/string-to-color.js
similarity index 93%
rename from framework/core/ember/common/app/utils/string-to-color.js
rename to framework/core/js/lib/utils/string-to-color.js
index 81741b0fb..691b8099f 100644
--- a/framework/core/ember/common/app/utils/string-to-color.js
+++ b/framework/core/js/lib/utils/string-to-color.js
@@ -1,5 +1,3 @@
-import Ember from 'ember';
-
function hsvToRgb(h, s, v) {
var r, g, b, i, f, p, q, t;
if (h && s === undefined && v === undefined) {
@@ -25,7 +23,7 @@ function hsvToRgb(h, s, v) {
};
}
-export default function(string) {
+export default function stringToColor(string) {
var num = 0;
for (var i = 0; i < string.length; i++) {
num += string.charCodeAt(i);
diff --git a/framework/core/js/lib/utils/subtree-retainer.js b/framework/core/js/lib/utils/subtree-retainer.js
new file mode 100644
index 000000000..154a8a56b
--- /dev/null
+++ b/framework/core/js/lib/utils/subtree-retainer.js
@@ -0,0 +1,33 @@
+/**
+ // constructor
+ this.subtree = new SubtreeRetainer(
+ () => this.props.post.freshness,
+ () => this.showing
+ );
+ this.subtree.add(() => this.props.user.freshness);
+
+ // view
+ this.subtree.retain() || 'expensive expression'
+ */
+export default class SubtreeRetainer {
+ constructor() {
+ this.old = [];
+ this.callbacks = [].slice.call(arguments);
+ }
+
+ retain() {
+ var needsRebuild = false;
+ this.callbacks.forEach((callback, i) => {
+ var result = callback();
+ if (result !== this.old[i]) {
+ this.old[i] = result;
+ needsRebuild = true;
+ }
+ });
+ return needsRebuild ? false : {subtree: 'retain'};
+ }
+
+ add() {
+ this.callbacks = this.callbacks.concat([].slice.call(arguments));
+ }
+}
diff --git a/framework/core/src/Admin/Actions/IndexAction.php b/framework/core/src/Admin/Actions/IndexAction.php
index 816e9d712..76a345a23 100644
--- a/framework/core/src/Admin/Actions/IndexAction.php
+++ b/framework/core/src/Admin/Actions/IndexAction.php
@@ -13,13 +13,8 @@ class IndexAction extends Action
public function handle(Request $request, $params = [])
{
$config = [
- 'modulePrefix' => 'flarum-admin',
- 'environment' => 'production',
- 'baseURL' => '/admin',
- 'apiURL' => '/api',
- 'locationType' => 'hash',
- 'EmberENV' => [],
- 'APP' => [],
+ 'baseURL' => 'http://flarum.dev/admin',
+ 'apiURL' => 'http://flarum.dev/api',
'forumTitle' => Config::get('flarum::forum_title', 'Flarum Demo Forum')
];
$data = [];
@@ -46,7 +41,7 @@ class IndexAction extends Action
->with('styles', app('flarum.admin.assetManager')->getCSSFiles())
->with('scripts', app('flarum.admin.assetManager')->getJSFiles())
->with('config', $config)
- ->with('content', '')
+ ->with('layout', View::make('flarum.admin::admin'))
->with('data', $data)
->with('session', $session)
->with('alert', $alert);
diff --git a/framework/core/src/Admin/AdminServiceProvider.php b/framework/core/src/Admin/AdminServiceProvider.php
index a0f4b7b7e..4c5afc217 100644
--- a/framework/core/src/Admin/AdminServiceProvider.php
+++ b/framework/core/src/Admin/AdminServiceProvider.php
@@ -19,8 +19,7 @@ class AdminServiceProvider extends ServiceProvider
$assetManager = $this->app['flarum.admin.assetManager'];
$assetManager->addFile([
- $root.'/ember/admin/dist/assets/vendor.js',
- $root.'/ember/admin/dist/assets/flarum-admin.js',
+ $root.'/js/admin/dist/app.js',
$root.'/less/admin/app.less'
]);
diff --git a/framework/core/src/Forum/Actions/IndexAction.php b/framework/core/src/Forum/Actions/IndexAction.php
index 7b0a10ad5..9e28b6d06 100644
--- a/framework/core/src/Forum/Actions/IndexAction.php
+++ b/framework/core/src/Forum/Actions/IndexAction.php
@@ -12,13 +12,8 @@ class IndexAction extends BaseAction
public function handle(Request $request, $params = [])
{
$config = [
- 'modulePrefix' => 'flarum-forum',
- 'environment' => 'production',
- 'baseURL' => '/',
- 'apiURL' => '/api',
- 'locationType' => 'hash',
- 'EmberENV' => [],
- 'APP' => [],
+ 'baseURL' => 'http://flarum.dev',
+ 'apiURL' => 'http://flarum.dev/api',
'forumTitle' => Config::get('flarum::forum_title', 'Flarum Demo Forum'),
'welcomeDescription' => 'Flarum is now at a point where you can have basic conversations, so here is a little demo for you to break. Learn more »'
];
@@ -41,14 +36,12 @@ class IndexAction extends BaseAction
}
}
-
-
return View::make('flarum.forum::index')
->with('title', Config::get('flarum::forum_title', 'Flarum Demo Forum'))
->with('styles', app('flarum.forum.assetManager')->getCSSFiles())
->with('scripts', app('flarum.forum.assetManager')->getJSFiles())
->with('config', $config)
- ->with('content', '')
+ ->with('layout', View::make('flarum.forum::forum'))
->with('data', $data)
->with('session', $session)
->with('alert', $alert);
diff --git a/framework/core/src/Forum/ForumServiceProvider.php b/framework/core/src/Forum/ForumServiceProvider.php
index 0a0c7b023..feec51f97 100644
--- a/framework/core/src/Forum/ForumServiceProvider.php
+++ b/framework/core/src/Forum/ForumServiceProvider.php
@@ -19,8 +19,7 @@ class ForumServiceProvider extends ServiceProvider
$assetManager = $this->app['flarum.forum.assetManager'];
$assetManager->addFile([
- $root.'/ember/forum/dist/assets/vendor.js',
- $root.'/ember/forum/dist/assets/flarum-forum.js',
+ $root.'/js/forum/dist/app.js',
$root.'/less/forum/app.less'
]);
diff --git a/framework/core/views/admin.blade.php b/framework/core/views/admin.blade.php
new file mode 100644
index 000000000..28d39eb23
--- /dev/null
+++ b/framework/core/views/admin.blade.php
@@ -0,0 +1,17 @@
+
diff --git a/framework/core/views/forum.blade.php b/framework/core/views/forum.blade.php
new file mode 100644
index 000000000..6f6b2e289
--- /dev/null
+++ b/framework/core/views/forum.blade.php
@@ -0,0 +1,27 @@
+
diff --git a/framework/core/views/index.blade.php b/framework/core/views/index.blade.php
index d0563e510..87b911e5a 100644
--- a/framework/core/views/index.blade.php
+++ b/framework/core/views/index.blade.php
@@ -1,4 +1,4 @@
-
+
@@ -6,32 +6,29 @@
{{ $title }}
-
@foreach ($styles as $file)
-
+
@endforeach
+
- Loading...
-
+ {!! $layout !!}
- {!! $content !!}
+
+
-
@foreach ($scripts as $file)
-
+
@endforeach
+