diff --git a/ember/admin/.bowerrc b/ember/admin/.bowerrc
deleted file mode 100644
index 959e1696e..000000000
--- a/ember/admin/.bowerrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "directory": "bower_components",
-  "analytics": false
-}
diff --git a/ember/admin/.editorconfig b/ember/admin/.editorconfig
deleted file mode 100644
index 2fe4874a0..000000000
--- a/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/ember/admin/.gitignore b/ember/admin/.gitignore
deleted file mode 100644
index 86fceae7a..000000000
--- a/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/ember/admin/.jshintrc b/ember/admin/.jshintrc
deleted file mode 100644
index e75f71963..000000000
--- a/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/ember/admin/.travis.yml b/ember/admin/.travis.yml
deleted file mode 100644
index cf23938b7..000000000
--- a/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/ember/admin/Brocfile.js b/ember/admin/Brocfile.js
deleted file mode 100644
index 0e4a4a415..000000000
--- a/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/ember/admin/app/app.js b/ember/admin/app/app.js
deleted file mode 100644
index a4a0917c5..000000000
--- a/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/ember/admin/app/components/ui/admin-nav-item.js b/ember/admin/app/components/ui/admin-nav-item.js
deleted file mode 100644
index 7658dadc9..000000000
--- a/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"}} <span class="label">{{label}}</span> <div class="description">{{description}}</div>{{/link-to}}')
-});
diff --git a/ember/admin/app/components/user-dropdown.js b/ember/admin/app/components/user-dropdown.js
deleted file mode 100644
index fad9b0ed6..000000000
--- a/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/ember/admin/app/controllers/.gitkeep b/ember/admin/app/controllers/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/admin/app/controllers/application.js b/ember/admin/app/controllers/application.js
deleted file mode 100644
index 5387efb7b..000000000
--- a/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/ember/admin/app/index.html b/ember/admin/app/index.html
deleted file mode 100644
index 5bfcca9ec..000000000
--- a/ember/admin/app/index.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <title>Flarum</title>
-    <meta name="description" content="">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-
-    {{content-for 'head'}}
-
-    <link rel="stylesheet" href="assets/vendor.css">
-    <link rel="stylesheet" href="assets/flarum.css">
-
-    {{content-for 'head-footer'}}
-  </head>
-  <body>
-    {{content-for 'body'}}
-
-    <script src="assets/vendor.js"></script>
-    <script src="assets/flarum.js"></script>
-
-    {{content-for 'body-footer'}}
-  </body>
-</html>
diff --git a/ember/admin/app/router.js b/ember/admin/app/router.js
deleted file mode 100644
index b8bc6caab..000000000
--- a/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/ember/admin/app/routes/.gitkeep b/ember/admin/app/routes/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/admin/app/styles/.gitkeep b/ember/admin/app/styles/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/admin/app/styles/app.css b/ember/admin/app/styles/app.css
deleted file mode 100644
index 65fd7e93e..000000000
--- a/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/ember/admin/app/templates/.gitkeep b/ember/admin/app/templates/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/admin/app/templates/appearance.hbs b/ember/admin/app/templates/appearance.hbs
deleted file mode 100644
index 6db9a2630..000000000
--- a/ember/admin/app/templates/appearance.hbs
+++ /dev/null
@@ -1 +0,0 @@
-Appearance
diff --git a/ember/admin/app/templates/application.hbs b/ember/admin/app/templates/application.hbs
deleted file mode 100644
index ad5e01d76..000000000
--- a/ember/admin/app/templates/application.hbs
+++ /dev/null
@@ -1,43 +0,0 @@
-<div id="page" class="global-page with-pane">
-
-  {{application/back-button className="back-control" toggleDrawer="toggleDrawer" goBack="goBack" canGoBack=false}}
-
-  <div id="drawer" class="global-drawer">
-    <header id="header" class="global-header">
-      {{application/back-button goBack="goBack" canGoBack=true}}
-
-      <div class="container">
-
-        <div class="header-primary">
-          <h1 class="header-title">
-            Administration
-          </h1>
-          {{ui/item-list items=view.headerPrimary class="header-controls"}}
-        </div>
-
-        <div class="header-secondary">
-          {{ui/item-list items=view.headerSecondary class="header-controls"}}
-        </div>
-
-      </div>
-    </header>
-  </div>
-
-  <main id="content" class="global-content">
-    <div class="container">
-      <div class="side-nav admin-nav title-control">
-        {{ui/dropdown-select items=view.adminNav}}
-      </div>
-      <div class="admin-content">
-        {{outlet}}
-      </div>
-    </div>
-  </main>
-
-</div>
-
-<div id="modal" class="modal fade">
-  {{outlet "modal"}}
-</div>
-
-{{render "alerts"}}
diff --git a/ember/admin/app/templates/basics.hbs b/ember/admin/app/templates/basics.hbs
deleted file mode 100644
index 4a80312cf..000000000
--- a/ember/admin/app/templates/basics.hbs
+++ /dev/null
@@ -1 +0,0 @@
-Basics
diff --git a/ember/admin/app/templates/dashboard.hbs b/ember/admin/app/templates/dashboard.hbs
deleted file mode 100644
index dcabf8fdf..000000000
--- a/ember/admin/app/templates/dashboard.hbs
+++ /dev/null
@@ -1 +0,0 @@
-Dashboard
diff --git a/ember/admin/app/templates/extensions.hbs b/ember/admin/app/templates/extensions.hbs
deleted file mode 100644
index 26c0a2e8b..000000000
--- a/ember/admin/app/templates/extensions.hbs
+++ /dev/null
@@ -1 +0,0 @@
-Extensions
diff --git a/ember/admin/app/templates/loading.hbs b/ember/admin/app/templates/loading.hbs
deleted file mode 100644
index ce3ce25fe..000000000
--- a/ember/admin/app/templates/loading.hbs
+++ /dev/null
@@ -1 +0,0 @@
-{{ui/loading-indicator class="loading-indicator-block"}}
diff --git a/ember/admin/app/templates/permissions.hbs b/ember/admin/app/templates/permissions.hbs
deleted file mode 100644
index ca206fbdd..000000000
--- a/ember/admin/app/templates/permissions.hbs
+++ /dev/null
@@ -1 +0,0 @@
-Permissions
diff --git a/ember/admin/app/views/.gitkeep b/ember/admin/app/views/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/admin/app/views/application.js b/ember/admin/app/views/application.js
deleted file mode 100644
index a07d71a4c..000000000
--- a/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/ember/admin/bower.json b/ember/admin/bower.json
deleted file mode 100644
index da9977507..000000000
--- a/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/ember/admin/config/environment.js b/ember/admin/config/environment.js
deleted file mode 100644
index c33eccada..000000000
--- a/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/ember/admin/package.json b/ember/admin/package.json
deleted file mode 100644
index 5bab82f72..000000000
--- a/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/ember/admin/testem.json b/ember/admin/testem.json
deleted file mode 100644
index 42a4ddb22..000000000
--- a/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/ember/admin/tests/.jshintrc b/ember/admin/tests/.jshintrc
deleted file mode 100644
index 6ebf71a02..000000000
--- a/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/ember/admin/tests/helpers/resolver.js b/ember/admin/tests/helpers/resolver.js
deleted file mode 100644
index 28f4ece46..000000000
--- a/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/ember/admin/tests/helpers/start-app.js b/ember/admin/tests/helpers/start-app.js
deleted file mode 100644
index 16cc7c398..000000000
--- a/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/ember/admin/tests/index.html b/ember/admin/tests/index.html
deleted file mode 100644
index efd95f7ec..000000000
--- a/ember/admin/tests/index.html
+++ /dev/null
@@ -1,33 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <title>Flarum Tests</title>
-    <meta name="description" content="">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-
-    {{content-for 'head'}}
-    {{content-for 'test-head'}}
-
-    <link rel="stylesheet" href="assets/vendor.css">
-    <link rel="stylesheet" href="assets/flarum-admin.css">
-    <link rel="stylesheet" href="assets/test-support.css">
-
-    {{content-for 'head-footer'}}
-    {{content-for 'test-head-footer'}}
-  </head>
-  <body>
-
-    {{content-for 'body'}}
-    {{content-for 'test-body'}}
-    <script src="assets/vendor.js"></script>
-    <script src="assets/test-support.js"></script>
-    <script src="assets/flarum-admin.js"></script>
-    <script src="testem.js"></script>
-    <script src="assets/test-loader.js"></script>
-
-    {{content-for 'body-footer'}}
-    {{content-for 'test-body-footer'}}
-  </body>
-</html>
diff --git a/ember/admin/tests/integration/index-test.js b/ember/admin/tests/integration/index-test.js
deleted file mode 100644
index 859e5657d..000000000
--- a/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/ember/admin/tests/test-helper.js b/ember/admin/tests/test-helper.js
deleted file mode 100644
index e6cfb70fe..000000000
--- a/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/ember/admin/tests/unit/.gitkeep b/ember/admin/tests/unit/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/.bowerrc b/ember/common/.bowerrc
deleted file mode 100644
index 959e1696e..000000000
--- a/ember/common/.bowerrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "directory": "bower_components",
-  "analytics": false
-}
diff --git a/ember/common/.editorconfig b/ember/common/.editorconfig
deleted file mode 100644
index 2fe4874a0..000000000
--- a/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/ember/common/.gitignore b/ember/common/.gitignore
deleted file mode 100644
index 86fceae7a..000000000
--- a/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/ember/common/.jshintrc b/ember/common/.jshintrc
deleted file mode 100644
index 08096effa..000000000
--- a/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/ember/common/.npmignore b/ember/common/.npmignore
deleted file mode 100644
index 0533b9183..000000000
--- a/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/ember/common/.travis.yml b/ember/common/.travis.yml
deleted file mode 100644
index cf23938b7..000000000
--- a/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/ember/common/Brocfile.js b/ember/common/Brocfile.js
deleted file mode 100644
index 042a64dd6..000000000
--- a/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/ember/common/addon/.gitkeep b/ember/common/addon/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/app/.gitkeep b/ember/common/app/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/app/adapters/application.js b/ember/common/app/adapters/application.js
deleted file mode 100644
index 589a91e46..000000000
--- a/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/ember/common/app/authenticators/flarum.js b/ember/common/app/authenticators/flarum.js
deleted file mode 100644
index edec017f1..000000000
--- a/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/ember/common/app/authorizers/flarum.js b/ember/common/app/authorizers/flarum.js
deleted file mode 100644
index 8cd3d01ec..000000000
--- a/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/ember/common/app/components/application/back-button.js b/ember/common/app/components/application/back-button.js
deleted file mode 100755
index 5ecaa8090..000000000
--- a/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/ember/common/app/components/ui/action-button.js b/ember/common/app/components/ui/action-button.js
deleted file mode 100644
index 97be6707f..000000000
--- a/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}}<span class="label">{{label}}</span>'),
-
-  label: '',
-  icon: '',
-  className: '',
-  action: null,
-
-  click: function(e) {
-    e.preventDefault();
-    var action = this.get('action');
-    if (typeof action === 'string') {
-      this.sendAction('action');
-    } else if (typeof action === 'function') {
-      action.call(this);
-    }
-  }
-});
diff --git a/ember/common/app/components/ui/alert-message.js b/ember/common/app/components/ui/alert-message.js
deleted file mode 100755
index 40a268243..000000000
--- a/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/ember/common/app/components/ui/badge-button.js b/ember/common/app/components/ui/badge-button.js
deleted file mode 100644
index b70920cc4..000000000
--- a/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/ember/common/app/components/ui/dropdown-button.js b/ember/common/app/components/ui/dropdown-button.js
deleted file mode 100644
index 7afbb73a9..000000000
--- a/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/ember/common/app/components/ui/dropdown-select.js b/ember/common/app/components/ui/dropdown-select.js
deleted file mode 100644
index b7012e92a..000000000
--- a/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/ember/common/app/components/ui/dropdown-split.js b/ember/common/app/components/ui/dropdown-split.js
deleted file mode 100644
index 5fcc43690..000000000
--- a/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/ember/common/app/components/ui/field-set.js b/ember/common/app/components/ui/field-set.js
deleted file mode 100644
index f12ddb4f1..000000000
--- a/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/ember/common/app/components/ui/item-list.js b/ember/common/app/components/ui/item-list.js
deleted file mode 100644
index 10ecd4525..000000000
--- a/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 <ul>, making sure each one is contained
-  in an <li> element.
- */
-export default Ember.Component.extend({
-  layoutName: 'components/ui/item-list',
-  tagName: 'ul',
-
-  listItems: Ember.computed('items.[]', function() {
-    var items = this.get('items');
-    if (!Ember.isArray(items)) {
-      return [];
-    }
-    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/ember/common/app/components/ui/loading-indicator.js b/ember/common/app/components/ui/loading-indicator.js
deleted file mode 100644
index 6f54c92e4..000000000
--- a/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('&nbsp;'),
-  size: 'small',
-
-  didInsertElement: function() {
-    var size = this.get('size');
-    Ember.$.fn.spin.presets[size].zIndex = 'auto';
-    this.$().spin(size);
-  }
-});
diff --git a/ember/common/app/components/ui/nav-item.js b/ember/common/app/components/ui/nav-item.js
deleted file mode 100644
index 5504be97c..000000000
--- a/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}} <span class="count">{{badge}}</span>{{/link-to}}'),
-  tagName: 'li',
-  classNameBindings: ['active'],
-
-  icon: '',
-  label: '',
-  badge: '',
-  routeName: '',
-
-  active: Ember.computed('childViews.@each.active', function() {
-    return !!this.get('childViews').anyBy('active');
-  })
-});
diff --git a/ember/common/app/components/ui/search-input.js b/ember/common/app/components/ui/search-input.js
deleted file mode 100644
index 3b676da87..000000000
--- a/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/ember/common/app/components/ui/select-input.js b/ember/common/app/components/ui/select-input.js
deleted file mode 100644
index bb90891f9..000000000
--- a/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/ember/common/app/components/ui/separator-item.js b/ember/common/app/components/ui/separator-item.js
deleted file mode 100644
index cf927b883..000000000
--- a/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/ember/common/app/components/ui/switch-input.js b/ember/common/app/components/ui/switch-input.js
deleted file mode 100644
index 278eae4c3..000000000
--- a/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/ember/common/app/components/ui/text-editor.js b/ember/common/app/components/ui/text-editor.js
deleted file mode 100644
index e61e737f1..000000000
--- a/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/ember/common/app/components/ui/text-input.js b/ember/common/app/components/ui/text-input.js
deleted file mode 100644
index b050c71c5..000000000
--- a/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/ember/common/app/components/ui/yesno-input.js b/ember/common/app/components/ui/yesno-input.js
deleted file mode 100644
index 88e16897c..000000000
--- a/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/ember/common/app/controllers/alerts.js b/ember/common/app/controllers/alerts.js
deleted file mode 100644
index 562f165ae..000000000
--- a/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/ember/common/app/helpers/.gitkeep b/ember/common/app/helpers/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/app/helpers/abbreviate-number.js b/ember/common/app/helpers/abbreviate-number.js
deleted file mode 100644
index 09f0a1215..000000000
--- a/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/ember/common/app/helpers/fa-icon.js b/ember/common/app/helpers/fa-icon.js
deleted file mode 100644
index 3ca42f355..000000000
--- a/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('<i class="fa fa-fw fa-'+icon+' '+(options.hash.class || '')+'"></i>');
-});
-
diff --git a/ember/common/app/helpers/full-time.js b/ember/common/app/helpers/full-time.js
deleted file mode 100644
index b231e32ec..000000000
--- a/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('<time pubdate datetime="'+datetime+'">'+full+'</time>');
-});
diff --git a/ember/common/app/helpers/highlight-words.js b/ember/common/app/helpers/highlight-words.js
deleted file mode 100644
index 1942fa9fd..000000000
--- a/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 '<span class="highlight-keyword">'+matched+'</span>';
-    };
-    words.forEach(function(word) {
-      text = text.replace(
-        new RegExp("\\b"+word+"\\b", 'gi'),
-        replacement
-      );
-    });
-  }
-  return new Ember.Handlebars.SafeString(text);
-});
diff --git a/ember/common/app/helpers/human-time.js b/ember/common/app/helpers/human-time.js
deleted file mode 100644
index 919ebe257..000000000
--- a/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('<time pubdate datetime="'+datetime+'" title="'+full+'" data-humantime>'+ago+'</time>');
-});
diff --git a/ember/common/app/helpers/user-avatar.js b/ember/common/app/helpers/user-avatar.js
deleted file mode 100644
index b65af43ee..000000000
--- a/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('<img src="'+avatarUrl+'" '+attributes+'>');
-    }
-
-    content = username.charAt(0).toUpperCase();
-    attributes += ' style="background:'+user.get('color')+'"';
-  }
-
-  return new Ember.Handlebars.SafeString('<span '+attributes+'>'+content+'</span>');
-}, 'avatarUrl', 'username', 'color');
-
diff --git a/ember/common/app/helpers/user-name.js b/ember/common/app/helpers/user-name.js
deleted file mode 100644
index 7f426749e..000000000
--- a/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('<span class="username">'+Ember.Handlebars.Utils.escapeExpression(username)+'</span>');
-});
-
diff --git a/ember/common/app/initializers/authentication.js b/ember/common/app/initializers/authentication.js
deleted file mode 100644
index fc257a003..000000000
--- a/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/ember/common/app/initializers/find-query-one.js b/ember/common/app/initializers/find-query-one.js
deleted file mode 100644
index 6e9adc934..000000000
--- a/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/ember/common/app/initializers/human-time-updater.js b/ember/common/app/initializers/human-time-updater.js
deleted file mode 100644
index 3b32e0371..000000000
--- a/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/ember/common/app/initializers/inject-components.js b/ember/common/app/initializers/inject-components.js
deleted file mode 100644
index 87946d15c..000000000
--- a/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/ember/common/app/initializers/preload-data.js b/ember/common/app/initializers/preload-data.js
deleted file mode 100644
index 5c18048d6..000000000
--- a/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/ember/common/app/mixins/add-css-class-to-body.js b/ember/common/app/mixins/add-css-class-to-body.js
deleted file mode 100644
index 30d2fa4ca..000000000
--- a/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/ember/common/app/mixins/fade-in.js b/ember/common/app/mixins/fade-in.js
deleted file mode 100644
index 269103aeb..000000000
--- a/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/ember/common/app/mixins/has-item-lists.js b/ember/common/app/mixins/has-item-lists.js
deleted file mode 100644
index e45b23a2e..000000000
--- a/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/ember/common/app/mixins/modal-controller.js b/ember/common/app/mixins/modal-controller.js
deleted file mode 100644
index 7291580fe..000000000
--- a/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/ember/common/app/mixins/modal-view.js b/ember/common/app/mixins/modal-view.js
deleted file mode 100644
index 603ad58c2..000000000
--- a/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/ember/common/app/models/.gitkeep b/ember/common/app/models/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/app/models/activity.js b/ember/common/app/models/activity.js
deleted file mode 100644
index 671c85f24..000000000
--- a/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/ember/common/app/models/discussion-result.js b/ember/common/app/models/discussion-result.js
deleted file mode 100644
index 46a6711af..000000000
--- a/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/ember/common/app/models/discussion.js b/ember/common/app/models/discussion.js
deleted file mode 100644
index c1f292320..000000000
--- a/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/ember/common/app/models/group.js b/ember/common/app/models/group.js
deleted file mode 100644
index c03e313aa..000000000
--- a/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/ember/common/app/models/notification.js b/ember/common/app/models/notification.js
deleted file mode 100644
index 98f04f317..000000000
--- a/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/ember/common/app/models/post-result.js b/ember/common/app/models/post-result.js
deleted file mode 100644
index 1800ef7bf..000000000
--- a/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/ember/common/app/models/post-stream.js b/ember/common/app/models/post-stream.js
deleted file mode 100644
index 32677fca0..000000000
--- a/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/ember/common/app/models/post.js b/ember/common/app/models/post.js
deleted file mode 100644
index f91677257..000000000
--- a/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/ember/common/app/models/subject.js b/ember/common/app/models/subject.js
deleted file mode 100644
index d7b7b0561..000000000
--- a/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/ember/common/app/models/user.js b/ember/common/app/models/user.js
deleted file mode 100644
index 3885f9551..000000000
--- a/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/ember/common/app/serializers/application.js b/ember/common/app/serializers/application.js
deleted file mode 100644
index 81deb0401..000000000
--- a/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/ember/common/app/serializers/post.js b/ember/common/app/serializers/post.js
deleted file mode 100644
index 661069c42..000000000
--- a/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/ember/common/app/templates/alerts.hbs b/ember/common/app/templates/alerts.hbs
deleted file mode 100644
index 1821af7cd..000000000
--- a/ember/common/app/templates/alerts.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-<div class="alerts">
-  {{#each alert in alerts}}
-    <div class="alert-wrapper">
-      {{view alert dismiss="dismissAlert"}}
-    </div>
-  {{/each}}
-</div>
diff --git a/ember/common/app/templates/components/application/back-button.hbs b/ember/common/app/templates/components/application/back-button.hbs
deleted file mode 100644
index 63e5bf683..000000000
--- a/ember/common/app/templates/components/application/back-button.hbs
+++ /dev/null
@@ -1,10 +0,0 @@
-{{#if canGoBack}}
-  <div class="btn-group">
-    <button class="btn btn-default btn-icon back" {{action "back"}}>{{fa-icon "chevron-left" class="icon-glyph"}}</button>
-    {{#if target.paned}}
-      <button {{bind-attr class=":btn :btn-default :btn-icon :pin target.panePinned:active"}} {{action "togglePinned"}}>{{fa-icon "thumb-tack" class="icon-glyph"}}</button>
-    {{/if}}
-  </div>
-{{else if toggleDrawer}}
-  <button class="btn btn-default btn-icon drawer-toggle" {{action "toggleDrawer"}}>{{fa-icon "reorder" class="icon-glyph"}}</button>
-{{/if}}
diff --git a/ember/common/app/templates/components/application/user-dropdown.hbs b/ember/common/app/templates/components/application/user-dropdown.hbs
deleted file mode 100644
index 0f14f66a0..000000000
--- a/ember/common/app/templates/components/application/user-dropdown.hbs
+++ /dev/null
@@ -1,5 +0,0 @@
-<a href="#" {{bind-attr class=":dropdown-toggle buttonClass"}} data-toggle="dropdown" {{action "buttonClick"}}>
-  {{user-avatar user}}
-  <span class="label">{{label}}</span>
-</a>
-{{ui/item-list items=items class=dropdownMenuClass}}
diff --git a/ember/common/app/templates/components/ui/alert-message.hbs b/ember/common/app/templates/components/ui/alert-message.hbs
deleted file mode 100644
index 4ff865d54..000000000
--- a/ember/common/app/templates/components/ui/alert-message.hbs
+++ /dev/null
@@ -1,2 +0,0 @@
-<span class="alert-text">{{message}}</span>
-{{ui/item-list items=controls class="alert-controls"}}
diff --git a/ember/common/app/templates/components/ui/dropdown-button.hbs b/ember/common/app/templates/components/ui/dropdown-button.hbs
deleted file mode 100644
index 36a94dfd0..000000000
--- a/ember/common/app/templates/components/ui/dropdown-button.hbs
+++ /dev/null
@@ -1,6 +0,0 @@
-<a href="#" {{bind-attr class=":dropdown-toggle buttonClass"}} data-toggle="dropdown" {{action "buttonClick"}}>
-  {{fa-icon icon class="icon-glyph"}}
-  <span class="label">{{label}}</span>
-  {{fa-icon "caret-down" class="icon-caret"}}
-</a>
-{{ui/item-list items=items class=dropdownMenuClass}}
diff --git a/ember/common/app/templates/components/ui/dropdown-select.hbs b/ember/common/app/templates/components/ui/dropdown-select.hbs
deleted file mode 100644
index 9aec7f42c..000000000
--- a/ember/common/app/templates/components/ui/dropdown-select.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-{{#if items}}
-  <a href="#" {{bind-attr class=":dropdown-toggle buttonClass"}} data-toggle="dropdown">
-    <span class="label">{{activeItem.label}}</span>
-    {{fa-icon "sort" class="icon-caret"}}
-  </a>
-  {{ui/item-list items=items class=dropdownMenuClass viewName="menu"}}
-{{/if}}
diff --git a/ember/common/app/templates/components/ui/dropdown-split.hbs b/ember/common/app/templates/components/ui/dropdown-split.hbs
deleted file mode 100644
index ab8f82e91..000000000
--- a/ember/common/app/templates/components/ui/dropdown-split.hbs
+++ /dev/null
@@ -1,8 +0,0 @@
-{{#if items}}
-  {{view firstItem className=mainButtonClass}}
-  <button {{bind-attr class=":dropdown-toggle :btn buttonClass"}} data-toggle="dropdown">
-    {{fa-icon "caret-down" class="icon-caret"}}
-    {{fa-icon icon class="icon-glyph"}}
-  </button>
-  {{ui/item-list items=items class=dropdownMenuClass}}
-{{/if}}
diff --git a/ember/common/app/templates/components/ui/field-set.hbs b/ember/common/app/templates/components/ui/field-set.hbs
deleted file mode 100644
index d4e10daa0..000000000
--- a/ember/common/app/templates/components/ui/field-set.hbs
+++ /dev/null
@@ -1,3 +0,0 @@
-<legend>{{label}}</legend>
-
-{{ui/item-list items=fields}}
diff --git a/ember/common/app/templates/components/ui/item-list.hbs b/ember/common/app/templates/components/ui/item-list.hbs
deleted file mode 100644
index 039cbdbe5..000000000
--- a/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}}
-    <li class="{{item.listItemClass}}">{{view item}}</li>
-  {{/if}}
-{{/each}}
diff --git a/ember/common/app/templates/components/ui/search-input.hbs b/ember/common/app/templates/components/ui/search-input.hbs
deleted file mode 100644
index 7d7ce7782..000000000
--- a/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"}}
-<button class="clear btn btn-icon btn-link">{{fa-icon "times"}}</button>
diff --git a/ember/common/app/templates/components/ui/switch-input.hbs b/ember/common/app/templates/components/ui/switch-input.hbs
deleted file mode 100644
index be78ff97c..000000000
--- a/ember/common/app/templates/components/ui/switch-input.hbs
+++ /dev/null
@@ -1,12 +0,0 @@
-<label>
-  <div class="switch-control">
-    {{input type="checkbox" checked=toggleState}}
-    <div class="switch {{if loading "loading"}}">
-
-    </div>
-  </div>
-  {{label}}
-  {{#if loading}}
-    {{ui/loading-indicator size="tiny"}}
-  {{/if}}
-</label>
diff --git a/ember/common/app/templates/components/ui/text-editor.hbs b/ember/common/app/templates/components/ui/text-editor.hbs
deleted file mode 100644
index 5c2be620c..000000000
--- a/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/ember/common/app/templates/components/ui/yesno-input.hbs b/ember/common/app/templates/components/ui/yesno-input.hbs
deleted file mode 100644
index f443a0fd9..000000000
--- a/ember/common/app/templates/components/ui/yesno-input.hbs
+++ /dev/null
@@ -1,10 +0,0 @@
-{{input type="checkbox" checked=toggleState disabled=disabled}}
-<div class="yesno {{if loading "loading"}} {{if disabled "disabled"}} {{if toggleState "yes" "no"}}">
-  {{#if loading}}
-    {{ui/loading-indicator size="tiny"}}
-  {{else if toggleState}}
-    {{fa-icon "check"}}
-  {{else}}
-    {{fa-icon "times"}}
-  {{/if}}
-</div>
diff --git a/ember/common/app/utils/.gitkeep b/ember/common/app/utils/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/app/utils/tagged-array.js b/ember/common/app/utils/tagged-array.js
deleted file mode 100644
index 12aac4ed3..000000000
--- a/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/ember/common/bower.json b/ember/common/bower.json
deleted file mode 100644
index 71ce0e012..000000000
--- a/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/ember/common/config/environment.js b/ember/common/config/environment.js
deleted file mode 100644
index 0dfaed472..000000000
--- a/ember/common/config/environment.js
+++ /dev/null
@@ -1,5 +0,0 @@
-'use strict';
-
-module.exports = function(/* environment, appConfig */) {
-  return { };
-};
diff --git a/ember/common/index.js b/ember/common/index.js
deleted file mode 100644
index 66e6565e5..000000000
--- a/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/ember/common/package.json b/ember/common/package.json
deleted file mode 100644
index b8ef14247..000000000
--- a/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/ember/common/testem.json b/ember/common/testem.json
deleted file mode 100644
index 42a4ddb22..000000000
--- a/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/ember/common/tests/.jshintrc b/ember/common/tests/.jshintrc
deleted file mode 100644
index ea8b88f62..000000000
--- a/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/ember/common/tests/dummy/app/app.js b/ember/common/tests/dummy/app/app.js
deleted file mode 100644
index 757df3899..000000000
--- a/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/ember/common/tests/dummy/app/components/.gitkeep b/ember/common/tests/dummy/app/components/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/tests/dummy/app/controllers/.gitkeep b/ember/common/tests/dummy/app/controllers/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/tests/dummy/app/helpers/.gitkeep b/ember/common/tests/dummy/app/helpers/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/tests/dummy/app/index.html b/ember/common/tests/dummy/app/index.html
deleted file mode 100644
index 1c49d36d0..000000000
--- a/ember/common/tests/dummy/app/index.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <title>Dummy</title>
-    <meta name="description" content="">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-
-    {{content-for 'head'}}
-
-    <link rel="stylesheet" href="assets/vendor.css">
-    <link rel="stylesheet" href="assets/dummy.css">
-
-    {{content-for 'head-footer'}}
-  </head>
-  <body>
-    {{content-for 'body'}}
-
-    <script src="assets/vendor.js"></script>
-    <script src="assets/dummy.js"></script>
-
-    {{content-for 'body-footer'}}
-  </body>
-</html>
diff --git a/ember/common/tests/dummy/app/models/.gitkeep b/ember/common/tests/dummy/app/models/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/tests/dummy/app/router.js b/ember/common/tests/dummy/app/router.js
deleted file mode 100644
index cef554b3d..000000000
--- a/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/ember/common/tests/dummy/app/routes/.gitkeep b/ember/common/tests/dummy/app/routes/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/tests/dummy/app/styles/app.css b/ember/common/tests/dummy/app/styles/app.css
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/tests/dummy/app/templates/application.hbs b/ember/common/tests/dummy/app/templates/application.hbs
deleted file mode 100644
index 05eb936cf..000000000
--- a/ember/common/tests/dummy/app/templates/application.hbs
+++ /dev/null
@@ -1,3 +0,0 @@
-<h2 id="title">Welcome to Ember.js</h2>
-
-{{outlet}}
diff --git a/ember/common/tests/dummy/app/templates/components/.gitkeep b/ember/common/tests/dummy/app/templates/components/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/tests/dummy/app/views/.gitkeep b/ember/common/tests/dummy/app/views/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/common/tests/dummy/config/environment.js b/ember/common/tests/dummy/config/environment.js
deleted file mode 100644
index c59bcd538..000000000
--- a/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/ember/common/tests/dummy/public/crossdomain.xml b/ember/common/tests/dummy/public/crossdomain.xml
deleted file mode 100644
index 29a035d7f..000000000
--- a/ember/common/tests/dummy/public/crossdomain.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0"?>
-<!DOCTYPE cross-domain-policy SYSTEM "http://www.adobe.com/xml/dtds/cross-domain-policy.dtd">
-<cross-domain-policy>
-    <!-- Read this: www.adobe.com/devnet/articles/crossdomain_policy_file_spec.html -->
-
-    <!-- Most restrictive policy: -->
-    <site-control permitted-cross-domain-policies="none"/>
-
-    <!-- Least restrictive policy: -->
-    <!--
-    <site-control permitted-cross-domain-policies="all"/>
-    <allow-access-from domain="*" to-ports="*" secure="false"/>
-    <allow-http-request-headers-from domain="*" headers="*" secure="false"/>
-    -->
-</cross-domain-policy>
diff --git a/ember/common/tests/dummy/public/robots.txt b/ember/common/tests/dummy/public/robots.txt
deleted file mode 100644
index 5debfa4df..000000000
--- a/ember/common/tests/dummy/public/robots.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-# http://www.robotstxt.org
-User-agent: *
diff --git a/ember/common/tests/helpers/resolver.js b/ember/common/tests/helpers/resolver.js
deleted file mode 100644
index 28f4ece46..000000000
--- a/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/ember/common/tests/helpers/start-app.js b/ember/common/tests/helpers/start-app.js
deleted file mode 100644
index 16cc7c398..000000000
--- a/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/ember/common/tests/index.html b/ember/common/tests/index.html
deleted file mode 100644
index 8fea6fe70..000000000
--- a/ember/common/tests/index.html
+++ /dev/null
@@ -1,33 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <title>Dummy Tests</title>
-    <meta name="description" content="">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-
-    {{content-for 'head'}}
-    {{content-for 'test-head'}}
-
-    <link rel="stylesheet" href="assets/vendor.css">
-    <link rel="stylesheet" href="assets/dummy.css">
-    <link rel="stylesheet" href="assets/test-support.css">
-
-    {{content-for 'head-footer'}}
-    {{content-for 'test-head-footer'}}
-  </head>
-  <body>
-
-    {{content-for 'body'}}
-    {{content-for 'test-body'}}
-    <script src="assets/vendor.js"></script>
-    <script src="assets/test-support.js"></script>
-    <script src="assets/dummy.js"></script>
-    <script src="testem.js"></script>
-    <script src="assets/test-loader.js"></script>
-
-    {{content-for 'body-footer'}}
-    {{content-for 'test-body-footer'}}
-  </body>
-</html>
diff --git a/ember/common/tests/test-helper.js b/ember/common/tests/test-helper.js
deleted file mode 100644
index e6cfb70fe..000000000
--- a/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/ember/common/tests/unit/.gitkeep b/ember/common/tests/unit/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/forum/.bowerrc b/ember/forum/.bowerrc
deleted file mode 100644
index 959e1696e..000000000
--- a/ember/forum/.bowerrc
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "directory": "bower_components",
-  "analytics": false
-}
diff --git a/ember/forum/.editorconfig b/ember/forum/.editorconfig
deleted file mode 100644
index 2fe4874a0..000000000
--- a/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/ember/forum/.gitignore b/ember/forum/.gitignore
deleted file mode 100644
index 86fceae7a..000000000
--- a/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/ember/forum/.jshintrc b/ember/forum/.jshintrc
deleted file mode 100644
index e75f71963..000000000
--- a/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/ember/forum/.travis.yml b/ember/forum/.travis.yml
deleted file mode 100644
index cf23938b7..000000000
--- a/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/ember/forum/Brocfile.js b/ember/forum/Brocfile.js
deleted file mode 100644
index 0e4a4a415..000000000
--- a/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/ember/forum/app/app.js b/ember/forum/app/app.js
deleted file mode 100644
index a4a0917c5..000000000
--- a/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/ember/forum/app/components/application/forum-statistic.js b/ember/forum/app/components/application/forum-statistic.js
deleted file mode 100644
index e640ec544..000000000
--- a/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/ember/forum/app/components/application/notification-discussion-renamed.js b/ember/forum/app/components/application/notification-discussion-renamed.js
deleted file mode 100644
index bdff99203..000000000
--- a/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/ember/forum/app/components/application/notification-item.js b/ember/forum/app/components/application/notification-item.js
deleted file mode 100644
index 7c6954d56..000000000
--- a/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/ember/forum/app/components/application/notification.js b/ember/forum/app/components/application/notification.js
deleted file mode 100644
index e28a22a1c..000000000
--- a/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/ember/forum/app/components/application/powered-by.js b/ember/forum/app/components/application/powered-by.js
deleted file mode 100644
index 0acba3e34..000000000
--- a/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('<a href="http://flarum.org" target="_blank">Powered by Flarum</a>')
-});
diff --git a/ember/forum/app/components/application/user-dropdown.js b/ember/forum/app/components/application/user-dropdown.js
deleted file mode 100644
index c9379d421..000000000
--- a/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('<a href="{{baseURL}}admin" target="_blank">{{fa-icon "wrench"}} Administration</a>')
-      }));
-    }
-
-    this.addSeparatorItem(items);
-
-    this.addActionItem(items, 'logout', 'Log Out', 'sign-out', null, function() {
-      self.get('parentController').send('invalidateSession');
-    });
-  }
-})
diff --git a/ember/forum/app/components/application/user-notifications.js b/ember/forum/app/components/application/user-notifications.js
deleted file mode 100644
index e89dd3205..000000000
--- a/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/ember/forum/app/components/composer/composer-body.js b/ember/forum/app/components/composer/composer-body.js
deleted file mode 100644
index 900c97412..000000000
--- a/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/ember/forum/app/components/composer/composer-discussion.js b/ember/forum/app/components/composer/composer-discussion.js
deleted file mode 100644
index 766168d41..000000000
--- a/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/ember/forum/app/components/composer/composer-edit.js b/ember/forum/app/components/composer/composer-edit.js
deleted file mode 100644
index 4c7e902f0..000000000
--- a/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 <em>{{discussion.title}}</em>'),
-      discussion: this.get('post.discussion'),
-      component: this
-    });
-    controls.pushObjectWithTag(title, 'title');
-  }
-});
diff --git a/ember/forum/app/components/composer/composer-reply.js b/ember/forum/app/components/composer/composer-reply.js
deleted file mode 100644
index 646568468..000000000
--- a/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 <em>{{component.discussion.title}}</em>'),
-      component: this
-    });
-    items.pushObjectWithTag(title, 'title');
-  }
-});
diff --git a/ember/forum/app/components/discussion/post-comment.js b/ember/forum/app/components/discussion/post-comment.js
deleted file mode 100644
index ae1b94c2d..000000000
--- a/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/ember/forum/app/components/discussion/post-discussion-renamed.js b/ember/forum/app/components/discussion/post-discussion-renamed.js
deleted file mode 100644
index 5eaa7c171..000000000
--- a/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/ember/forum/app/components/discussion/post-header/edited.js b/ember/forum/app/components/discussion/post-header/edited.js
deleted file mode 100644
index 5cbbe3eb5..000000000
--- a/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/ember/forum/app/components/discussion/post-header/meta.js b/ember/forum/app/components/discussion/post-header/meta.js
deleted file mode 100644
index bb2515ac8..000000000
--- a/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/ember/forum/app/components/discussion/post-header/toggle.js b/ember/forum/app/components/discussion/post-header/toggle.js
deleted file mode 100644
index 714bdcd3c..000000000
--- a/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('<a href="#" class="btn btn-default btn-more" {{action "toggle"}}>{{fa-icon "ellipsis-h"}}</a>'),
-
-  hidden: Ember.computed.not('post.isHidden'),
-
-  actions: {
-    toggle: function() {
-      this.toggleProperty('parent.revealContent');
-    }
-  }
-});
diff --git a/ember/forum/app/components/discussion/post-header/user.js b/ember/forum/app/components/discussion/post-header/user.js
deleted file mode 100644
index 168bc5e27..000000000
--- a/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}}<h3>{{#link-to "user" post.user}}{{user-avatar post.user}} {{user-name post.user}}{{/link-to}} {{ui/item-list items=post.user.badges class="badges"}}</h3>{{#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}}<h3>{{user-avatar post.user}} {{user-name post.user}}</h3>{{/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/ember/forum/app/components/discussion/stream-content.js b/ember/forum/app/components/discussion/stream-content.js
deleted file mode 100644
index 8f12fd5d4..000000000
--- a/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/ember/forum/app/components/discussion/stream-item.js b/ember/forum/app/components/discussion/stream-item.js
deleted file mode 100644
index 7cac1419d..000000000
--- a/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('<span>');
-    if (this.get('loading')) {
-      buffer.push('&nbsp;');
-    } else {
-      buffer.push(this.get('count')+' more post'+(this.get('count') !== 1 ? 's' : ''));
-    }
-    buffer.push('</span>');
-  },
-
-  didInsertElement: function() {
-    if (!this.get('gap')) {
-      return;
-    }
-
-    if (this.get('loading')) {
-      var view = this;
-      Ember.run.scheduleOnce('afterRender', function() {
-        view.$().spin('small');
-      });
-    } else {
-      var self = this;
-      this.$().hover(function(e) {
-        if (! self.get('loading')) {
-          var up = e.clientY > $(this).offset().top - $(document).scrollTop() + $(this).outerHeight(true) / 2;
-          self.set('direction', up ? 'up' : 'down');
-        }
-      });
-    }
-  },
-
-  load: function(relativeIndex) {
-    // If this item is not a gap, or if we're already loading its posts,
-    // then we don't need to do anything.
-    if (! this.get('gap') || this.get('loading')) {
-      return false;
-    }
-
-    // If new posts are being loaded in an upwards direction, then when
-    // they are rendered, the rest of the posts will be pushed down the
-    // page. If loaded in a downwards direction from the end of a
-    // discussion, the terminal gap will disappear and the page will
-    // scroll up a bit before the new posts are rendered. In order to
-    // maintain the current scroll position relative to the content
-    // before/after the gap, we need to find item directly after the gap
-    // and use it as an anchor.
-    var siblingFunc = this.get('direction') === 'up' ? 'nextAll' : 'prevAll';
-    var anchor = this.$()[siblingFunc]('.item:first');
-
-    // Immediately after the posts have been loaded (but before they
-    // have been rendered,) we want to grab the distance from the top of
-    // the viewport to the top of the anchor element.
-    this.get('stream').one('postsLoaded', function() {
-      if (anchor.length) {
-        var scrollOffset = anchor.offset().top - $(document).scrollTop();
-      }
-
-      // After they have been rendered, we scroll back to a position
-      // so that the distance from the top of the viewport to the top
-      // of the anchor element is the same as before. If there is no
-      // anchor (i.e. this gap is terminal,) then we'll scroll to the
-      // bottom of the document.
-      Ember.run.scheduleOnce('afterRender', function() {
-        $('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
-      });
-    });
-
-    // Tell the controller that we want to load the range of posts that this
-    // gap represents. We also specify which direction we want to load the
-    // posts from.
-    this.sendAction(
-      'loadRange',
-      this.get('start') + (relativeIndex || 0),
-      this.get('end'),
-      this.get('direction') === 'up'
-    );
-  },
-
-  click: function() {
-    this.load();
-  }
-});
diff --git a/ember/forum/app/components/index/discussion-listing.js b/ember/forum/app/components/index/discussion-listing.js
deleted file mode 100755
index 1c31d3903..000000000
--- a/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/ember/forum/app/components/index/welcome-hero.js b/ember/forum/app/components/index/welcome-hero.js
deleted file mode 100644
index f1e3c8ab8..000000000
--- a/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/ember/forum/app/components/user/activity-item.js b/ember/forum/app/components/user/activity-item.js
deleted file mode 100644
index c7f01060d..000000000
--- a/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/ember/forum/app/components/user/activity-post.js b/ember/forum/app/components/user/activity-post.js
deleted file mode 100644
index bbd26d1e4..000000000
--- a/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/ember/forum/app/components/user/avatar-editor.js b/ember/forum/app/components/user/avatar-editor.js
deleted file mode 100644
index 8c7526d68..000000000
--- a/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 = $('<input type="file">');
-      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/ember/forum/app/components/user/notification-grid.js b/ember/forum/app/components/user/notification-grid.js
deleted file mode 100644
index cdc981cd3..000000000
--- a/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/ember/forum/app/components/user/user-bio.js b/ember/forum/app/components/user/user-bio.js
deleted file mode 100644
index f18558877..000000000
--- a/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/ember/forum/app/components/user/user-card.js b/ember/forum/app/components/user/user-card.js
deleted file mode 100644
index a2cf972b9..000000000
--- a/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) {
-      $('<img>').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/ember/forum/app/controllers/.gitkeep b/ember/forum/app/controllers/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/forum/app/controllers/application.js b/ember/forum/app/controllers/application.js
deleted file mode 100644
index ec27c72de..000000000
--- a/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/ember/forum/app/controllers/composer.js b/ember/forum/app/controllers/composer.js
deleted file mode 100644
index 1556b27f9..000000000
--- a/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/ember/forum/app/controllers/discussion.js b/ember/forum/app/controllers/discussion.js
deleted file mode 100644
index df6d0c9d5..000000000
--- a/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/ember/forum/app/controllers/index.js b/ember/forum/app/controllers/index.js
deleted file mode 100644
index d2b9aefbd..000000000
--- a/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/ember/forum/app/controllers/index/index.js b/ember/forum/app/controllers/index/index.js
deleted file mode 100644
index 897e87b72..000000000
--- a/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/ember/forum/app/controllers/login.js b/ember/forum/app/controllers/login.js
deleted file mode 100644
index d0b4183fc..000000000
--- a/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/ember/forum/app/controllers/signup.js b/ember/forum/app/controllers/signup.js
deleted file mode 100644
index 3f3763409..000000000
--- a/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/ember/forum/app/controllers/user.js b/ember/forum/app/controllers/user.js
deleted file mode 100644
index b461f2cd3..000000000
--- a/ember/forum/app/controllers/user.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import Ember from 'ember';
-
-export default Ember.Controller.extend({
-
-});
diff --git a/ember/forum/app/controllers/user/activity.js b/ember/forum/app/controllers/user/activity.js
deleted file mode 100644
index 6efe75ad0..000000000
--- a/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/ember/forum/app/index.html b/ember/forum/app/index.html
deleted file mode 100644
index 5bfcca9ec..000000000
--- a/ember/forum/app/index.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <title>Flarum</title>
-    <meta name="description" content="">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-
-    {{content-for 'head'}}
-
-    <link rel="stylesheet" href="assets/vendor.css">
-    <link rel="stylesheet" href="assets/flarum.css">
-
-    {{content-for 'head-footer'}}
-  </head>
-  <body>
-    {{content-for 'body'}}
-
-    <script src="assets/vendor.js"></script>
-    <script src="assets/flarum.js"></script>
-
-    {{content-for 'body-footer'}}
-  </body>
-</html>
diff --git a/ember/forum/app/initializers/inject-composer.js b/ember/forum/app/initializers/inject-composer.js
deleted file mode 100644
index 52b09bde4..000000000
--- a/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/ember/forum/app/mixins/paneable.js b/ember/forum/app/mixins/paneable.js
deleted file mode 100644
index 34b8ec7ff..000000000
--- a/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/ember/forum/app/mixins/pushes-history.js b/ember/forum/app/mixins/pushes-history.js
deleted file mode 100644
index c0ca98d31..000000000
--- a/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/ember/forum/app/mixins/use-composer.js b/ember/forum/app/mixins/use-composer.js
deleted file mode 100644
index 2af36e73d..000000000
--- a/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/ember/forum/app/router.js b/ember/forum/app/router.js
deleted file mode 100644
index e281d62bc..000000000
--- a/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/ember/forum/app/routes/.gitkeep b/ember/forum/app/routes/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/forum/app/routes/application.js b/ember/forum/app/routes/application.js
deleted file mode 100644
index d6b207da3..000000000
--- a/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/ember/forum/app/routes/discussion.js b/ember/forum/app/routes/discussion.js
deleted file mode 100644
index e1b57306e..000000000
--- a/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/ember/forum/app/routes/index.js b/ember/forum/app/routes/index.js
deleted file mode 100644
index 2aedef2f3..000000000
--- a/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/ember/forum/app/routes/index/index.js b/ember/forum/app/routes/index/index.js
deleted file mode 100644
index 97c9cf675..000000000
--- a/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/ember/forum/app/routes/user.js b/ember/forum/app/routes/user.js
deleted file mode 100644
index 4fa670d8a..000000000
--- a/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/ember/forum/app/routes/user/activity.js b/ember/forum/app/routes/user/activity.js
deleted file mode 100644
index bec0726c5..000000000
--- a/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/ember/forum/app/routes/user/settings.js b/ember/forum/app/routes/user/settings.js
deleted file mode 100644
index 8b44a23e0..000000000
--- a/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/ember/forum/app/styles/app.css b/ember/forum/app/styles/app.css
deleted file mode 100644
index 65fd7e93e..000000000
--- a/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/ember/forum/app/templates/.gitkeep b/ember/forum/app/templates/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/forum/app/templates/application.hbs b/ember/forum/app/templates/application.hbs
deleted file mode 100644
index 219c056a6..000000000
--- a/ember/forum/app/templates/application.hbs
+++ /dev/null
@@ -1,55 +0,0 @@
-<div id="page" {{bind-attr class=":global-page backButtonTarget.paneIsPinned:with-pane"}}>
-
-  {{application/back-button target=backButtonTarget className="back-control" toggleDrawer="toggleDrawer" goBack="goBack" canGoBack=canGoBack}}
-
-  <div id="drawer" class="global-drawer">
-    <header id="header" class="global-header">
-      {{application/back-button target=backButtonTarget goBack="goBack" canGoBack=canGoBack}}
-
-      <div class="container">
-
-        <div class="header-primary">
-          <h1 class="header-title">
-            {{#link-to "index" (query-params searchQuery="" sort="recent" show="discussions")}}
-              {{#if view.image}}
-                <img {{bind-attr src=view.image alt=view.title}}>
-              {{else}}
-                {{view.title}}
-              {{/if}}
-            {{/link-to}}
-          </h1>
-          {{ui/item-list items=view.headerPrimary class="header-controls"}}
-        </div>
-
-        <div class="header-secondary">
-          {{ui/item-list items=view.headerSecondary class="header-controls"}}
-        </div>
-
-      </div>
-    </header>
-
-    <footer id="footer" class="global-footer">
-      <div class="container">
-        {{ui/item-list items=view.footerPrimary class="footer-primary"}}
-        {{ui/item-list items=view.footerSecondary class="footer-secondary"}}
-      </div>
-    </footer>
-  </div>
-
-  <main id="content" class="global-content">
-    {{outlet}}
-
-    <div class="composer-container">
-      <div class="container">
-        {{render "composer"}}
-      </div>
-    </div>
-  </main>
-
-</div>
-
-<div id="modal" class="modal fade">
-  {{outlet "modal"}}
-</div>
-
-{{render "alerts"}}
diff --git a/ember/forum/app/templates/components/.gitkeep b/ember/forum/app/templates/components/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/forum/app/templates/components/application/notification-discussion-renamed.hbs b/ember/forum/app/templates/components/application/notification-discussion-renamed.hbs
deleted file mode 100644
index 8f468b13c..000000000
--- a/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}}
-
-  <h3 class="notification-title">{{notification.content.oldTitle}}</h3>
-
-  <div class="notification-info">
-    {{fa-icon "pencil"}}
-    Renamed by {{user-name notification.sender}}
-    {{#if notification.additionalUnreadCount}}
-      and {{notification.additionalUnreadCount}} others
-    {{/if}}
-    {{human-time notification.time}}
-  </div>
-{{/link-to}}
diff --git a/ember/forum/app/templates/components/application/notification-item.hbs b/ember/forum/app/templates/components/application/notification-item.hbs
deleted file mode 100644
index 21e7113b0..000000000
--- a/ember/forum/app/templates/components/application/notification-item.hbs
+++ /dev/null
@@ -1 +0,0 @@
-{{component componentName notification=notification}}
diff --git a/ember/forum/app/templates/components/application/user-notifications.hbs b/ember/forum/app/templates/components/application/user-notifications.hbs
deleted file mode 100644
index e2956932b..000000000
--- a/ember/forum/app/templates/components/application/user-notifications.hbs
+++ /dev/null
@@ -1,26 +0,0 @@
-<a href="#" {{bind-attr class=":dropdown-toggle buttonClass"}} data-toggle="dropdown" {{action "buttonClick"}}>
-  <span class="notifications-icon">
-    {{#if unread}}
-      {{user.unreadNotificationsCount}}
-    {{else}}
-      {{fa-icon "bell" class="icon-glyph"}}
-    {{/if}}
-  </span>
-  <span class="label">Notifications</span>
-</a>
-<div class="{{dropdownMenuClass}}">
-  <div class="notifications-header">
-    {{ui/action-button class="btn btn-icon btn-link btn-sm" icon="check" title="Mark All as Read" action="markAllAsRead"}}
-    <h4>Notifications</h4>
-  </div>
-  <ul class="notifications-list">
-    {{#each notifications as |notification|}}
-      {{application/notification-item notification=notification}}
-    {{else unless notificationsLoading}}
-      <li class="no-notifications">No Notifications</li>
-    {{/each}}
-  </ul>
-  {{#if notificationsLoading}}
-    {{ui/loading-indicator}}
-  {{/if}}
-</div>
diff --git a/ember/forum/app/templates/components/composer/composer-body.hbs b/ember/forum/app/templates/components/composer/composer-body.hbs
deleted file mode 100644
index ce0705efd..000000000
--- a/ember/forum/app/templates/components/composer/composer-body.hbs
+++ /dev/null
@@ -1,11 +0,0 @@
-{{user-avatar user class="composer-avatar"}}
-
-<div class="composer-body">
-  {{ui/item-list items=controls class="composer-header"}}
-
-  <div class="composer-editor">
-    {{ui/text-editor submit="submit" value=content placeholder=placeholder submitLabel=submitLabel disabled=loading}}
-  </div>
-</div>
-
-{{ui/loading-indicator classNameBindings=":composer-loading loading:active"}}
diff --git a/ember/forum/app/templates/components/discussion/post-comment.hbs b/ember/forum/app/templates/components/discussion/post-comment.hbs
deleted file mode 100644
index 212438e80..000000000
--- a/ember/forum/app/templates/components/discussion/post-comment.hbs
+++ /dev/null
@@ -1,17 +0,0 @@
-{{partial "components/discussion/post-controls"}}
-
-<header class="post-header">
-  {{ui/item-list items=header}}
-</header>
-
-<div class="post-body">
-  {{{post.contentHtml}}}
-</div>
-
-<aside class="post-footer">
-  {{ui/item-list items=footer}}
-</aside>
-
-<aside class="post-actions">
-  {{ui/item-list items=actions}}
-</aside>
diff --git a/ember/forum/app/templates/components/discussion/post-controls.hbs b/ember/forum/app/templates/components/discussion/post-controls.hbs
deleted file mode 100644
index f4a8b920d..000000000
--- a/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/ember/forum/app/templates/components/discussion/post-discussion-renamed.hbs b/ember/forum/app/templates/components/discussion/post-discussion-renamed.hbs
deleted file mode 100644
index bb5ffaa82..000000000
--- a/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"}}
-
-<div class="post-activity-info">{{#link-to "user" post.user class="post-user"}}{{post.user.username}}{{/link-to}} changed the title from <strong class="old-title">{{oldTitle}}</strong> to <strong class="new-title">{{newTitle}}</strong>.</div>
-
-<div class="post-activity-time">{{human-time post.time}}</div>
diff --git a/ember/forum/app/templates/components/discussion/post-header/meta.hbs b/ember/forum/app/templates/components/discussion/post-header/meta.hbs
deleted file mode 100644
index f7b3438ed..000000000
--- a/ember/forum/app/templates/components/discussion/post-header/meta.hbs
+++ /dev/null
@@ -1,10 +0,0 @@
-<a href="#" class="dropdown-toggle" data-toggle="dropdown">{{human-time post.time}}</a>
-<div class="dropdown-menu post-meta">
-  <span class="number">Post #{{post.number}}</span>
-  <span class="time">{{full-time post.time}}</span>
-  {{#if touch}}
-    <a href="{{permalink}}" class="btn btn-default permalink">{{permalink}}</a>
-  {{else}}
-    <input value="{{permalink}}" class="form-control permalink">
-  {{/if}}
-</div>
diff --git a/ember/forum/app/templates/components/discussion/stream-content.hbs b/ember/forum/app/templates/components/discussion/stream-content.hbs
deleted file mode 100644
index 2a7431cfa..000000000
--- a/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/ember/forum/app/templates/components/discussion/stream-scrubber.hbs b/ember/forum/app/templates/components/discussion/stream-scrubber.hbs
deleted file mode 100644
index e7b9b7c7d..000000000
--- a/ember/forum/app/templates/components/discussion/stream-scrubber.hbs
+++ /dev/null
@@ -1,28 +0,0 @@
-<a href="#" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
-  <span class="index">{{visibleIndex}}</span> of <span class="count">{{count}}</span> posts
-  {{fa-icon "sort" class="icon-glyph"}}
-</a>
-<div class="dropdown-menu">
-  <div class="scrubber">
-    <a href="#" class="scrubber-first" {{action "first"}}>{{fa-icon "angle-double-up"}} Original Post</a>
-    <div class="scrubber-scrollbar">
-      <div class="scrubber-before"></div>
-      <div class="scrubber-slider">
-        <div class="scrubber-handle"></div>
-        {{#if loaded}}
-          <div class="scrubber-info">
-            <strong><span class="index">{{visibleIndex}}</span> of <span class="count">{{count}}</span> posts</strong>
-            <span class="description">{{description}}</span>
-          </div>
-        {{/if}}
-      </div>
-      <div class="scrubber-after"></div>
-      <ul class="scrubber-highlights">
-        {{#each index in relevantPostIndexes}}
-          <li {{bind-attr style=index}}></li>
-        {{/each}}
-      </ul>
-    </div>
-    <a href="#" class="scrubber-last" {{action "last"}}>{{fa-icon "angle-double-down"}} Now</a>
-  </div>
-</div>
diff --git a/ember/forum/app/templates/components/index/discussion-info/terminal-post.hbs b/ember/forum/app/templates/components/index/discussion-info/terminal-post.hbs
deleted file mode 100644
index 3145a0f3e..000000000
--- a/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/ember/forum/app/templates/components/index/discussion-listing.hbs b/ember/forum/app/templates/components/index/discussion-listing.hbs
deleted file mode 100644
index 5fc13f377..000000000
--- a/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"}}
-  <h3 class="title">{{highlight-words discussion.title searchQuery}}</h3>
-  {{ui/item-list items=info class="info"}}
-{{/link-to}}
-
-<span class="count" {{action "markAsRead"}} {{bind-attr title=countTitle}}>
-  {{#if displayUnread}}
-    {{abbreviate-number discussion.unreadCount}} <span class="label">unread</span>
-  {{else}}
-    {{abbreviate-number discussion.repliesCount}} <span class="label">replies</span>
-  {{/if}}
-</span>
-
-{{#if relevantPosts}}
-  <div class="relevant-posts">
-    {{#each post in relevantPosts}}
-      {{#link-to "discussion" discussion (query-params start=post.number) class="post item"}}
-        {{user-avatar post.user class="avatar-thumb"}}
-        <span class="post-body">{{highlight-words post.relevantContent searchQuery}}</span>
-      {{/link-to}}
-    {{/each}}
-  </div>
-{{/if}}
diff --git a/ember/forum/app/templates/components/index/welcome-hero.hbs b/ember/forum/app/templates/components/index/welcome-hero.hbs
deleted file mode 100644
index 459b5287c..000000000
--- a/ember/forum/app/templates/components/index/welcome-hero.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-<div class="container">
-  <button class="close btn btn-icon btn-link" {{action "close"}}>{{fa-icon "times"}}</button>
-  <div class="container-narrow">
-    <h2>Welcome to {{title}}</h2>
-    <p>{{{description}}}</p>
-  </div>
-</div>
diff --git a/ember/forum/app/templates/components/user/activity-item.hbs b/ember/forum/app/templates/components/user/activity-item.hbs
deleted file mode 100644
index 2311dacf6..000000000
--- a/ember/forum/app/templates/components/user/activity-item.hbs
+++ /dev/null
@@ -1 +0,0 @@
-{{component componentName activity=activity}}
diff --git a/ember/forum/app/templates/components/user/activity-join.hbs b/ember/forum/app/templates/components/user/activity-join.hbs
deleted file mode 100644
index 3ffafbfb8..000000000
--- a/ember/forum/app/templates/components/user/activity-join.hbs
+++ /dev/null
@@ -1,6 +0,0 @@
-{{user-avatar activity.user class="activity-icon"}}
-
-<div class="activity-info">
-  <strong>Joined the forum</strong>
-  {{human-time activity.time}}
-</div>
diff --git a/ember/forum/app/templates/components/user/activity-post.hbs b/ember/forum/app/templates/components/user/activity-post.hbs
deleted file mode 100644
index f58019629..000000000
--- a/ember/forum/app/templates/components/user/activity-post.hbs
+++ /dev/null
@@ -1,21 +0,0 @@
-{{user-avatar activity.post.user class="activity-icon"}}
-
-<div class="activity-info">
-  <strong>{{if isFirstPost "Started a discussion" "Posted a reply"}}</strong>
-  {{human-time activity.time}}
-</div>
-
-{{#if isFirstPost}}
-  <div class="activity-content activity-discussion">
-    {{index/discussion-listing discussion=activity.post.discussion}}
-  </div>
-{{else}}
-  {{#link-to "discussion" activity.post.discussion (query-params start=activity.post.number) class="activity-content activity-post"}}
-    <h3 class="title">
-      {{activity.post.discussion.title}}
-    </h3>
-    <div class="body">
-      {{{activity.post.contentHtml}}}
-    </div>
-  {{/link-to}}
-{{/if}}
diff --git a/ember/forum/app/templates/components/user/avatar-editor.hbs b/ember/forum/app/templates/components/user/avatar-editor.hbs
deleted file mode 100644
index 7f204a6cd..000000000
--- a/ember/forum/app/templates/components/user/avatar-editor.hbs
+++ /dev/null
@@ -1,12 +0,0 @@
-{{user-avatar user}}
-<a href="#" class="dropdown-toggle" data-toggle="dropdown">
-  {{#if loading}}
-    {{ui/loading-indicator}}
-  {{else}}
-    {{fa-icon "pencil"}}
-  {{/if}}
-</a>
-<ul class="dropdown-menu">
-  <li><a href="#" {{action "upload"}}>{{fa-icon "upload"}} Upload</a></li>
-  <li><a href="#" {{action "remove"}}>{{fa-icon "times"}} Remove</a></li>
-</ul>
diff --git a/ember/forum/app/templates/components/user/notification-grid.hbs b/ember/forum/app/templates/components/user/notification-grid.hbs
deleted file mode 100644
index 88d5e6319..000000000
--- a/ember/forum/app/templates/components/user/notification-grid.hbs
+++ /dev/null
@@ -1,20 +0,0 @@
-<table>
-  <thead>
-    <tr>
-      <td></td>
-      {{#each methods as |method|}}
-        <th class="toggle-group" {{action "toggleMethod" method}}>{{fa-icon method.icon}} {{method.label}}</th>
-      {{/each}}
-    </tr>
-  </thead>
-  <tbody>
-    {{#each grid as |row|}}
-      <tr>
-        <td class="toggle-group" {{action "toggleType" row.type}}>{{row.label}}</td>
-        {{#each row.cells as |cell|}}
-          <td class="yesno-cell">{{ui/yesno-input toggleState=cell.enabled changed=cell.save loading=cell.loading disabled=cell.disabled}}</td>
-        {{/each}}
-      </tr>
-    {{/each}}
-  </tbody>
-</table>
diff --git a/ember/forum/app/templates/components/user/user-bio.hbs b/ember/forum/app/templates/components/user/user-bio.hbs
deleted file mode 100644
index 6bee687ab..000000000
--- a/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}}
-  <div class="bio-content">
-    {{#if user.bioHtml}}
-      {{{user.bioHtml}}}
-    {{else if isEditable}}
-      <p>Write something about yourself...</p>
-    {{/if}}
-  </div>
-{{/if}}
diff --git a/ember/forum/app/templates/components/user/user-card.hbs b/ember/forum/app/templates/components/user/user-card.hbs
deleted file mode 100644
index 0a8e80dcf..000000000
--- a/ember/forum/app/templates/components/user/user-card.hbs
+++ /dev/null
@@ -1,20 +0,0 @@
-<div class="darken-overlay"></div>
-<div class="container">
-  {{#if controls}}
-    {{ui/dropdown-button items=controls class="contextual-controls" menuClass="pull-right" buttonClass=controlsButtonClass}}
-  {{/if}}
-
-  <div class="user-profile">
-    <h2 class="user-identity">
-      {{#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}}
-    </h2>
-
-    {{ui/item-list items=user.badges class="badges user-badges"}}
-
-    {{ui/item-list items=info class="user-info"}}
-  </div>
-</div>
diff --git a/ember/forum/app/templates/composer.hbs b/ember/forum/app/templates/composer.hbs
deleted file mode 100644
index 4c3600dad..000000000
--- a/ember/forum/app/templates/composer.hbs
+++ /dev/null
@@ -1,9 +0,0 @@
-<div class="composer-handle"></div>
-
-{{ui/item-list items=view.controls class="composer-controls"}}
-
-<div class="composer-content">
-  {{#if content}}
-    {{view content}}
-  {{/if}}
-</div>
diff --git a/ember/forum/app/templates/discussion.hbs b/ember/forum/app/templates/discussion.hbs
deleted file mode 100644
index a35a41e8b..000000000
--- a/ember/forum/app/templates/discussion.hbs
+++ /dev/null
@@ -1,19 +0,0 @@
-<header class="hero discussion-hero">
-  <div class="container">
-    {{ui/item-list items=model.badges class="badges"}}
-    <h2 class="discussion-title">{{model.title}}</h2>
-  </div>
-</header>
-
-<div class="container">
-  <nav class="discussion-nav">
-    {{ui/item-list items=view.sidebar}}
-  </nav>
-
-  {{discussion/stream-content
-    viewName="streamContent"
-    stream=stream
-    class="discussion-posts posts"
-    positionChanged="positionChanged"
-    postRemoved="postRemoved"}}
-</div>
diff --git a/ember/forum/app/templates/error.hbs b/ember/forum/app/templates/error.hbs
deleted file mode 100644
index b17a9157d..000000000
--- a/ember/forum/app/templates/error.hbs
+++ /dev/null
@@ -1,13 +0,0 @@
-<header class="hero error-hero">
-  <div class="container">
-    <h2>Error</h2>
-  </div>
-</header>
-
-<div class="error-area">
-  <div class="container">
-    <p>{{model.message}}</p>
-
-    <pre>{{model.stack}}</pre>
-  </div>
-</div>
diff --git a/ember/forum/app/templates/index.hbs b/ember/forum/app/templates/index.hbs
deleted file mode 100644
index 4a32da04b..000000000
--- a/ember/forum/app/templates/index.hbs
+++ /dev/null
@@ -1,57 +0,0 @@
-<div {{bind-attr class=":index-area paned paneIsShowing:showing"}}>
-
-  {{#if view.hero}}
-    {{view view.hero}}
-  {{/if}}
-
-  <div class="container">
-
-    <nav class="side-nav index-nav">
-      {{ui/item-list items=view.sidebar}}
-    </nav>
-
-    <div class="offset-content index-results">
-
-      <div class="index-toolbar">
-        <div class="index-toolbar-view">
-          <span class="btn-group control-show">
-            {{#link-to (query-params show="discussions") class="btn btn-default btn-icon"}}{{fa-icon "bars"}}{{/link-to}}
-            {{#link-to (query-params show="posts") class="btn btn-default btn-icon"}}{{fa-icon "square-o"}}{{/link-to}}
-          </span>
-
-          {{ui/select-input class="control-sort" content=index.sortOptions optionValuePath="content.key" optionLabelPath="content.label" value=index.sort}}
-        </div>
-        <div class="index-toolbar-action">
-          {{ui/action-button class="control-markAllAsRead btn btn-default btn-icon" icon="check" title="Mark All as Read" action="markAllAsRead"}}
-        </div>
-      </div>
-
-      <ul class="discussions-list">
-        {{#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}}
-      </ul>
-
-      {{#if index.resultsLoading}}
-        {{ui/loading-indicator size="small"}}
-      {{else if index.moreResults}}
-        <div class="load-more">
-          {{ui/action-button class="control-loadMore btn btn-default" action="loadMore" label="Load More"}}
-        </div>
-      {{/if}}
-
-    </div>
-
-  </div>
-
-</div>
-
-<div class="discussion-area">
-  {{outlet}}
-</div>
diff --git a/ember/forum/app/templates/loading.hbs b/ember/forum/app/templates/loading.hbs
deleted file mode 100644
index ce3ce25fe..000000000
--- a/ember/forum/app/templates/loading.hbs
+++ /dev/null
@@ -1 +0,0 @@
-{{ui/loading-indicator class="loading-indicator-block"}}
diff --git a/ember/forum/app/templates/login.hbs b/ember/forum/app/templates/login.hbs
deleted file mode 100644
index 704657ec4..000000000
--- a/ember/forum/app/templates/login.hbs
+++ /dev/null
@@ -1,31 +0,0 @@
-<div class="modal-content">
-  <button class="close btn btn-icon btn-link back-control" {{action "closeModal"}}>{{fa-icon "times"}}</button>
-  <form {{action "authenticate" on="submit"}}>
-    <div class="modal-header">
-      <h3 class="title-control">Log In</h3>
-    </div>
-    <div class="modal-body">
-      <div class="form-centered">
-        <div class="form-group">
-          {{#if error}}
-            <div class="form-alert">
-              <div class="alert alert-warning">{{error}}</div>
-            </div>
-          {{/if}}
-          {{input value=identification name="email" type="text" class="form-control" placeholder="Username or Email" disabled=loading}}
-        </div>
-        <div class="form-group">
-          {{input value=password name="password" type="password" class="form-control" placeholder="Password" disabled=loading}}
-        </div>
-        <div class="form-group">
-          <button type="submit" class="btn btn-primary btn-block" {{bind-attr disabled=loading}}>Log In</button>
-        </div>
-      </div>
-    </div>
-    <div class="modal-footer">
-      <p class="forgot-password-link"><a href="#">Forgot password?</a></p>
-      <p class="sign-up-link">Don't have an account? <a href="#" {{action "signup"}}>Sign Up</a></p>
-    </div>
-  </form>
-</div>
-{{ui/loading-indicator classNameBindings=":modal-loading loading:active"}}
diff --git a/ember/forum/app/templates/signup.hbs b/ember/forum/app/templates/signup.hbs
deleted file mode 100644
index 19e65780d..000000000
--- a/ember/forum/app/templates/signup.hbs
+++ /dev/null
@@ -1,40 +0,0 @@
-<div class="modal-content">
-  <button class="close btn btn-icon btn-link back-control" {{action "closeModal"}}>{{fa-icon "times"}}</button>
-  <form {{action "submit" on="submit"}}>
-    <div class="modal-header">
-      <h3 class="title-control">Sign Up</h3>
-    </div>
-    <div class="modal-body">
-      <div class="form-centered">
-        <div class="form-group">
-          {{input value=username name="username" type="text" class="form-control" placeholder="Username" disabled=loading}}
-        </div>
-        <div class="form-group">
-          {{input value=email name="email" type="text" class="form-control" placeholder="Email" disabled=loading}}
-        </div>
-        <div class="form-group">
-          {{input value=password name="password" type="password" class="form-control" placeholder="Password" disabled=loading}}
-        </div>
-        <div class="form-group">
-          <button type="submit" class="btn btn-primary btn-block" {{bind-attr disabled=loading}}>Sign Up</button>
-        </div>
-      </div>
-    </div>
-    <div class="modal-footer">
-      <p class="log-in-link">Already have an account? <a href="#" {{action "login"}}>Log In</a></p>
-    </div>
-  </form>
-</div>
-{{ui/loading-indicator classNameBindings=":modal-loading loading:active"}}
-
-{{#if welcomeUser}}
-  <div {{bind-attr class=":signup-welcome" style=welcomeStyle}}>
-    {{user-avatar welcomeUser}}
-    <h3>Welcome, {{welcomeUser.username}}!</h3>
-
-    {{#unless welcomeUser.isConfirmed}}
-      <p>We've sent a confirmation email to <strong>{{welcomeUser.email}}</strong>. If it doesn't arrive soon, check your spam folder.</p>
-      <p><a {{bind-attr href=emailProviderUrl}} class="btn btn-default">Go to {{emailProviderName}}</a></p>
-    {{/unless}}
-  </div>
-{{/if}}
diff --git a/ember/forum/app/templates/user.hbs b/ember/forum/app/templates/user.hbs
deleted file mode 100644
index dea7c6a91..000000000
--- a/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"}}
-
-<div class="container">
-  <nav class="side-nav user-nav">
-    {{ui/item-list items=view.sidebar}}
-  </nav>
-
-  <div class="offset-content user-content">
-    {{outlet}}
-  </div>
-</div>
diff --git a/ember/forum/app/templates/user/activity.hbs b/ember/forum/app/templates/user/activity.hbs
deleted file mode 100644
index cc7ccd434..000000000
--- a/ember/forum/app/templates/user/activity.hbs
+++ /dev/null
@@ -1,13 +0,0 @@
-<ul class="activity-list">
-  {{#each activity in model}}
-    {{user/activity-item activity=activity}}
-  {{/each}}
-</ul>
-
-{{#if resultsLoading}}
-  {{ui/loading-indicator size="small"}}
-{{else if moreResults}}
-  <div class="load-more">
-    {{ui/action-button class="control-loadMore btn btn-default" action="loadMore" label="Load More"}}
-  </div>
-{{/if}}
diff --git a/ember/forum/app/templates/user/settings.hbs b/ember/forum/app/templates/user/settings.hbs
deleted file mode 100644
index f4ff909f4..000000000
--- a/ember/forum/app/templates/user/settings.hbs
+++ /dev/null
@@ -1 +0,0 @@
-{{ui/item-list items=view.settings}}
diff --git a/ember/forum/app/views/.gitkeep b/ember/forum/app/views/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/ember/forum/app/views/application.js b/ember/forum/app/views/application.js
deleted file mode 100644
index 458f1435b..000000000
--- a/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/ember/forum/app/views/composer.js b/ember/forum/app/views/composer.js
deleted file mode 100644
index 649b3c75d..000000000
--- a/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/ember/forum/app/views/discussion.js b/ember/forum/app/views/discussion.js
deleted file mode 100644
index 0bde7e4f6..000000000
--- a/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/ember/forum/app/views/index.js b/ember/forum/app/views/index.js
deleted file mode 100644
index 532567e00..000000000
--- a/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}} <span class="count">{{badge}}</span>{{/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}} <span class="count">{{badge}}</span>{{/link-to}}')
-    }), 'private');
-
-    items.pushObjectWithTag(NavItem.extend({
-      label: 'Following',
-      icon: 'star',
-      layout: precompileTemplate('{{#link-to "index" (query-params filter="following")}}{{fa-icon icon}} {{label}} <span class="count">{{badge}}</span>{{/link-to}}')
-    }), 'following');
-  }
-});
diff --git a/ember/forum/app/views/index/index.js b/ember/forum/app/views/index/index.js
deleted file mode 100644
index bd0fbf393..000000000
--- a/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/ember/forum/app/views/login.js b/ember/forum/app/views/login.js
deleted file mode 100644
index 7368adc19..000000000
--- a/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/ember/forum/app/views/signup.js b/ember/forum/app/views/signup.js
deleted file mode 100644
index 3c64ec566..000000000
--- a/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/ember/forum/app/views/user.js b/ember/forum/app/views/user.js
deleted file mode 100644
index fd7d1afd5..000000000
--- a/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}} <span class="count">{{badge}}</span>{{/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}} <span class="count">{{badge}}</span>{{/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/ember/forum/app/views/user/activity.js b/ember/forum/app/views/user/activity.js
deleted file mode 100644
index 08e850b4b..000000000
--- a/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/ember/forum/app/views/user/settings.js b/ember/forum/app/views/user/settings.js
deleted file mode 100644
index 3612065f5..000000000
--- a/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/ember/forum/bower.json b/ember/forum/bower.json
deleted file mode 100644
index a7070a909..000000000
--- a/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/ember/forum/config/environment.js b/ember/forum/config/environment.js
deleted file mode 100644
index ce7dffa17..000000000
--- a/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/ember/forum/package.json b/ember/forum/package.json
deleted file mode 100644
index b0ce2f6d0..000000000
--- a/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/ember/forum/testem.json b/ember/forum/testem.json
deleted file mode 100644
index 42a4ddb22..000000000
--- a/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/ember/forum/tests/.jshintrc b/ember/forum/tests/.jshintrc
deleted file mode 100644
index 6ebf71a02..000000000
--- a/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/ember/forum/tests/helpers/resolver.js b/ember/forum/tests/helpers/resolver.js
deleted file mode 100644
index 28f4ece46..000000000
--- a/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/ember/forum/tests/helpers/start-app.js b/ember/forum/tests/helpers/start-app.js
deleted file mode 100644
index 16cc7c398..000000000
--- a/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/ember/forum/tests/index.html b/ember/forum/tests/index.html
deleted file mode 100644
index 9990b47c7..000000000
--- a/ember/forum/tests/index.html
+++ /dev/null
@@ -1,33 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <meta charset="utf-8">
-    <meta http-equiv="X-UA-Compatible" content="IE=edge">
-    <title>Flarum Tests</title>
-    <meta name="description" content="">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-
-    {{content-for 'head'}}
-    {{content-for 'test-head'}}
-
-    <link rel="stylesheet" href="assets/vendor.css">
-    <link rel="stylesheet" href="assets/flarum-forum.css">
-    <link rel="stylesheet" href="assets/test-support.css">
-
-    {{content-for 'head-footer'}}
-    {{content-for 'test-head-footer'}}
-  </head>
-  <body>
-
-    {{content-for 'body'}}
-    {{content-for 'test-body'}}
-    <script src="assets/vendor.js"></script>
-    <script src="assets/test-support.js"></script>
-    <script src="assets/flarum-forum.js"></script>
-    <script src="testem.js"></script>
-    <script src="assets/test-loader.js"></script>
-
-    {{content-for 'body-footer'}}
-    {{content-for 'test-body-footer'}}
-  </body>
-</html>
diff --git a/ember/forum/tests/integration/index-test.js b/ember/forum/tests/integration/index-test.js
deleted file mode 100644
index 859e5657d..000000000
--- a/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/ember/forum/tests/test-helper.js b/ember/forum/tests/test-helper.js
deleted file mode 100644
index e6cfb70fe..000000000
--- a/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/ember/forum/tests/unit/.gitkeep b/ember/forum/tests/unit/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/js/admin/.gitignore b/js/admin/.gitignore
new file mode 100644
index 000000000..bae304483
--- /dev/null
+++ b/js/admin/.gitignore
@@ -0,0 +1,4 @@
+bower_components
+node_modules
+mithril.js
+dist
diff --git a/js/admin/Gulpfile.js b/js/admin/Gulpfile.js
new file mode 100644
index 000000000..9f173606d
--- /dev/null
+++ b/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/js/admin/bower.json b/js/admin/bower.json
new file mode 100644
index 000000000..175d2317e
--- /dev/null
+++ b/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/js/admin/package.json b/js/admin/package.json
new file mode 100644
index 000000000..9fc67619d
--- /dev/null
+++ b/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/js/admin/src/app.js b/js/admin/src/app.js
new file mode 100644
index 000000000..fa4c7f067
--- /dev/null
+++ b/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/js/admin/src/components/admin-nav-item.js b/js/admin/src/components/admin-nav-item.js
new file mode 100644
index 000000000..2f05cab67
--- /dev/null
+++ b/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/js/admin/src/components/admin-nav.js b/js/admin/src/components/admin-nav.js
new file mode 100644
index 000000000..bd9c042ee
--- /dev/null
+++ b/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/js/admin/src/components/appearance-page.js b/js/admin/src/components/appearance-page.js
new file mode 100644
index 000000000..5d1ddd2f1
--- /dev/null
+++ b/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/js/admin/src/components/basics-page.js b/js/admin/src/components/basics-page.js
new file mode 100644
index 000000000..c5baf8a77
--- /dev/null
+++ b/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/js/admin/src/components/dashboard-page.js b/js/admin/src/components/dashboard-page.js
new file mode 100644
index 000000000..d9dc00acb
--- /dev/null
+++ b/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/js/admin/src/components/extensions-page.js b/js/admin/src/components/extensions-page.js
new file mode 100644
index 000000000..c1fe5e793
--- /dev/null
+++ b/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/js/admin/src/components/header-primary.js b/js/admin/src/components/header-primary.js
new file mode 100644
index 000000000..166a7b6cc
--- /dev/null
+++ b/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/js/admin/src/components/header-secondary.js b/js/admin/src/components/header-secondary.js
new file mode 100644
index 000000000..8c7be583d
--- /dev/null
+++ b/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/js/admin/src/components/permissions-page.js b/js/admin/src/components/permissions-page.js
new file mode 100644
index 000000000..bc4c0ba4a
--- /dev/null
+++ b/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/js/admin/src/components/user-dropdown.js b/js/admin/src/components/user-dropdown.js
new file mode 100644
index 000000000..1bbbc60f5
--- /dev/null
+++ b/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/js/admin/src/initializers/boot.js b/js/admin/src/initializers/boot.js
new file mode 100644
index 000000000..52e4a4574
--- /dev/null
+++ b/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/js/admin/src/initializers/routes.js b/js/admin/src/initializers/routes.js
new file mode 100644
index 000000000..d0519bf82
--- /dev/null
+++ b/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/js/forum/.gitignore b/js/forum/.gitignore
new file mode 100644
index 000000000..bae304483
--- /dev/null
+++ b/js/forum/.gitignore
@@ -0,0 +1,4 @@
+bower_components
+node_modules
+mithril.js
+dist
diff --git a/js/forum/Gulpfile.js b/js/forum/Gulpfile.js
new file mode 100644
index 000000000..ee258528a
--- /dev/null
+++ b/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/js/forum/bower.json b/js/forum/bower.json
new file mode 100644
index 000000000..175d2317e
--- /dev/null
+++ b/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/js/forum/package.json b/js/forum/package.json
new file mode 100644
index 000000000..9fc67619d
--- /dev/null
+++ b/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/js/forum/src/app.js b/js/forum/src/app.js
new file mode 100644
index 000000000..fedf78534
--- /dev/null
+++ b/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/js/forum/src/components/activity-join.js b/js/forum/src/components/activity-join.js
new file mode 100644
index 000000000..048c295d1
--- /dev/null
+++ b/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/js/forum/src/components/activity-page.js b/js/forum/src/components/activity-page.js
new file mode 100644
index 000000000..20cf759c4
--- /dev/null
+++ b/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/js/forum/src/components/activity-post.js b/js/forum/src/components/activity-post.js
new file mode 100644
index 000000000..868594c75
--- /dev/null
+++ b/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/js/forum/src/components/avatar-editor.js b/js/forum/src/components/avatar-editor.js
new file mode 100644
index 000000000..cd62f9b75
--- /dev/null
+++ b/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 = $('<input type="file">');
+    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/js/forum/src/components/composer-body.js b/js/forum/src/components/composer-body.js
new file mode 100644
index 000000000..41d2d8ecb
--- /dev/null
+++ b/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/js/forum/src/components/composer-discussion.js b/js/forum/src/components/composer-discussion.js
new file mode 100644
index 000000000..244f35b06
--- /dev/null
+++ b/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/js/forum/src/components/composer-edit.js b/js/forum/src/components/composer-edit.js
new file mode 100644
index 000000000..92cd29fb1
--- /dev/null
+++ b/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/js/forum/src/components/composer-reply.js b/js/forum/src/components/composer-reply.js
new file mode 100644
index 000000000..6af8478c5
--- /dev/null
+++ b/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/js/forum/src/components/composer.js b/js/forum/src/components/composer.js
new file mode 100644
index 000000000..a9f4b0101
--- /dev/null
+++ b/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/js/forum/src/components/discussion-list.js b/js/forum/src/components/discussion-list.js
new file mode 100644
index 000000000..cff6a7ead
--- /dev/null
+++ b/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/js/forum/src/components/discussion-page.js b/js/forum/src/components/discussion-page.js
new file mode 100644
index 000000000..e49c90214
--- /dev/null
+++ b/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/js/forum/src/components/footer-primary.js b/js/forum/src/components/footer-primary.js
new file mode 100644
index 000000000..f23c93e70
--- /dev/null
+++ b/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/js/forum/src/components/footer-secondary.js b/js/forum/src/components/footer-secondary.js
new file mode 100644
index 000000000..60fc4d434
--- /dev/null
+++ b/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/js/forum/src/components/header-primary.js b/js/forum/src/components/header-primary.js
new file mode 100644
index 000000000..166a7b6cc
--- /dev/null
+++ b/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/js/forum/src/components/header-secondary.js b/js/forum/src/components/header-secondary.js
new file mode 100644
index 000000000..900002e56
--- /dev/null
+++ b/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/js/forum/src/components/index-page.js b/js/forum/src/components/index-page.js
new file mode 100644
index 000000000..99d38f2f0
--- /dev/null
+++ b/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/js/forum/src/components/login-modal.js b/js/forum/src/components/login-modal.js
new file mode 100644
index 000000000..0cccca498
--- /dev/null
+++ b/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/js/forum/src/components/notification-discussion-renamed.js b/js/forum/src/components/notification-discussion-renamed.js
new file mode 100644
index 000000000..a1d19025e
--- /dev/null
+++ b/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/js/forum/src/components/notification-grid.js b/js/forum/src/components/notification-grid.js
new file mode 100644
index 000000000..ee89179be
--- /dev/null
+++ b/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/js/forum/src/components/notification.js b/js/forum/src/components/notification.js
new file mode 100644
index 000000000..3d5082ae0
--- /dev/null
+++ b/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/js/forum/src/components/post-comment.js b/js/forum/src/components/post-comment.js
new file mode 100644
index 000000000..49f58f9d5
--- /dev/null
+++ b/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/js/forum/src/components/post-discussion-renamed.js b/js/forum/src/components/post-discussion-renamed.js
new file mode 100644
index 000000000..eb7457429
--- /dev/null
+++ b/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/js/forum/src/components/post-header-edited.js b/js/forum/src/components/post-header-edited.js
new file mode 100644
index 000000000..381c0d11d
--- /dev/null
+++ b/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/js/forum/src/components/post-header-meta.js b/js/forum/src/components/post-header-meta.js
new file mode 100644
index 000000000..22dbd5a15
--- /dev/null
+++ b/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/js/forum/src/components/post-header-toggle.js b/js/forum/src/components/post-header-toggle.js
new file mode 100644
index 000000000..541c150d9
--- /dev/null
+++ b/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/js/forum/src/components/post-header-user.js b/js/forum/src/components/post-header-user.js
new file mode 100644
index 000000000..e92fca14a
--- /dev/null
+++ b/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/js/forum/src/components/settings-page.js b/js/forum/src/components/settings-page.js
new file mode 100644
index 000000000..68c75d547
--- /dev/null
+++ b/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/js/forum/src/components/signup-modal.js b/js/forum/src/components/signup-modal.js
new file mode 100644
index 000000000..e050d3a6a
--- /dev/null
+++ b/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/js/forum/src/components/stream-content.js b/js/forum/src/components/stream-content.js
new file mode 100644
index 000000000..6acd5bf10
--- /dev/null
+++ b/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/js/forum/src/components/stream-item.js b/js/forum/src/components/stream-item.js
new file mode 100644
index 000000000..c8fd194db
--- /dev/null
+++ b/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/ember/forum/app/components/discussion/stream-scrubber.js b/js/forum/src/components/stream-scrubber.js
similarity index 50%
rename from ember/forum/app/components/discussion/stream-scrubber.js
rename to js/forum/src/components/stream-scrubber.js
index e9dd1b2a7..bf18af92d 100644
--- a/ember/forum/app/components/discussion/stream-scrubber.js
+++ b/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/js/forum/src/components/terminal-post.js b/js/forum/src/components/terminal-post.js
new file mode 100644
index 000000000..7897f2e8d
--- /dev/null
+++ b/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/js/forum/src/components/user-bio.js b/js/forum/src/components/user-bio.js
new file mode 100644
index 000000000..09ea34b1e
--- /dev/null
+++ b/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/js/forum/src/components/user-card.js b/js/forum/src/components/user-card.js
new file mode 100644
index 000000000..4a25a3e19
--- /dev/null
+++ b/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/js/forum/src/components/user-dropdown.js b/js/forum/src/components/user-dropdown.js
new file mode 100644
index 000000000..c475183f8
--- /dev/null
+++ b/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/js/forum/src/components/user-notifications.js b/js/forum/src/components/user-notifications.js
new file mode 100644
index 000000000..d406ea570
--- /dev/null
+++ b/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/js/forum/src/components/user-page.js b/js/forum/src/components/user-page.js
new file mode 100644
index 000000000..919335a16
--- /dev/null
+++ b/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/js/forum/src/components/welcome-hero.js b/js/forum/src/components/welcome-hero.js
new file mode 100644
index 000000000..91c02524c
--- /dev/null
+++ b/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/js/forum/src/initializers/boot.js b/js/forum/src/initializers/boot.js
new file mode 100644
index 000000000..569ebccbc
--- /dev/null
+++ b/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/js/forum/src/initializers/components.js b/js/forum/src/initializers/components.js
new file mode 100644
index 000000000..8fa216c92
--- /dev/null
+++ b/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/js/forum/src/initializers/routes.js b/js/forum/src/initializers/routes.js
new file mode 100644
index 000000000..52a4352aa
--- /dev/null
+++ b/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/js/forum/src/utils/history.js b/js/forum/src/utils/history.js
new file mode 100644
index 000000000..d51e5e948
--- /dev/null
+++ b/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/js/forum/src/utils/pane.js b/js/forum/src/utils/pane.js
new file mode 100644
index 000000000..a1bac964f
--- /dev/null
+++ b/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/js/forum/src/utils/post-stream.js b/js/forum/src/utils/post-stream.js
new file mode 100644
index 000000000..903b3b544
--- /dev/null
+++ b/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/js/lib/component.js b/js/lib/component.js
new file mode 100644
index 000000000..1709592f5
--- /dev/null
+++ b/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/js/lib/components/action-button.js b/js/lib/components/action-button.js
new file mode 100644
index 000000000..2e48cb83b
--- /dev/null
+++ b/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/js/lib/components/alert.js b/js/lib/components/alert.js
new file mode 100644
index 000000000..9445ece86
--- /dev/null
+++ b/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/js/lib/components/alerts.js b/js/lib/components/alerts.js
new file mode 100644
index 000000000..bf336a014
--- /dev/null
+++ b/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/js/lib/components/back-button.js b/js/lib/components/back-button.js
new file mode 100644
index 000000000..fe2e6222c
--- /dev/null
+++ b/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/js/lib/components/badge.js b/js/lib/components/badge.js
new file mode 100644
index 000000000..abc0b5b10
--- /dev/null
+++ b/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/js/lib/components/dropdown-button.js b/js/lib/components/dropdown-button.js
new file mode 100644
index 000000000..94afe36e4
--- /dev/null
+++ b/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/js/lib/components/dropdown-select.js b/js/lib/components/dropdown-select.js
new file mode 100644
index 000000000..86e2eb61a
--- /dev/null
+++ b/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/js/lib/components/dropdown-split.js b/js/lib/components/dropdown-split.js
new file mode 100644
index 000000000..d52b9037a
--- /dev/null
+++ b/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/js/lib/components/field-set.js b/js/lib/components/field-set.js
new file mode 100644
index 000000000..98b01c3d4
--- /dev/null
+++ b/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/js/lib/components/loading-indicator.js b/js/lib/components/loading-indicator.js
new file mode 100644
index 000000000..67b02f417
--- /dev/null
+++ b/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('&nbsp;'));
+  }
+}
diff --git a/js/lib/components/modal.js b/js/lib/components/modal.js
new file mode 100644
index 000000000..27e818e94
--- /dev/null
+++ b/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/js/lib/components/nav-item.js b/js/lib/components/nav-item.js
new file mode 100644
index 000000000..711f9f6a9
--- /dev/null
+++ b/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/js/lib/components/select-input.js b/js/lib/components/select-input.js
new file mode 100644
index 000000000..aa4081ed8
--- /dev/null
+++ b/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/js/lib/components/separator.js b/js/lib/components/separator.js
new file mode 100644
index 000000000..4a2ce0a61
--- /dev/null
+++ b/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/js/lib/components/switch-input.js b/js/lib/components/switch-input.js
new file mode 100644
index 000000000..7c2460bc3
--- /dev/null
+++ b/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/js/lib/components/text-editor.js b/js/lib/components/text-editor.js
new file mode 100644
index 000000000..5f4e5ba80
--- /dev/null
+++ b/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/js/lib/components/yesno-input.js b/js/lib/components/yesno-input.js
new file mode 100644
index 000000000..2594fbf9c
--- /dev/null
+++ b/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/js/lib/extension-utils.js b/js/lib/extension-utils.js
new file mode 100644
index 000000000..c54bcfc06
--- /dev/null
+++ b/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/js/lib/helpers/avatar.js b/js/lib/helpers/avatar.js
new file mode 100644
index 000000000..b64b34740
--- /dev/null
+++ b/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/js/lib/helpers/full-time.js b/js/lib/helpers/full-time.js
new file mode 100644
index 000000000..806831c2c
--- /dev/null
+++ b/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/js/lib/helpers/human-time.js b/js/lib/helpers/human-time.js
new file mode 100644
index 000000000..d39663acc
--- /dev/null
+++ b/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/js/lib/helpers/icon.js b/js/lib/helpers/icon.js
new file mode 100644
index 000000000..d66df971a
--- /dev/null
+++ b/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/js/lib/helpers/list-items.js b/js/lib/helpers/list-items.js
new file mode 100644
index 000000000..ef719fb59
--- /dev/null
+++ b/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/js/lib/helpers/username.js b/js/lib/helpers/username.js
new file mode 100644
index 000000000..c3e5c17d6
--- /dev/null
+++ b/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/js/lib/initializers/preload.js b/js/lib/initializers/preload.js
new file mode 100644
index 000000000..fb3c2b0a3
--- /dev/null
+++ b/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/js/lib/initializers/session.js b/js/lib/initializers/session.js
new file mode 100644
index 000000000..bc5d74b10
--- /dev/null
+++ b/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/js/lib/initializers/store.js b/js/lib/initializers/store.js
new file mode 100644
index 000000000..ac48edf52
--- /dev/null
+++ b/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/js/lib/initializers/timestamps.js b/js/lib/initializers/timestamps.js
new file mode 100644
index 000000000..98e3bb1e6
--- /dev/null
+++ b/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/js/lib/model.js b/js/lib/model.js
new file mode 100644
index 000000000..9dd901af4
--- /dev/null
+++ b/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/js/lib/models/activity.js b/js/lib/models/activity.js
new file mode 100644
index 000000000..0d620b3bb
--- /dev/null
+++ b/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/js/lib/models/discussion.js b/js/lib/models/discussion.js
new file mode 100644
index 000000000..5aae82450
--- /dev/null
+++ b/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/js/lib/models/group.js b/js/lib/models/group.js
new file mode 100644
index 000000000..8b8623d61
--- /dev/null
+++ b/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/js/lib/models/notification.js b/js/lib/models/notification.js
new file mode 100644
index 000000000..b6e4120c9
--- /dev/null
+++ b/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/js/lib/models/post.js b/js/lib/models/post.js
new file mode 100644
index 000000000..5cb27fad8
--- /dev/null
+++ b/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/js/lib/models/user.js b/js/lib/models/user.js
new file mode 100644
index 000000000..c083065db
--- /dev/null
+++ b/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/js/lib/session.js b/js/lib/session.js
new file mode 100644
index 000000000..d7c94b610
--- /dev/null
+++ b/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/js/lib/store.js b/js/lib/store.js
new file mode 100644
index 000000000..e2e10404b
--- /dev/null
+++ b/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/js/lib/utils/abbreviate-number.js b/js/lib/utils/abbreviate-number.js
new file mode 100644
index 000000000..c7aa47963
--- /dev/null
+++ b/js/lib/utils/abbreviate-number.js
@@ -0,0 +1,3 @@
+export default function(number) {
+  return ''+number; // todo
+}
diff --git a/js/lib/utils/app.js b/js/lib/utils/app.js
new file mode 100644
index 000000000..46200f2ca
--- /dev/null
+++ b/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/js/lib/utils/class-list.js b/js/lib/utils/class-list.js
new file mode 100644
index 000000000..26b6315f3
--- /dev/null
+++ b/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/js/lib/utils/computed.js b/js/lib/utils/computed.js
new file mode 100644
index 000000000..42bbcff9b
--- /dev/null
+++ b/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/js/lib/utils/evented.js b/js/lib/utils/evented.js
new file mode 100644
index 000000000..572900ef6
--- /dev/null
+++ b/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/ember/common/app/utils/human-time.js b/js/lib/utils/human-time.js
similarity index 78%
rename from ember/common/app/utils/human-time.js
rename to js/lib/utils/human-time.js
index 4bff800a2..3b991fb32 100644
--- a/ember/common/app/utils/human-time.js
+++ b/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/js/lib/utils/item-list.js b/js/lib/utils/item-list.js
new file mode 100644
index 000000000..ad5186f88
--- /dev/null
+++ b/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/js/lib/utils/map-routes.js b/js/lib/utils/map-routes.js
new file mode 100644
index 000000000..4c66f3652
--- /dev/null
+++ b/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/js/lib/utils/mixin.js b/js/lib/utils/mixin.js
new file mode 100644
index 000000000..3a6a344f2
--- /dev/null
+++ b/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/js/lib/utils/scroll-listener.js b/js/lib/utils/scroll-listener.js
new file mode 100644
index 000000000..007a444a9
--- /dev/null
+++ b/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/ember/common/app/utils/string-to-color.js b/js/lib/utils/string-to-color.js
similarity index 93%
rename from ember/common/app/utils/string-to-color.js
rename to js/lib/utils/string-to-color.js
index 81741b0fb..691b8099f 100644
--- a/ember/common/app/utils/string-to-color.js
+++ b/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/js/lib/utils/subtree-retainer.js b/js/lib/utils/subtree-retainer.js
new file mode 100644
index 000000000..154a8a56b
--- /dev/null
+++ b/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/src/Admin/Actions/IndexAction.php b/src/Admin/Actions/IndexAction.php
index 816e9d712..76a345a23 100644
--- a/src/Admin/Actions/IndexAction.php
+++ b/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/src/Admin/AdminServiceProvider.php b/src/Admin/AdminServiceProvider.php
index a0f4b7b7e..4c5afc217 100644
--- a/src/Admin/AdminServiceProvider.php
+++ b/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/src/Forum/Actions/IndexAction.php b/src/Forum/Actions/IndexAction.php
index 7b0a10ad5..9e28b6d06 100644
--- a/src/Forum/Actions/IndexAction.php
+++ b/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. <a href="http://demo.flarum.org/#/1/welcome-to-the-first-public-demo-of-flarum">Learn more &raquo;</a>'
         ];
@@ -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/src/Forum/ForumServiceProvider.php b/src/Forum/ForumServiceProvider.php
index 0a0c7b023..feec51f97 100644
--- a/src/Forum/ForumServiceProvider.php
+++ b/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/views/admin.blade.php b/views/admin.blade.php
new file mode 100644
index 000000000..28d39eb23
--- /dev/null
+++ b/views/admin.blade.php
@@ -0,0 +1,17 @@
+<div class="global-page" id="page">
+  <div id="back-control"></div>
+  <div class="global-drawer">
+    <header class="global-header" id="header">
+      <div id="back-button"></div>
+      <div class="container">
+        <h1 class="header-title"><a href="#" onclick="app.history.home()">Flarum Demo Forum</a></h1>
+        <div id="header-primary" class="header-primary"></div>
+        <div id="header-secondary" class="header-secondary"></div>
+      </div>
+    </header>
+  </div>
+  <main class="global-content">
+    <div class="side-nav admin-nav title-control" id="admin-nav"></div>
+    <div class="admin-content" id="content"></div>
+  </main>
+</div>
diff --git a/views/forum.blade.php b/views/forum.blade.php
new file mode 100644
index 000000000..6f6b2e289
--- /dev/null
+++ b/views/forum.blade.php
@@ -0,0 +1,27 @@
+<div class="global-page" id="page">
+  <div id="back-control"></div>
+  <div class="global-drawer">
+    <header class="global-header" id="header">
+      <div id="back-button"></div>
+      <div class="container">
+        <h1 class="header-title"><a href="#" onclick="app.history.home()">Flarum Demo Forum</a></h1>
+        <div id="header-primary" class="header-primary"></div>
+        <div id="header-secondary" class="header-secondary"></div>
+      </div>
+    </header>
+    <footer class="global-footer" id="footer">
+      <div class="container">
+        <div id="footer-primary" class="footer-primary"></div>
+        <div id="footer-secondary" class="footer-secondary"></div>
+      </div>
+    </footer>
+  </div>
+  <main class="global-content">
+    <div id="content"></div>
+    <div class="composer-container">
+      <div class="container">
+        <div id="composer"></div>
+      </div>
+    </div>
+  </main>
+</div>
diff --git a/views/index.blade.php b/views/index.blade.php
index d0563e510..87b911e5a 100644
--- a/views/index.blade.php
+++ b/views/index.blade.php
@@ -1,4 +1,4 @@
-<!DOCTYPE html>
+<!doctype html>
 <html>
   <head>
     <meta charset="utf-8">
@@ -6,32 +6,29 @@
     <title>{{ $title }}</title>
     <meta name="description" content="">
     <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1">
-    <meta name="{{ $config['modulePrefix'] }}/config/environment" content="{{ rawurlencode(json_encode($config)) }}">
     <base href="/">
     @foreach ($styles as $file)
-        <link rel="stylesheet" href="{{ str_replace(public_path(), '', $file) }}">
+      <link rel="stylesheet" href="{{ str_replace(public_path(), '', $file) }}">
     @endforeach
   </head>
+
   <body>
-    <div id="assets-loading" class="fade">Loading...</div>
-    <script>
-        setTimeout(function() {
-            var loading = document.getElementById('assets-loading');
-            if (loading) {
-                loading.className += ' in';
-            }
-        }, 1000);
-    </script>
+    {!! $layout !!}
 
-    {!! $content !!}
+    <div id="modal"></div>
+    <div id="alerts"></div>
 
-    <script>
-        var FLARUM_DATA = {!! json_encode($data) !!};
-        var FLARUM_SESSION = {!! json_encode($session) !!};
-        var FLARUM_ALERT = {!! json_encode($alert) !!};
-    </script>
     @foreach ($scripts as $file)
-        <script src="{{ str_replace(public_path(), '', $file) }}"></script>
+      <script src="{{ str_replace(public_path(), '', $file) }}"></script>
     @endforeach
+    <script>
+      var app = require('flarum/app')['default'];
+      app.config = {!! json_encode($config) !!};
+      app.preload = {
+        data: {!! json_encode($data) !!},
+        session: {!! json_encode($session) !!}
+      };
+      app.boot();
+    </script>
   </body>
 </html>