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(' '), - 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(' '); - } 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(' ')); + } +} 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 »</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>