From 74db323f83116087e773d23c3b547bc6627c1956 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 20 Dec 2014 16:56:46 +1030 Subject: [PATCH] Hello world! --- .gitignore | 6 + .travis.yml | 12 + LICENSE.txt | 21 + README.md | 162 ++++ composer.json | 33 + ember/.bowerrc | 3 + ember/.gitignore | 17 + ember/.jshintrc | 32 + ember/Brocfile.js | 20 + ember/README.md | 25 + ember/app/adapters/application.js | 26 + ember/app/app.js | 81 ++ ember/app/components/.gitkeep | 0 ember/app/components/button-item.js | 18 + ember/app/components/item-collection.js | 6 + ember/app/components/loading-indicator.js | 13 + ember/app/components/menu-item-separator.js | 5 + ember/app/components/menu-item.js | 31 + ember/app/components/menu-list.js | 6 + ember/app/components/menu-split.js | 15 + ember/app/components/nav-item.js | 34 + ember/app/components/notification-message.js | 9 + ember/app/components/search-input.js | 38 + ember/app/components/select-input.js | 9 + ember/app/controllers/.gitkeep | 0 ember/app/controllers/application.js | 48 + ember/app/controllers/composer.js | 17 + ember/app/controllers/discussion.js | 103 +++ ember/app/controllers/discussions.js | 175 ++++ ember/app/controllers/discussions/index.js | 5 + ember/app/controllers/login.js | 9 + ember/app/helpers/.gitkeep | 0 ember/app/helpers/abbreviate-number.js | 6 + ember/app/helpers/abbreviate-time.js | 31 + ember/app/helpers/fa-icon.js | 6 + ember/app/helpers/highlight-words.js | 18 + ember/app/helpers/post-content.js | 14 + ember/app/helpers/render-hook.js | 6 + ember/app/helpers/user-avatar.js | 53 ++ ember/app/index.html | 26 + ember/app/mixins/post-stream.js | 33 + ember/app/models/.gitkeep | 0 ember/app/models/discussion-result.js | 12 + ember/app/models/discussion-state.js | 7 + ember/app/models/discussion.js | 81 ++ ember/app/models/group.js | 10 + ember/app/models/post-result.js | 20 + ember/app/models/post-stream.js | 201 ++++ ember/app/models/post.js | 37 + ember/app/models/result-stream.js | 44 + ember/app/models/user.js | 21 + ember/app/router.js | 24 + ember/app/routes/.gitkeep | 0 ember/app/routes/application.js | 28 + ember/app/routes/discussion.js | 64 ++ ember/app/routes/discussions.js | 36 + ember/app/routes/discussions/index.js | 20 + ember/app/serializers/application.js | 14 + ember/app/styles/.gitkeep | 0 ember/app/styles/app.less | 14 + ember/app/styles/config.less | 32 + .../styles/flarum/bootstrap/bootstrap.less | 49 + .../styles/flarum/bootstrap/variables.less | 832 +++++++++++++++++ ember/app/styles/flarum/discussion.less | 357 ++++++++ ember/app/styles/flarum/discussions.less | 454 +++++++++ ember/app/styles/flarum/global.less | 858 ++++++++++++++++++ ember/app/templates/.gitkeep | 0 ember/app/templates/application.hbs | 56 ++ ember/app/templates/components/.gitkeep | 0 .../templates/components/item-collection.hbs | 3 + ember/app/templates/components/menu-list.hbs | 3 + ember/app/templates/components/menu-split.hbs | 13 + .../components/notification-message.hbs | 9 + .../components/post-type-comment.hbs | 26 + .../templates/components/post-type-title.hbs | 4 + .../app/templates/components/search-input.hbs | 2 + .../app/templates/components/text-editor.hbs | 9 + ember/app/templates/composer.hbs | 17 + ember/app/templates/discussion-header.hbs | 9 + ember/app/templates/discussion-post.hbs | 13 + ember/app/templates/discussion-scrollbar.hbs | 20 + ember/app/templates/discussion.hbs | 19 + ember/app/templates/discussions-header.hbs | 7 + ember/app/templates/discussions-nav.hbs | 4 + ember/app/templates/discussions-result.hbs | 60 ++ ember/app/templates/discussions.hbs | 46 + ember/app/templates/error.hbs | 5 + ember/app/templates/loading.hbs | 1 + ember/app/templates/login.hbs | 27 + ember/app/templates/session.hbs | 36 + ember/app/transitions.js | 12 + ember/app/transitions/slide-left.js | 2 + ember/app/transitions/slide-right.js | 2 + ember/app/transitions/slide.js | 48 + ember/app/utils/.gitkeep | 0 ember/app/utils/menu.js | 33 + ember/app/utils/named-container-view.js | 65 ++ ember/app/utils/plugin.js | 5 + ember/app/utils/scrollbar.js | 62 ++ ember/app/views/.gitkeep | 0 ember/app/views/application.js | 9 + ember/app/views/composer.js | 37 + ember/app/views/discussion-item.js | 132 +++ ember/app/views/discussion-post.js | 51 ++ ember/app/views/discussion-scrollbar.js | 280 ++++++ ember/app/views/discussion-sidebar.js | 50 + ember/app/views/discussion.js | 237 +++++ ember/app/views/discussions-nav.js | 22 + ember/app/views/discussions-result.js | 154 ++++ ember/app/views/discussions-sidebar.js | 127 +++ ember/app/views/discussions.js | 57 ++ ember/app/views/discussions/index.js | 12 + ember/app/views/login.js | 29 + ember/app/views/session.js | 5 + ember/bower.json | 21 + ember/config/environment.js | 38 + ember/package.json | 35 + ember/public/avatars/001.jpg | Bin 0 -> 5064 bytes ember/public/avatars/002.jpg | Bin 0 -> 5232 bytes ember/public/avatars/003.jpg | Bin 0 -> 5207 bytes ember/public/avatars/004.jpg | Bin 0 -> 3601 bytes ember/public/avatars/005.jpg | Bin 0 -> 5524 bytes ember/public/avatars/006.jpg | Bin 0 -> 7417 bytes ember/public/avatars/007.jpg | Bin 0 -> 8987 bytes ember/public/avatars/008.jpg | Bin 0 -> 4266 bytes ember/public/avatars/009.jpg | Bin 0 -> 3695 bytes ember/public/avatars/010.jpg | Bin 0 -> 2937 bytes ember/public/avatars/011.jpg | Bin 0 -> 15768 bytes ember/public/avatars/012.jpg | Bin 0 -> 5252 bytes ember/public/avatars/013.jpg | Bin 0 -> 5494 bytes ember/public/avatars/014.jpg | Bin 0 -> 3195 bytes ember/public/avatars/015.jpg | Bin 0 -> 6919 bytes ember/public/avatars/016.jpg | Bin 0 -> 5188 bytes ember/public/avatars/017.jpg | Bin 0 -> 4493 bytes ember/public/avatars/018.jpg | Bin 0 -> 6111 bytes ember/public/avatars/019.jpg | Bin 0 -> 3258 bytes ember/public/avatars/020.jpg | Bin 0 -> 2064 bytes ember/testem.json | 6 + ember/tests/.jshintrc | 73 ++ ember/tests/helpers/resolver.js | 9 + ember/tests/helpers/start-app.js | 30 + ember/tests/index.html | 49 + ember/tests/test-helper.js | 6 + ember/tests/unit/.gitkeep | 0 phpunit.xml | 18 + src/Flarum/Api/Actions/Base.php | 215 +++++ src/Flarum/Api/Actions/Discussions/Create.php | 35 + src/Flarum/Api/Actions/Discussions/Delete.php | 26 + src/Flarum/Api/Actions/Discussions/Index.php | 84 ++ src/Flarum/Api/Actions/Discussions/Show.php | 29 + src/Flarum/Api/Actions/Discussions/Update.php | 51 ++ src/Flarum/Api/Actions/Groups/Index.php | 18 + src/Flarum/Api/Actions/Posts/Create.php | 39 + src/Flarum/Api/Actions/Posts/Delete.php | 26 + src/Flarum/Api/Actions/Posts/Index.php | 62 ++ src/Flarum/Api/Actions/Posts/Show.php | 39 + src/Flarum/Api/Actions/Posts/Update.php | 39 + src/Flarum/Api/Actions/Users/Create.php | 39 + src/Flarum/Api/Actions/Users/Delete.php | 26 + src/Flarum/Api/Actions/Users/Index.php | 83 ++ src/Flarum/Api/Actions/Users/Show.php | 26 + src/Flarum/Api/Actions/Users/Update.php | 40 + src/Flarum/Api/ApiServiceProvider.php | 46 + .../Api/Serializers/ActivitySerializer.php | 19 + src/Flarum/Api/Serializers/BaseSerializer.php | 102 +++ .../Serializers/DiscussionBasicSerializer.php | 50 + .../Api/Serializers/DiscussionSerializer.php | 137 +++ .../Api/Serializers/GroupSerializer.php | 48 + .../Api/Serializers/PostBasicSerializer.php | 85 ++ src/Flarum/Api/Serializers/PostSerializer.php | 109 +++ .../Api/Serializers/UserAdminSerializer.php | 21 + .../Api/Serializers/UserBasicSerializer.php | 50 + .../Api/Serializers/UserCurrentSerializer.php | 29 + src/Flarum/Api/Serializers/UserSerializer.php | 52 ++ src/Flarum/Core/Activity/Activity.php | 36 + src/Flarum/Core/CoreServiceProvider.php | 98 ++ .../Commands/DeleteDiscussionCommand.php | 14 + .../DeleteDiscussionCommandHandler.php | 33 + .../Commands/DeleteDiscussionValidator.php | 7 + .../Commands/EditDiscussionCommand.php | 16 + .../Commands/EditDiscussionCommandHandler.php | 38 + .../Commands/EditDiscussionValidator.php | 7 + .../Commands/ReadDiscussionCommand.php | 17 + .../Commands/ReadDiscussionCommandHandler.php | 35 + .../Commands/ReadDiscussionValidator.php | 19 + .../Commands/StartDiscussionCommand.php | 17 + .../StartDiscussionCommandHandler.php | 59 ++ .../Commands/StartDiscussionValidator.php | 7 + src/Flarum/Core/Discussions/Discussion.php | 190 ++++ .../Core/Discussions/DiscussionFinder.php | 258 ++++++ .../Core/Discussions/DiscussionRepository.php | 43 + .../Core/Discussions/DiscussionState.php | 49 + .../Events/DiscussionWasDeleted.php | 13 + .../Discussions/Events/DiscussionWasRead.php | 13 + .../Events/DiscussionWasRenamed.php | 17 + .../Events/DiscussionWasStarted.php | 13 + src/Flarum/Core/Entity.php | 86 ++ src/Flarum/Core/Forum.php | 26 + src/Flarum/Core/Groups/Group.php | 18 + src/Flarum/Core/Groups/GroupRepository.php | 15 + .../Listeners/DiscussionMetadataUpdater.php | 63 ++ src/Flarum/Core/Listeners/PostFormatter.php | 48 + .../Core/Listeners/TitleChangePostCreator.php | 28 + .../Core/Listeners/UserMetadataUpdater.php | 70 ++ src/Flarum/Core/Permissions/Manager.php | 42 + src/Flarum/Core/Permissions/Permission.php | 7 + .../Core/Permissions/PermissionRepository.php | 20 + .../Core/Posts/Commands/DeletePostCommand.php | 14 + .../Commands/DeletePostCommandHandler.php | 33 + .../Posts/Commands/DeletePostValidator.php | 7 + .../Core/Posts/Commands/EditPostCommand.php | 18 + .../Posts/Commands/EditPostCommandHandler.php | 44 + .../Core/Posts/Commands/EditPostValidator.php | 7 + .../Core/Posts/Commands/PostReplyCommand.php | 17 + .../Commands/PostReplyCommandHandler.php | 53 ++ .../Posts/Commands/PostReplyValidator.php | 7 + src/Flarum/Core/Posts/CommentPost.php | 63 ++ .../Core/Posts/Events/PostWasDeleted.php | 13 + .../Core/Posts/Events/PostWasHidden.php | 13 + .../Core/Posts/Events/PostWasRestored.php | 13 + .../Core/Posts/Events/PostWasRevised.php | 13 + .../Core/Posts/Events/ReplyWasPosted.php | 13 + src/Flarum/Core/Posts/Post.php | 113 +++ src/Flarum/Core/Posts/PostRepository.php | 31 + src/Flarum/Core/Posts/TitleChangePost.php | 25 + .../Core/Search/FulltextSearchDriver.php | 48 + .../Core/Search/SearchDriverInterface.php | 8 + src/Flarum/Core/Search/SphinxSearchDriver.php | 34 + src/Flarum/Core/Search/Tokenizer.php | 17 + src/Flarum/Core/Search/Tokens/AuthorToken.php | 25 + .../Core/Search/Tokens/TokenAbstract.php | 27 + .../Core/Search/Tokens/TokenInterface.php | 12 + src/Flarum/Core/Support/CommandValidator.php | 44 + .../Exceptions/PermissionDeniedException.php | 7 + .../Exceptions/ValidationFailureException.php | 40 + .../Core/Support/Extensions/Extension.php | 6 + .../Core/Support/Extensions/Manager.php | 6 + .../Core/Users/Commands/DeleteUserCommand.php | 14 + .../Commands/DeleteUserCommandHandler.php | 33 + .../Users/Commands/DeleteUserValidator.php | 7 + .../Core/Users/Commands/EditUserCommand.php | 20 + .../Users/Commands/EditUserCommandHandler.php | 46 + .../Core/Users/Commands/EditUserValidator.php | 7 + .../Users/Commands/RegisterUserCommand.php | 20 + .../Commands/RegisterUserCommandHandler.php | 49 + .../Users/Commands/RegisterUserValidator.php | 7 + .../Core/Users/Events/EmailWasChanged.php | 13 + .../Core/Users/Events/PasswordWasChanged.php | 13 + .../Core/Users/Events/UserWasDeleted.php | 13 + .../Core/Users/Events/UserWasRegistered.php | 13 + .../Core/Users/Events/UserWasRenamed.php | 13 + src/Flarum/Core/Users/Guest.php | 34 + src/Flarum/Core/Users/User.php | 240 +++++ src/Flarum/Core/Users/UserFinder.php | 167 ++++ src/Flarum/Core/Users/UserRepository.php | 48 + src/Flarum/Core/Users/UsernameValidator.php | 10 + src/Flarum/Web/AssetManager.php | 90 ++ src/Flarum/Web/WebServiceProvider.php | 66 ++ src/config/.gitkeep | 0 src/config/config.php | 23 + src/lang/.gitkeep | 0 src/lang/en/reminders.php | 22 + src/lang/en/validation.php | 93 ++ src/migrations/.gitkeep | 0 .../2014_01_14_231259_create_config_table.php | 32 + ..._01_14_231321_create_discussions_table.php | 45 + .../2014_01_14_231334_create_groups_table.php | 32 + ..._01_14_231343_create_permissions_table.php | 34 + .../2014_01_14_231350_create_posts_table.php | 49 + ...014_01_14_231357_create_sessions_table.php | 33 + .../2014_01_14_231404_create_users_table.php | 39 + ..._231455_create_users_discussions_table.php | 35 + ...01_14_231503_create_users_groups_table.php | 33 + ...014_01_19_232631_create_activity_table.php | 38 + src/routes.api.php | 178 ++++ src/routes.php | 7 + src/views/.gitkeep | 0 src/views/index.blade.php | 28 + tests/.gitkeep | 0 279 files changed, 11954 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 composer.json create mode 100644 ember/.bowerrc create mode 100644 ember/.gitignore create mode 100644 ember/.jshintrc create mode 100644 ember/Brocfile.js create mode 100644 ember/README.md create mode 100644 ember/app/adapters/application.js create mode 100644 ember/app/app.js create mode 100644 ember/app/components/.gitkeep create mode 100644 ember/app/components/button-item.js create mode 100644 ember/app/components/item-collection.js create mode 100644 ember/app/components/loading-indicator.js create mode 100644 ember/app/components/menu-item-separator.js create mode 100644 ember/app/components/menu-item.js create mode 100644 ember/app/components/menu-list.js create mode 100644 ember/app/components/menu-split.js create mode 100644 ember/app/components/nav-item.js create mode 100755 ember/app/components/notification-message.js create mode 100644 ember/app/components/search-input.js create mode 100644 ember/app/components/select-input.js create mode 100644 ember/app/controllers/.gitkeep create mode 100644 ember/app/controllers/application.js create mode 100644 ember/app/controllers/composer.js create mode 100644 ember/app/controllers/discussion.js create mode 100644 ember/app/controllers/discussions.js create mode 100644 ember/app/controllers/discussions/index.js create mode 100644 ember/app/controllers/login.js create mode 100644 ember/app/helpers/.gitkeep create mode 100644 ember/app/helpers/abbreviate-number.js create mode 100644 ember/app/helpers/abbreviate-time.js create mode 100644 ember/app/helpers/fa-icon.js create mode 100644 ember/app/helpers/highlight-words.js create mode 100644 ember/app/helpers/post-content.js create mode 100644 ember/app/helpers/render-hook.js create mode 100644 ember/app/helpers/user-avatar.js create mode 100644 ember/app/index.html create mode 100644 ember/app/mixins/post-stream.js create mode 100644 ember/app/models/.gitkeep create mode 100644 ember/app/models/discussion-result.js create mode 100644 ember/app/models/discussion-state.js create mode 100644 ember/app/models/discussion.js create mode 100644 ember/app/models/group.js create mode 100644 ember/app/models/post-result.js create mode 100644 ember/app/models/post-stream.js create mode 100644 ember/app/models/post.js create mode 100644 ember/app/models/result-stream.js create mode 100644 ember/app/models/user.js create mode 100644 ember/app/router.js create mode 100644 ember/app/routes/.gitkeep create mode 100644 ember/app/routes/application.js create mode 100644 ember/app/routes/discussion.js create mode 100644 ember/app/routes/discussions.js create mode 100644 ember/app/routes/discussions/index.js create mode 100644 ember/app/serializers/application.js create mode 100644 ember/app/styles/.gitkeep create mode 100644 ember/app/styles/app.less create mode 100644 ember/app/styles/config.less create mode 100644 ember/app/styles/flarum/bootstrap/bootstrap.less create mode 100644 ember/app/styles/flarum/bootstrap/variables.less create mode 100644 ember/app/styles/flarum/discussion.less create mode 100644 ember/app/styles/flarum/discussions.less create mode 100644 ember/app/styles/flarum/global.less create mode 100644 ember/app/templates/.gitkeep create mode 100644 ember/app/templates/application.hbs create mode 100644 ember/app/templates/components/.gitkeep create mode 100644 ember/app/templates/components/item-collection.hbs create mode 100644 ember/app/templates/components/menu-list.hbs create mode 100644 ember/app/templates/components/menu-split.hbs create mode 100644 ember/app/templates/components/notification-message.hbs create mode 100644 ember/app/templates/components/post-type-comment.hbs create mode 100644 ember/app/templates/components/post-type-title.hbs create mode 100644 ember/app/templates/components/search-input.hbs create mode 100644 ember/app/templates/components/text-editor.hbs create mode 100644 ember/app/templates/composer.hbs create mode 100644 ember/app/templates/discussion-header.hbs create mode 100644 ember/app/templates/discussion-post.hbs create mode 100644 ember/app/templates/discussion-scrollbar.hbs create mode 100644 ember/app/templates/discussion.hbs create mode 100644 ember/app/templates/discussions-header.hbs create mode 100644 ember/app/templates/discussions-nav.hbs create mode 100644 ember/app/templates/discussions-result.hbs create mode 100644 ember/app/templates/discussions.hbs create mode 100644 ember/app/templates/error.hbs create mode 100644 ember/app/templates/loading.hbs create mode 100644 ember/app/templates/login.hbs create mode 100644 ember/app/templates/session.hbs create mode 100644 ember/app/transitions.js create mode 100644 ember/app/transitions/slide-left.js create mode 100644 ember/app/transitions/slide-right.js create mode 100644 ember/app/transitions/slide.js create mode 100644 ember/app/utils/.gitkeep create mode 100644 ember/app/utils/menu.js create mode 100644 ember/app/utils/named-container-view.js create mode 100644 ember/app/utils/plugin.js create mode 100644 ember/app/utils/scrollbar.js create mode 100644 ember/app/views/.gitkeep create mode 100644 ember/app/views/application.js create mode 100644 ember/app/views/composer.js create mode 100644 ember/app/views/discussion-item.js create mode 100644 ember/app/views/discussion-post.js create mode 100644 ember/app/views/discussion-scrollbar.js create mode 100644 ember/app/views/discussion-sidebar.js create mode 100644 ember/app/views/discussion.js create mode 100644 ember/app/views/discussions-nav.js create mode 100755 ember/app/views/discussions-result.js create mode 100644 ember/app/views/discussions-sidebar.js create mode 100644 ember/app/views/discussions.js create mode 100644 ember/app/views/discussions/index.js create mode 100644 ember/app/views/login.js create mode 100644 ember/app/views/session.js create mode 100644 ember/bower.json create mode 100644 ember/config/environment.js create mode 100644 ember/package.json create mode 100755 ember/public/avatars/001.jpg create mode 100755 ember/public/avatars/002.jpg create mode 100755 ember/public/avatars/003.jpg create mode 100755 ember/public/avatars/004.jpg create mode 100755 ember/public/avatars/005.jpg create mode 100755 ember/public/avatars/006.jpg create mode 100755 ember/public/avatars/007.jpg create mode 100755 ember/public/avatars/008.jpg create mode 100755 ember/public/avatars/009.jpg create mode 100755 ember/public/avatars/010.jpg create mode 100755 ember/public/avatars/011.jpg create mode 100755 ember/public/avatars/012.jpg create mode 100755 ember/public/avatars/013.jpg create mode 100755 ember/public/avatars/014.jpg create mode 100755 ember/public/avatars/015.jpg create mode 100755 ember/public/avatars/016.jpg create mode 100755 ember/public/avatars/017.jpg create mode 100755 ember/public/avatars/018.jpg create mode 100755 ember/public/avatars/019.jpg create mode 100755 ember/public/avatars/020.jpg create mode 100644 ember/testem.json create mode 100644 ember/tests/.jshintrc create mode 100644 ember/tests/helpers/resolver.js create mode 100644 ember/tests/helpers/start-app.js create mode 100644 ember/tests/index.html create mode 100644 ember/tests/test-helper.js create mode 100644 ember/tests/unit/.gitkeep create mode 100644 phpunit.xml create mode 100644 src/Flarum/Api/Actions/Base.php create mode 100644 src/Flarum/Api/Actions/Discussions/Create.php create mode 100644 src/Flarum/Api/Actions/Discussions/Delete.php create mode 100644 src/Flarum/Api/Actions/Discussions/Index.php create mode 100644 src/Flarum/Api/Actions/Discussions/Show.php create mode 100644 src/Flarum/Api/Actions/Discussions/Update.php create mode 100644 src/Flarum/Api/Actions/Groups/Index.php create mode 100644 src/Flarum/Api/Actions/Posts/Create.php create mode 100644 src/Flarum/Api/Actions/Posts/Delete.php create mode 100644 src/Flarum/Api/Actions/Posts/Index.php create mode 100644 src/Flarum/Api/Actions/Posts/Show.php create mode 100644 src/Flarum/Api/Actions/Posts/Update.php create mode 100644 src/Flarum/Api/Actions/Users/Create.php create mode 100644 src/Flarum/Api/Actions/Users/Delete.php create mode 100644 src/Flarum/Api/Actions/Users/Index.php create mode 100644 src/Flarum/Api/Actions/Users/Show.php create mode 100644 src/Flarum/Api/Actions/Users/Update.php create mode 100644 src/Flarum/Api/ApiServiceProvider.php create mode 100644 src/Flarum/Api/Serializers/ActivitySerializer.php create mode 100644 src/Flarum/Api/Serializers/BaseSerializer.php create mode 100644 src/Flarum/Api/Serializers/DiscussionBasicSerializer.php create mode 100644 src/Flarum/Api/Serializers/DiscussionSerializer.php create mode 100644 src/Flarum/Api/Serializers/GroupSerializer.php create mode 100644 src/Flarum/Api/Serializers/PostBasicSerializer.php create mode 100644 src/Flarum/Api/Serializers/PostSerializer.php create mode 100644 src/Flarum/Api/Serializers/UserAdminSerializer.php create mode 100644 src/Flarum/Api/Serializers/UserBasicSerializer.php create mode 100644 src/Flarum/Api/Serializers/UserCurrentSerializer.php create mode 100644 src/Flarum/Api/Serializers/UserSerializer.php create mode 100644 src/Flarum/Core/Activity/Activity.php create mode 100644 src/Flarum/Core/CoreServiceProvider.php create mode 100644 src/Flarum/Core/Discussions/Commands/DeleteDiscussionCommand.php create mode 100644 src/Flarum/Core/Discussions/Commands/DeleteDiscussionCommandHandler.php create mode 100644 src/Flarum/Core/Discussions/Commands/DeleteDiscussionValidator.php create mode 100644 src/Flarum/Core/Discussions/Commands/EditDiscussionCommand.php create mode 100644 src/Flarum/Core/Discussions/Commands/EditDiscussionCommandHandler.php create mode 100644 src/Flarum/Core/Discussions/Commands/EditDiscussionValidator.php create mode 100644 src/Flarum/Core/Discussions/Commands/ReadDiscussionCommand.php create mode 100644 src/Flarum/Core/Discussions/Commands/ReadDiscussionCommandHandler.php create mode 100644 src/Flarum/Core/Discussions/Commands/ReadDiscussionValidator.php create mode 100644 src/Flarum/Core/Discussions/Commands/StartDiscussionCommand.php create mode 100644 src/Flarum/Core/Discussions/Commands/StartDiscussionCommandHandler.php create mode 100644 src/Flarum/Core/Discussions/Commands/StartDiscussionValidator.php create mode 100755 src/Flarum/Core/Discussions/Discussion.php create mode 100644 src/Flarum/Core/Discussions/DiscussionFinder.php create mode 100755 src/Flarum/Core/Discussions/DiscussionRepository.php create mode 100644 src/Flarum/Core/Discussions/DiscussionState.php create mode 100644 src/Flarum/Core/Discussions/Events/DiscussionWasDeleted.php create mode 100644 src/Flarum/Core/Discussions/Events/DiscussionWasRead.php create mode 100644 src/Flarum/Core/Discussions/Events/DiscussionWasRenamed.php create mode 100644 src/Flarum/Core/Discussions/Events/DiscussionWasStarted.php create mode 100755 src/Flarum/Core/Entity.php create mode 100755 src/Flarum/Core/Forum.php create mode 100755 src/Flarum/Core/Groups/Group.php create mode 100755 src/Flarum/Core/Groups/GroupRepository.php create mode 100755 src/Flarum/Core/Listeners/DiscussionMetadataUpdater.php create mode 100755 src/Flarum/Core/Listeners/PostFormatter.php create mode 100755 src/Flarum/Core/Listeners/TitleChangePostCreator.php create mode 100755 src/Flarum/Core/Listeners/UserMetadataUpdater.php create mode 100755 src/Flarum/Core/Permissions/Manager.php create mode 100644 src/Flarum/Core/Permissions/Permission.php create mode 100755 src/Flarum/Core/Permissions/PermissionRepository.php create mode 100644 src/Flarum/Core/Posts/Commands/DeletePostCommand.php create mode 100644 src/Flarum/Core/Posts/Commands/DeletePostCommandHandler.php create mode 100644 src/Flarum/Core/Posts/Commands/DeletePostValidator.php create mode 100644 src/Flarum/Core/Posts/Commands/EditPostCommand.php create mode 100644 src/Flarum/Core/Posts/Commands/EditPostCommandHandler.php create mode 100644 src/Flarum/Core/Posts/Commands/EditPostValidator.php create mode 100644 src/Flarum/Core/Posts/Commands/PostReplyCommand.php create mode 100644 src/Flarum/Core/Posts/Commands/PostReplyCommandHandler.php create mode 100644 src/Flarum/Core/Posts/Commands/PostReplyValidator.php create mode 100755 src/Flarum/Core/Posts/CommentPost.php create mode 100644 src/Flarum/Core/Posts/Events/PostWasDeleted.php create mode 100644 src/Flarum/Core/Posts/Events/PostWasHidden.php create mode 100644 src/Flarum/Core/Posts/Events/PostWasRestored.php create mode 100644 src/Flarum/Core/Posts/Events/PostWasRevised.php create mode 100644 src/Flarum/Core/Posts/Events/ReplyWasPosted.php create mode 100755 src/Flarum/Core/Posts/Post.php create mode 100755 src/Flarum/Core/Posts/PostRepository.php create mode 100755 src/Flarum/Core/Posts/TitleChangePost.php create mode 100644 src/Flarum/Core/Search/FulltextSearchDriver.php create mode 100644 src/Flarum/Core/Search/SearchDriverInterface.php create mode 100644 src/Flarum/Core/Search/SphinxSearchDriver.php create mode 100644 src/Flarum/Core/Search/Tokenizer.php create mode 100644 src/Flarum/Core/Search/Tokens/AuthorToken.php create mode 100644 src/Flarum/Core/Search/Tokens/TokenAbstract.php create mode 100644 src/Flarum/Core/Search/Tokens/TokenInterface.php create mode 100644 src/Flarum/Core/Support/CommandValidator.php create mode 100644 src/Flarum/Core/Support/Exceptions/PermissionDeniedException.php create mode 100644 src/Flarum/Core/Support/Exceptions/ValidationFailureException.php create mode 100755 src/Flarum/Core/Support/Extensions/Extension.php create mode 100755 src/Flarum/Core/Support/Extensions/Manager.php create mode 100644 src/Flarum/Core/Users/Commands/DeleteUserCommand.php create mode 100644 src/Flarum/Core/Users/Commands/DeleteUserCommandHandler.php create mode 100644 src/Flarum/Core/Users/Commands/DeleteUserValidator.php create mode 100644 src/Flarum/Core/Users/Commands/EditUserCommand.php create mode 100644 src/Flarum/Core/Users/Commands/EditUserCommandHandler.php create mode 100644 src/Flarum/Core/Users/Commands/EditUserValidator.php create mode 100644 src/Flarum/Core/Users/Commands/RegisterUserCommand.php create mode 100644 src/Flarum/Core/Users/Commands/RegisterUserCommandHandler.php create mode 100644 src/Flarum/Core/Users/Commands/RegisterUserValidator.php create mode 100644 src/Flarum/Core/Users/Events/EmailWasChanged.php create mode 100644 src/Flarum/Core/Users/Events/PasswordWasChanged.php create mode 100644 src/Flarum/Core/Users/Events/UserWasDeleted.php create mode 100644 src/Flarum/Core/Users/Events/UserWasRegistered.php create mode 100644 src/Flarum/Core/Users/Events/UserWasRenamed.php create mode 100755 src/Flarum/Core/Users/Guest.php create mode 100755 src/Flarum/Core/Users/User.php create mode 100644 src/Flarum/Core/Users/UserFinder.php create mode 100755 src/Flarum/Core/Users/UserRepository.php create mode 100644 src/Flarum/Core/Users/UsernameValidator.php create mode 100644 src/Flarum/Web/AssetManager.php create mode 100644 src/Flarum/Web/WebServiceProvider.php create mode 100644 src/config/.gitkeep create mode 100755 src/config/config.php create mode 100644 src/lang/.gitkeep create mode 100755 src/lang/en/reminders.php create mode 100755 src/lang/en/validation.php create mode 100644 src/migrations/.gitkeep create mode 100644 src/migrations/2014_01_14_231259_create_config_table.php create mode 100644 src/migrations/2014_01_14_231321_create_discussions_table.php create mode 100644 src/migrations/2014_01_14_231334_create_groups_table.php create mode 100644 src/migrations/2014_01_14_231343_create_permissions_table.php create mode 100644 src/migrations/2014_01_14_231350_create_posts_table.php create mode 100644 src/migrations/2014_01_14_231357_create_sessions_table.php create mode 100644 src/migrations/2014_01_14_231404_create_users_table.php create mode 100644 src/migrations/2014_01_14_231455_create_users_discussions_table.php create mode 100644 src/migrations/2014_01_14_231503_create_users_groups_table.php create mode 100644 src/migrations/2014_01_19_232631_create_activity_table.php create mode 100644 src/routes.api.php create mode 100755 src/routes.php create mode 100644 src/views/.gitkeep create mode 100644 src/views/index.blade.php create mode 100644 tests/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..63f0d1054 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor +composer.phar +composer.lock +.DS_Store +Thumbs.db +public/* \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..4a7ff2b8b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: php + +php: + - 5.3 + - 5.4 + - 5.5 + +before_script: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install --dev + +script: phpunit \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..b0608acf8 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Toby Zerner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..d7c92c648 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +**Together, let's build amazing PHP forum software.** + +I’m [Toby Zerner](http://tobyzerner.com), the developer of [esoTalk](http://esotalk.org). Years ago, I built esoTalk as a fresh, lightweight forum software alternative. esoTalk is nice but it is [not built on a sustainable foundation](http://esotalk.org/blog/faq.html). + +**As it stands, there is a need for modern, well-architected, powerful forum software that is easy to use and self-host.** That’s what Flarum is. But I'm a full-time student and I don't have time to do this by myself. So I'm opening up Flarum to the world — let's build it together. + +## Philosophy + +I have a vision for Flarum — one that has grown over years of playing with forums, developing esoTalk, and learning from mistakes. It is captured by the following four points: + +- **Modern design.** Beautiful, clean, customizable. Forum design hasn’t evolved with the rest of the web; let's finally bring it up to speed. +- **Unopinionated feature-set.** Every community is different, and forum software should be able to adapt to — rather than define — how a community is run. As such, Flarum will have a lightweight core, and most features will be implemented as optional extensions. +- **High performance.** Flarum should perform well on scales big and small, and on a large range of devices. +- **Sustainable architecture.** Flarum should be built for the future, on a strong foundation which can evolve with the technologies that power it. + +Flarum is open-source software released under the [MIT license](https://github.com/flarum/core/blob/master/LICENSE.txt). + +## Technology + +I've carefully considered which frameworks to use to build Flarum. Here's the reasoning: + +### Laravel + +PHP remains the most user-friendly language for deploying web scripts, especially on shared hosting. The Laravel framework will allow for rapid development of Flarum’s API, and has a large community which will encourage collaboration and evolution. + +### Ember.js + +Ember.js is a mature JavaScript framework which will power Flarum’s front-end. Use of a JavaScript framework allows us to build a fast, dynamic interface which feels more like an app than a simple web page. + +> Don't like the fact that Flarum is an Ember.js app? Take a look at [FluxBB 2](https://github.com/fluxbb/fluxbb/tree/2.0), which is being developed in a more traditional manner with Laravel. + +## Current State + +I’ve been working on a prototype for some time in-between my studies. In addition to interface design, most of my time has been spent building out the architecture: making decisions about which frameworks to use, the most effective way to componentize everything, standardizing the API, etc. + +### What’s Done + +- [x] The basic technology stack (Laravel and Ember – see above) +- [x] The [architectural foundation](https://github.com/flarum/core/wiki/Architecture) (core/API/web layers) +- [x] Some of the API (discussion and post read + write) +- [x] Discussion list view and basic search functionality +- [x] Discussion viewing and scrolling + +### What’s Next + +The priority at the moment is to build out a lightweight core, and only start building [Extensions](https://github.com/flarum/core/wiki/Extensions) when it is relatively stable. Below is a list of the things to work on immediately, with links to the relevant discussion. + +- [ ] Interface redesign (#1) +- [ ] Upgrade to Laravel 5 (#2) +- [ ] Set up testing frameworks in both Laravel (#3) and Ember (#4) +- [ ] Further consolidation of Extension interfaces (see Extensions) +- [ ] Develop user authentication strategy (#5) +- [ ] Implement replying, post editing, discussion creation (#6) +- [ ] Implement discussion title editing (#7) +- [ ] Implement post deletion (#8) +- [ ] Implement discussion deletion (#9) +- [ ] Build Notifications system (#10) +- [ ] Design user profile interface (#11) +- [ ] Design admin interfaces (#12) + +For a full list of planned features, see [Features](https://github.com/flarum/core/wiki/Features). + +## Installation + +Currently Flarum is in its very early stages, and it isn’t pretty. **It is far from usable.** Set it up only if you know what you’re doing, and expect it to break a lot. + +1. Make sure you have [Composer](http://getcomposer.org) and [ember-cli](http://www.ember-cli.com) installed globally. +2. Create a new [Laravel 4](http://laravel.com/docs/4.2/quick) project. +3. Run the following command in your project directory: + + composer require flarum/core + +4. Create a new MySQL database and enter your details into `app/config/packages/flarum/core/config.php`. +5. Run the Flarum migrations and database seeder to generate dummy data: + + php artisan migrate + php artisan db:seed --class="Flarum\Core\Support\DatabaseSeeder" + +6. Run the following commands to compile the Ember app: + + cd vendor/flarum/core/ember + ember serve --output-path="../public" + +7. Visit your Laravel application in a browser. + +> Note: You must access the Laravel application so that it is at the top level (i.e. not under any sub-directories.) To do this, you can either make your web server's document root the `public` folder of your application, or you can [configure a virtual host](http://davidwalsh.name/create-virtual-host) pointing to the `public` folder. + +If you’re having trouble, **do not** create a new issue — instead, get help on the [Flarum Development Forum](http://discuss.flarum.org). + +## Contributing + +Building Flarum is going to be a team effort, and we'd love for you to help! All contributions are welcomed. + +### What Can I Do? + +- **Contribute code.** Start by becoming familiar with Flarum's source code and its [Architecture](https://github.com/flarum/core/wiki/Architecture). Then have a look at what needs to be done in the list above, and see if there's anything you can help out with. See below for instructions on submitting a Pull Request. + +- **Participate in discussion.** Review the [wiki](https://github.com/flarum/core/wiki) and [issues](https://github.com/flarum/core/issues) and contribute your constructive thoughts. We'd also love to hear general feedback on the [Flarum Development Forum](http://discuss.flarum.org). + +- **Spread the word.** Know someone who could help out? Please share this project with them! + +> In this early stage of development, bug reports won't be particularly helpful, because things will be constantly changing and breaking. + +### Process + +1. Review the [Flarum Contributor License Agreement](#contributor-license-agreement). ([Why?](https://julien.ponge.org/blog/in-defense-of-contributor-license-agreements/)) + +2. Install Flarum as detailed in the instructions above. + +3. Create a new branch. + + git checkout -b new-flarum-branch + +> Please implement only one feature/bugfix per branch to keep pull requests clean and focused. + +4. Code. + - Follow the coding style: [PSR-2](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md). + - Include tests and make sure they pass (subject to #3 and #4). + +5. Commit. + - Commit messages are **required**. + - They should include a short description of the changes on the first line, then a blank line, then more details if necessary. + +6. Clean up. Squash together minor commits. + + git rebase -i + +7. Update your branch so that it is based on top of the latest code from the Flarum repository. + + git fetch origin + git rebase origin/master + +8. Fork your repository on GitHub and push to it. + + git remote add mine git@github.com:/flarum.git + git push mine new-flarum-branch + +9. Submit a pull request. + - Go to the Flarum repository you just pushed to (e.g. https://github.com/your-user-name/flarum). + - Click "Pull Request". + - Write your branch name in the branch field. + - Click "Update Commit Range". + - Ensure that the correct commits and files changes are included. + - Fill in a descriptive title and other details about your pull request. + - Click "Send pull request". + +10. Respond to feedback. + - We may suggest changes to your code. Maintaining a high standard of code quality is important for the longevity of this project — use it as an opportunity to improve your own skills and learn something new! + +### Core Team + +Currently the only person on the core development team is Toby Zerner ([@tobscure](http://twitter.com/tobscure)). Over time, judged by display of commitment to the project, and quantity/quality of contributions, I will be looking for more people to join the core development team. Please do not email me asking to be on the core team; rather, demonstrate initiative and commitment to the project and I will notice! + +### Contributor License Agreement + +By contributing your code to Flarum you grant Toby Zerner a non-exclusive, irrevocable, worldwide, royalty-free, sublicenseable, transferable license under all of Your relevant intellectual property rights (including copyright, patent, and any other rights), to use, copy, prepare derivative works of, distribute and publicly perform and display the Contributions on any licensing terms, including without limitation: (a) open source licenses like the MIT license; and (b) binary, proprietary, or commercial licenses. Except for the licenses granted herein, You reserve all right, title, and interest in and to the Contribution. + +You confirm that you are able to grant us these rights. You represent that You are legally entitled to grant the above license. If Your employer has rights to intellectual property that You create, You represent that You have received permission to make the Contributions on behalf of that employer, or that Your employer has waived such rights for the Contributions. + +You represent that the Contributions are Your original works of authorship, and to Your knowledge, no other person claims, or has the right to claim, any right in any invention or patent related to the Contributions. You also represent that You are not legally obligated, whether by entering into an agreement or otherwise, in any way that conflicts with the terms of this license. + +Toby Zerner acknowledges that, except as explicitly described in this Agreement, any Contribution which you provide is on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 000000000..1a57b8405 --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "flarum/core", + "description": "", + "authors": [ + { + "name": "Toby Zerner", + "email": "toby@flarum.org" + } + ], + "require": { + "php": ">=5.4.0", + "illuminate/support": "4.2.*", + "laracasts/commander": "1.1.*", + "leafo/lessphp": "0.4.0", + "fzaninotto/faker": "1.4.0" + }, + "autoload": { + "classmap": [ + "src/migrations" + ], + "psr-0": { + "Flarum\\Core": "src/", + "Flarum\\Api": "src/", + "Flarum\\Web": "src/" + } + }, + "scripts": { + "post-install-cmd": [ + "php artisan config:publish flarum/core" + ] + }, + "minimum-stability": "dev" +} diff --git a/ember/.bowerrc b/ember/.bowerrc new file mode 100644 index 000000000..6866ac2ca --- /dev/null +++ b/ember/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory": "vendor" +} diff --git a/ember/.gitignore b/ember/.gitignore new file mode 100644 index 000000000..f4d4f1f62 --- /dev/null +++ b/ember/.gitignore @@ -0,0 +1,17 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp + +# dependencies +/node_modules +/vendor/* + +# misc +/.sass-cache +/connect.lock +/coverage/* +/libpeerconnection.log +npm-debug.log +testem.log diff --git a/ember/.jshintrc b/ember/.jshintrc new file mode 100644 index 000000000..89765e858 --- /dev/null +++ b/ember/.jshintrc @@ -0,0 +1,32 @@ +{ + "predef": { + "document": true, + "window": true, + "FlarumENV": true + }, + "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/Brocfile.js b/ember/Brocfile.js new file mode 100644 index 000000000..29092de95 --- /dev/null +++ b/ember/Brocfile.js @@ -0,0 +1,20 @@ +/* global require, module */ + +var EmberApp = require('ember-cli/lib/broccoli/ember-app'); + +var app = new EmberApp(); + +app.import('vendor/bootstrap/dist/js/bootstrap.js'); +app.import('vendor/spin.js/spin.js'); +app.import('vendor/spin.js/jquery.spin.js'); +app.import('vendor/moment/moment.js'); +app.import('vendor/jquery-scrollparent/jquery.scrollparent.js'); +app.import('vendor/json-api.js'); + +app.import('vendor/font-awesome/fonts/fontawesome-webfont.eot'); +app.import('vendor/font-awesome/fonts/fontawesome-webfont.svg'); +app.import('vendor/font-awesome/fonts/fontawesome-webfont.ttf'); +app.import('vendor/font-awesome/fonts/fontawesome-webfont.woff'); +app.import('vendor/font-awesome/fonts/FontAwesome.otf'); + +module.exports = app.toTree(); diff --git a/ember/README.md b/ember/README.md new file mode 100644 index 000000000..cd7d1d1c6 --- /dev/null +++ b/ember/README.md @@ -0,0 +1,25 @@ +# Flarum + +This README outlines the details of collaborating on this Ember application. + +## Installation + +* `git clone` this repository +* `npm install` +* `bower install` + +## Running + +* `ember server` +* Visit your app at http://localhost:4200. + +## Running Tests + +* `ember test` +* `ember test --server` + +## Building + +* `ember build` + +For more information on using ember-cli, visit [http://iamstef.net/ember-cli/](http://iamstef.net/ember-cli/). diff --git a/ember/app/adapters/application.js b/ember/app/adapters/application.js new file mode 100644 index 000000000..66d8252dd --- /dev/null +++ b/ember/app/adapters/application.js @@ -0,0 +1,26 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +export default DS.JsonApiAdapter.extend({ + host: '/api', + + xhr: [], + + ajax: function(url, type, hash) { + var adapter = this; + + return new Ember.RSVP.Promise(function(resolve, reject) { + hash = adapter.ajaxOptions(url, type, hash); + + hash.success = function(json) { + Ember.run(null, resolve, json); + }; + + hash.error = function(jqXHR, textStatus, errorThrown) { + Ember.run(null, reject, adapter.ajaxError(jqXHR)); + }; + + adapter.xhr.push(Ember.$.ajax(hash)); + }, "DS: RestAdapter#ajax " + type + " to " + url); + }, +}); diff --git a/ember/app/app.js b/ember/app/app.js new file mode 100644 index 000000000..b58da3e50 --- /dev/null +++ b/ember/app/app.js @@ -0,0 +1,81 @@ +import Ember from 'ember'; +import Resolver from 'ember/resolver'; +import loadInitializers from 'ember/load-initializers'; + +Ember.MODEL_FACTORY_INJECTIONS = true; + +var App = Ember.Application.extend({ + modulePrefix: 'flarum', // TODO: loaded via config + Resolver: Resolver, + + registerPlugin: function(plugin) { + console.log('Plugin loaded: '+plugin.name); + plugin.boot(); + } +}); + +loadInitializers(App, 'flarum'); + + +//----------------------------------------- +// TODO: Move all this to an initializer + +/* +import User from 'flarum/models/user'; + +// Authentication + +import BaseAuthenticator from 'simple-auth/authenticators/base'; + +var FlarumAuthenticator = BaseAuthenticator.extend({ + restore: function(data) { + // return Ember.RSVP.Promise.resolve(data); + }, + authenticate: function(credentials) { + return new Ember.RSVP.Promise(function(resolve, reject) { + Ember.$.ajax({ + url: 'http://localhost/public/Flarum/flarum/public/api/auth', + type: 'POST', + data: { type: 'password', identification: credentials.identification, password: credentials.password } + }).then(function(response) { + resolve({ token: response.token, userId: response.user.id }); + }, function(xhr, status, error) { + reject(xhr.responseText); + }); + }); + }, + // invalidate: function() { + // return Ember.RSVP.Promise.resolve(); + // } +}); + +import BaseAuthorizer from 'simple-auth/authorizers/base'; + +var FlarumAuthorizer = BaseAuthorizer.extend({ + +}); + +App.initializer({ + name: 'authentication', + initialize: function(container, application) { + container.register('authenticator:flarum', FlarumAuthenticator); + container.register('authorizer:flarum', FlarumAuthorizer); + + // customize the session so that it allows access to the account object + Ember.SimpleAuth.Session.reopen({ + user: function() { + var userId = this.get('userId'); + if (!userId) return; + return container.lookup('store:main').find('user', userId); + }.property('userId') + }); + + Ember.SimpleAuth.setup(container, application, { + authorizerFactory: 'authorizer:flarum', + routeAfterAuthentication: 'discussions' + }); + } +}); +*/ + +export default App; diff --git a/ember/app/components/.gitkeep b/ember/app/components/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ember/app/components/button-item.js b/ember/app/components/button-item.js new file mode 100644 index 000000000..687ac852f --- /dev/null +++ b/ember/app/components/button-item.js @@ -0,0 +1,18 @@ +import Ember from 'ember'; + +export default Ember.View.extend({ + title: '', + icon: '', + class: '', + action: null, + + tagName: 'a', + classNames: ['btn'], + classNameBindings: ['class', 'disabled'], + + layout: Ember.Handlebars.compile('{{#if view.icon}}{{fa-icon view.icon class="fa-fw"}} {{/if}}{{view.title}}'), + + click: function() { + this.action(); + } +}); diff --git a/ember/app/components/item-collection.js b/ember/app/components/item-collection.js new file mode 100644 index 000000000..a9a01a5cf --- /dev/null +++ b/ember/app/components/item-collection.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: 'ul', + layoutName: 'components/item-collection', +}); diff --git a/ember/app/components/loading-indicator.js b/ember/app/components/loading-indicator.js new file mode 100644 index 000000000..6da887851 --- /dev/null +++ b/ember/app/components/loading-indicator.js @@ -0,0 +1,13 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + + classNames: ['loading'], + + layout: Ember.Handlebars.compile(' '), + + didInsertElement: function() { + this.$().spin(this.get('size')); + } + +}); diff --git a/ember/app/components/menu-item-separator.js b/ember/app/components/menu-item-separator.js new file mode 100644 index 000000000..ec7c4932c --- /dev/null +++ b/ember/app/components/menu-item-separator.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + liClass: 'divider' +}); diff --git a/ember/app/components/menu-item.js b/ember/app/components/menu-item.js new file mode 100644 index 000000000..b80d608eb --- /dev/null +++ b/ember/app/components/menu-item.js @@ -0,0 +1,31 @@ +import Ember from 'ember'; + +var MenuItem = Ember.Component.extend({ + title: '', + icon: '', + className: '', + action: null, + divider: false, + + tagName: 'a', + attributeBindings: ['href'], + classNameBindings: ['className'], + href: '#', + layout: Ember.Handlebars.compile('{{#if icon}}{{fa-icon icon class="fa-fw"}} {{/if}}{{title}}'), + + click: function(e) { + e.preventDefault(); + // this.sendAction('action'); + this.get('action')(); + } +}); + +MenuItem.reopenClass({ + separator: function() { + return this.create({ + divider: true + }); + } +}) + +export default MenuItem; diff --git a/ember/app/components/menu-list.js b/ember/app/components/menu-list.js new file mode 100644 index 000000000..c059697a4 --- /dev/null +++ b/ember/app/components/menu-list.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + tagName: 'ul', + layoutName: 'components/menu-list', +}); diff --git a/ember/app/components/menu-split.js b/ember/app/components/menu-split.js new file mode 100644 index 000000000..cddbcf4dc --- /dev/null +++ b/ember/app/components/menu-split.js @@ -0,0 +1,15 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + items: null, // NamedContainerView/Menu + layoutName: 'components/menu-split', + show: 1, + + visibleItems: function() { + return this.get('items').slice(0, this.get('show')); + }.property('items'), + + hiddenItems: function() { + return this.get('items').slice(this.get('show')); + }.property('items'), +}); diff --git a/ember/app/components/nav-item.js b/ember/app/components/nav-item.js new file mode 100644 index 000000000..6528dffd8 --- /dev/null +++ b/ember/app/components/nav-item.js @@ -0,0 +1,34 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + icon: '', + title: '', + action: null, + badge: '', + badgeAction: null, + // active: false, + + tagName: 'li', + classNameBindings: ['active'], + active: function() { + return this.get('childViews').anyBy('active'); + }.property('childViews.@each.active'), + + layout: function() { + return Ember.Handlebars.compile('{{badge}}\ + {{#link-to '+this.get('linkTo')+'}}'+this.get('iconTemplate')+'{{title}}{{/link-to}}'); + }.property('linkTo', 'iconTemplate'), + + iconTemplate: function() { + return '{{fa-icon icon}}'; + }.property(), + + actions: { + main: function() { + this.get('action')(); + }, + badge: function() { + this.get('badgeAction')(); + } + } +}); diff --git a/ember/app/components/notification-message.js b/ember/app/components/notification-message.js new file mode 100755 index 000000000..b9fd2042e --- /dev/null +++ b/ember/app/components/notification-message.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + + close: function() { + this.sendAction('closeAction'); + } + +}); diff --git a/ember/app/components/search-input.js b/ember/app/components/search-input.js new file mode 100644 index 000000000..dea3d1987 --- /dev/null +++ b/ember/app/components/search-input.js @@ -0,0 +1,38 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + classNames: ['search-input'], + classNameBindings: ['active', 'value:clearable'], + + didInsertElement: function() { + var self = this; + this.$().find('input').on('keydown', function(e) { + if (e.which == 27) { + self.clear(); + } + }); + this.$().find('.clear').on('mousedown', function(e) { + e.preventDefault(); + }).on('click', function(e) { + e.preventDefault(); + self.clear(); + }) + }, + + clear: function() { + this.set('value', ''); + this.sendAction('action', ''); + this.$().find('input').focus(); + }, + + willDestroyElement: function() { + this.$().find('input').off('keydown'); + this.$().find('.clear').off('mousedown click'); + }, + + actions: { + search: function() { + this.sendAction('action', this.get('value')); + } + } +}); diff --git a/ember/app/components/select-input.js b/ember/app/components/select-input.js new file mode 100644 index 000000000..d01c5e7ff --- /dev/null +++ b/ember/app/components/select-input.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.View.extend({ + + tagName: 'span', + classNames: ['select'], + layout: Ember.Handlebars.compile('{{view Ember.Select content=view.content optionValuePath=view.optionValuePath optionLabelPath=view.optionLabelPath value=view.value}} {{fa-icon "sort"}}') + +}); diff --git a/ember/app/controllers/.gitkeep b/ember/app/controllers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ember/app/controllers/application.js b/ember/app/controllers/application.js new file mode 100644 index 000000000..c5b925c80 --- /dev/null +++ b/ember/app/controllers/application.js @@ -0,0 +1,48 @@ +import Ember from 'ember'; + +// import NotificationMessage from '../models/notification-message'; + +export default Ember.Controller.extend({ + + needs: ['discussions'], + + // The title of the forum. + // TODO: Preload this value in the index.html payload from Laravel config. + forumTitle: 'Ninetech Support Forum', + // forumTitle: ' TV Addicts', + // forumTitle: '', + // forumTitle: '  Med Students Forum', + pageTitle: '', + documentTitle: function() { + return this.get('pageTitle') || this.get('forumTitle'); + }.property('pageTitle', 'forumTitle'), + + _updateTitle: function() { + var parts = [this.get('forumTitle')]; + var pageTitle = this.get('pageTitle'); + if (pageTitle) parts.unshift(pageTitle); + document.title = parts.join(' - '); + }.observes('pageTitle', 'forumTitle'), + + searchQuery: '', + searchActive: false, + + showDiscussionStream: false, + + // notificationMessage: NotificationMessage.create({text: 'Sorry, you do not have permission to do that!', class: 'message-warning'}), // currently displaying notification message object + + currentUser: null, + + actions: { + + hideMessage: function() { + this.set('notificationMessage', null); + }, + + search: function(query) { + this.transitionToRoute('discussions', {queryParams: {searchQuery: query, sort: query ? 'relevance' : 'recent'}}); + }, + + } + +}); diff --git a/ember/app/controllers/composer.js b/ember/app/controllers/composer.js new file mode 100644 index 000000000..199a258fa --- /dev/null +++ b/ember/app/controllers/composer.js @@ -0,0 +1,17 @@ +import Ember from 'ember'; + +export default Ember.Controller.extend({ + + needs: ['discussions'], + + showing: false, + + title: 'Replying to Some Discussion Title', + + actions: { + close: function() { + this.set('showing', false); + } + } + +}); diff --git a/ember/app/controllers/discussion.js b/ember/app/controllers/discussion.js new file mode 100644 index 000000000..79dc86e27 --- /dev/null +++ b/ember/app/controllers/discussion.js @@ -0,0 +1,103 @@ +import Ember from 'ember'; + +import PostStream from '../models/post-stream'; + +export default Ember.ObjectController.extend(Ember.Evented, { + + needs: ['application', 'composer'], + + queryParams: ['start'], + start: '1', + searchQuery: '', + + loaded: false, + postStream: null, + + setup: function(discussion) { + this.set('model', discussion); + + // Set up the post stream object. It needs to know about the discussion + // its representing the posts for, and we also need to inject the Ember + // data store. + var postStream = PostStream.create(); + postStream.set('discussion', discussion); + postStream.set('store', this.get('store')); + this.set('postStream', postStream); + + // Next, we need to load a list of the discussion's post IDs into the + // post stream object. If we don't already have this information, we'll + // need to reload the discussion model. + var promise = discussion.get('posts') ? Ember.RSVP.resolve(discussion) : discussion.reload(); + + // When we know we have the post IDs, we can set up the post stream with + // them. Then we're ready to load some posts! + var controller = this; + promise.then(function(discussion) { + postStream.setup(discussion.get('postIds')); + controller.set('loaded', true); + controller.send('jumpToNumber', controller.get('start')); + }); + }, + + actions: { + + reply: function() { + this.set('controllers.composer.showing', true); + this.set('controllers.composer.title', 'Replying to '+this.get('model.title')+''); + }, + + jumpToNumber: function(number) { + // In some instances, we might be given a placeholder start index + // value. We need to convert this into a numerical value. + switch (number) { + case 'last': + number = this.get('model.lastPostNumber'); + break; + + case 'unread': + number = this.get('model.readNumber') + 1; + break; + } + + number = Math.max(number, 1); + + // Let's start by telling our listeners that we're going to load + // posts near this number. The discussion view will listen and + // consequently scroll down to the appropriate position in the + // discussion. + this.trigger('loadingNumber', number); + + // 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, the view will scroll down to the appropriate post. + var controller = this; + this.get('postStream').loadNearNumber(number).then(function() { + Ember.run.scheduleOnce('afterRender', function() { + controller.trigger('loadedNumber', number); + }); + }); + }, + + jumpToIndex: function(index) { + // Let's start by telling our listeners that we're going to load + // posts at this index. The discussion view will listen and + // consequently scroll down to the appropriate position in the + // discussion. + this.trigger('loadingIndex', index); + + // Now we have to actually make sure the posts around this index are + // loaded. We will tell our listeners when they are. Again, the view + // will scroll down to the appropriate post. + var controller = this; + this.get('postStream').loadNearIndex(index).then(function() { + Ember.run.scheduleOnce('afterRender', function() { + controller.trigger('loadedIndex', index); + }); + }); + }, + + loadRange: function(start, end, backwards) { + this.get('postStream').loadRange(start, end, backwards); + } + } +}); diff --git a/ember/app/controllers/discussions.js b/ember/app/controllers/discussions.js new file mode 100644 index 000000000..2083083c6 --- /dev/null +++ b/ember/app/controllers/discussions.js @@ -0,0 +1,175 @@ +import Ember from 'ember'; + +import DiscussionResult from '../models/discussion-result'; +import PostResult from '../models/post-result'; + +export default Ember.ArrayController.extend(Ember.Evented, { + + needs: ['application', 'composer'], + + paned: false, + paneShowing: false, + paneTimeout: null, + panePinned: false, + + current: null, + + index: function() { + var index = '?'; + var id = this.get('current.id'); + this.get('model').some(function(result, i) { + if (result.get('id') == id) { + index = i + 1; + return true; + } + }); + return index; + }.property('current', 'model.@each'), + + count: function() { + return this.get('model.length'); + }.property('model.@each'), + + previous: function() { + var result = this.get('model').objectAt(this.get('index') - 2); + return result && result.get('content'); + }.property('index'), + + next: function() { + var result = this.get('model').objectAt(this.get('index')); + return result && result.get('content'); + }.property('index'), + + queryParams: ['sort', 'show', {searchQuery: 'q'}, 'filter'], + sort: 'recent', + show: 'discussions', + filter: '', + + searchQuery: '', + loadingMore: false, + + sortOptions: [ + {sort: 'recent', label: 'Recent'}, + {sort: 'replies', label: 'Replies'}, + {sort: 'newest', label: 'Newest'}, + {sort: 'oldest', label: 'Oldest'}, + ], + + displayStartUsers: function() { + return ['newest', 'oldest'].indexOf(this.get('sort')) != -1; + }.property('sort'), + + discussionsCount: function() { + return this.get('model.length'); + }.property('@each'), + + resultsLoading: false, + + start: 0, + + moreResults: function() { + return !! this.get('meta.moreUrl'); + }.property('meta.moreUrl'), + + meta: null, + + getResults: function(start) { + var sort = this.get('sort'); + // var order = this.get('order'); + var order; + var show = this.get('show'); + var searchQuery = this.get('searchQuery'); + + if (sort == 'newest') sort = 'created'; + else if (sort == 'oldest') { + sort = 'created'; + order = 'asc'; + } + else if (sort == 'recent') { + sort = ''; + } + else if (sort == 'replies') { + order = 'desc'; + } + + var params = { + sort: (order == 'desc' ? '-' : '')+sort, + q: searchQuery, + start: start + }; + + if (show == 'posts') { + if (searchQuery) params.include = 'relevantPosts'; + else if (sort == 'created') params.include = 'startPost,startUser'; + else params.include = 'lastPost,lastUser'; + } + + return this.store.find('discussion', params).then(function(discussions) { + var results = Em.A(); + discussions.forEach(function(discussion) { + var relevantPosts = Em.A(); + 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; + }); + }, + + actions: { + showDiscussionPane: function() { + this.set('paneShowing', true); + }, + + hideDiscussionPane: function() { + this.set('paneShowing', false); + }, + + togglePinned: function() { + this.set('panePinned', ! this.get('panePinned')); + }, + + loadMore: function() { + var self = this; + this.set('start', this.get('length')); + this.set('loadingMore', true); + + this.getResults(this.get('start')).then(function(results) { + self.get('model').addObjects(results); + self.set('meta', results.get('meta')); + // self.set('moreResults', !! results.get('meta.moreUrl')); + self.set('loadingMore', false); + }); + }, + + delete: function(discussion) { + alert('are you sure you want to delete discusn: '+discussion.get('title')); + } + }, + + queryDidChange: function(q) { + this.get('controllers.application').set('searchQuery', this.get('searchQuery')); + this.get('controllers.application').set('searchActive', !! this.get('searchQuery')); + + var sortOptions = this.get('sortOptions'); + + if (this.get('searchQuery') && sortOptions[0].sort != 'relevance') { + sortOptions.unshiftObject({sort: 'relevance', label: 'Relevance'}); + } + else if ( ! this.get('searchQuery') && sortOptions[0].sort == 'relevance') { + sortOptions.shiftObject(); + } + }.observes('searchQuery'), + + paramsDidChange: function(show) { + this.set('start', 0); + }.observes('show', 'sort', 'searchQuery') + +}); diff --git a/ember/app/controllers/discussions/index.js b/ember/app/controllers/discussions/index.js new file mode 100644 index 000000000..11f03b553 --- /dev/null +++ b/ember/app/controllers/discussions/index.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.ArrayController.extend({ + needs: ['application', 'composer'] +}); diff --git a/ember/app/controllers/login.js b/ember/app/controllers/login.js new file mode 100644 index 000000000..b7ffe1407 --- /dev/null +++ b/ember/app/controllers/login.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +// import NotificationMessage from '../models/notification-message'; + +export default Ember.Controller.extend(Ember.SimpleAuth.LoginControllerMixin, Ember.Evented, { + + authenticatorFactory: 'authenticator:flarum' + +}); diff --git a/ember/app/helpers/.gitkeep b/ember/app/helpers/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ember/app/helpers/abbreviate-number.js b/ember/app/helpers/abbreviate-number.js new file mode 100644 index 000000000..82e6c0d97 --- /dev/null +++ b/ember/app/helpers/abbreviate-number.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; + +export default Ember.Handlebars.makeBoundHelper(function(number, options) { + return new Handlebars.SafeString(number); +}); + diff --git a/ember/app/helpers/abbreviate-time.js b/ember/app/helpers/abbreviate-time.js new file mode 100644 index 000000000..2e916e6fd --- /dev/null +++ b/ember/app/helpers/abbreviate-time.js @@ -0,0 +1,31 @@ +import Ember from 'ember'; + +export default Ember.Handlebars.makeBoundHelper(function(time) { + var m = moment(time); + var datetime = m.format(), + full = m.format('LLLL'); + + var second = 1e3; + var minute = 6e4; + var hour = 36e5; + var day = 864e5; + var week = 6048e5; + var ago = null; + + var diff = Math.abs(m.diff(moment())); + + if (diff < 60 * minute) { + ago = moment.duration(diff).minutes()+'m'; + } else if (diff < 24 * hour) { + ago = moment.duration(diff).hours()+'h'; + } else if (diff < 30 * day) { + ago = moment.duration(diff).days()+'d'; + } else if (m.year() == moment().year()) { + ago = m.format('D MMM'); + } else { + ago = m.format('MMM \'YY'); + } + + return new Handlebars.SafeString(''); +}); + diff --git a/ember/app/helpers/fa-icon.js b/ember/app/helpers/fa-icon.js new file mode 100644 index 000000000..b1b464acc --- /dev/null +++ b/ember/app/helpers/fa-icon.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; + +export default Ember.Handlebars.makeBoundHelper(function(icon, options) { + return new Handlebars.SafeString(''); +}); + diff --git a/ember/app/helpers/highlight-words.js b/ember/app/helpers/highlight-words.js new file mode 100644 index 000000000..298c37d73 --- /dev/null +++ b/ember/app/helpers/highlight-words.js @@ -0,0 +1,18 @@ +import Ember from 'ember'; + +export default Ember.Handlebars.makeBoundHelper(function(text, phrase, options) { + if (phrase) { + var words = phrase.split(' '); + var replacement = function(matched) { + return ''+matched+''; + }; + words.forEach(function(word) { + text = text.replace( + new RegExp("\\b"+word+"\\b", 'gi'), + replacement + ); + }); + } + return new Handlebars.SafeString(text); +}); + diff --git a/ember/app/helpers/post-content.js b/ember/app/helpers/post-content.js new file mode 100644 index 000000000..d9cabee8e --- /dev/null +++ b/ember/app/helpers/post-content.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; + +// This helper takes a post as its argument and renders a certain component +// corresponding to the post's type. The naming convention is 'post-type-[type]' +// (for example, post-type-comment for a comment.) Other arguments added to the +// helper are passed through to the component. + +export default Ember.Handlebars.makeBoundHelper(function(post, options) { + options.hash.post = post; + var component = 'post-type-'+post.get('type'); + var helper = Ember.Handlebars.resolveHelper(options.data.view.container, component); + + helper.call(this, options); +}); diff --git a/ember/app/helpers/render-hook.js b/ember/app/helpers/render-hook.js new file mode 100644 index 000000000..3315962e7 --- /dev/null +++ b/ember/app/helpers/render-hook.js @@ -0,0 +1,6 @@ +import Ember from 'ember'; + +export default Ember.Handlebars.makeBoundHelper(function(name, options) { + return new Handlebars.SafeString(''); +}); + diff --git a/ember/app/helpers/user-avatar.js b/ember/app/helpers/user-avatar.js new file mode 100644 index 000000000..a06a4c59c --- /dev/null +++ b/ember/app/helpers/user-avatar.js @@ -0,0 +1,53 @@ +import Ember from 'ember'; + +function HSVtoRGB(h, s, v) { + var r, g, b, i, f, p, q, t; + if (h && s === undefined && v === undefined) { + s = h.s, v = h.v, h = h.h; + } + i = Math.floor(h * 6); + f = h * 6 - i; + p = v * (1 - s); + q = v * (1 - f * s); + t = v * (1 - (1 - f) * s); + switch (i % 6) { + case 0: r = v, g = t, b = p; break; + case 1: r = q, g = v, b = p; break; + case 2: r = p, g = v, b = t; break; + case 3: r = p, g = q, b = v; break; + case 4: r = t, g = p, b = v; break; + case 5: r = v, g = p, b = q; break; + } + return { + r: Math.floor(r * 255), + g: Math.floor(g * 255), + b: Math.floor(b * 255) + }; +} + +export default Ember.Handlebars.makeBoundHelper(function(user, options) { + if (!user) return; + + var number; + if (number = user.get('avatarNumber')) { + number = number + ''; + var filename = number.length >= 3 ? number : new Array(3 - number.length + 1).join('0') + number; + return new Handlebars.SafeString(''); + } + + var username = user.get('username'); + if (!username) username = '?'; + + var letter = username.charAt(0).toUpperCase(); + + var num = 0; + for (var i = 0; i < username.length; i++) { + num += username.charCodeAt(i) * 13; + } + + var hue = num % 360; + var rgb = HSVtoRGB(hue / 360, 100 / 255, 200 / 255); + var bg = ''+rgb.r.toString(16)+rgb.g.toString(16)+rgb.b.toString(16); + return new Handlebars.SafeString(''+letter+''); +}); + diff --git a/ember/app/index.html b/ember/app/index.html new file mode 100644 index 000000000..af2b75a21 --- /dev/null +++ b/ember/app/index.html @@ -0,0 +1,26 @@ + + + + + + Flarum + + + + {{BASE_TAG}} + + + + + + + + + + + diff --git a/ember/app/mixins/post-stream.js b/ember/app/mixins/post-stream.js new file mode 100644 index 000000000..bce952d97 --- /dev/null +++ b/ember/app/mixins/post-stream.js @@ -0,0 +1,33 @@ +export default Ember.Mixin.create({ + // 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 container the requested post. + findNearestToNumber: function(number) { + var nearestItem = $(); + $('.posts .item').each(function() { + var $this = $(this), + thisNumber = $this.data('number'); + if (thisNumber > number) { + return false; + } + nearestItem = $this; + }); + return nearestItem; + }, + + findNearestToIndex: function(index) { + var nearestItem = $('.posts .item[data-start='+index+'][data-end='+index+']'); + + if (! nearestItem.length) { + $('.posts .item').each(function() { + var $this = $(this); + if ($this.data('start') <= index && $this.data('end') >= index) { + nearestItem = $this; + return false; + } + }); + } + + return nearestItem; + } +}); diff --git a/ember/app/models/.gitkeep b/ember/app/models/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ember/app/models/discussion-result.js b/ember/app/models/discussion-result.js new file mode 100644 index 000000000..94f9e604f --- /dev/null +++ b/ember/app/models/discussion-result.js @@ -0,0 +1,12 @@ +import Ember from 'ember'; + +var DiscussionResult = Ember.ObjectProxy.extend({ + + relevantPosts: Em.A(), + + startPost: null, + lastPost: null + +}); + +export default DiscussionResult; diff --git a/ember/app/models/discussion-state.js b/ember/app/models/discussion-state.js new file mode 100644 index 000000000..d2cb576c6 --- /dev/null +++ b/ember/app/models/discussion-state.js @@ -0,0 +1,7 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +export default DS.Model.extend({ + readTime: DS.attr('date'), + readNumber: DS.attr('number') +}); diff --git a/ember/app/models/discussion.js b/ember/app/models/discussion.js new file mode 100644 index 000000000..fb9fdd6d6 --- /dev/null +++ b/ember/app/models/discussion.js @@ -0,0 +1,81 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +var Discussion = DS.Model.extend({ + + title: DS.attr('string'), + + slug: function() { + return this.get('title').toLowerCase().replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-'); + }.property('title'), + + canReply: DS.attr('boolean'), + canEdit: DS.attr('boolean'), + canDelete: DS.attr('boolean'), + + 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'), + + relevantPosts: DS.hasMany('post'), + + postsCount: DS.attr('number'), + repliesCount: function() { + return Math.max(0, this.get('postsCount') - 1); + }.property('postsCount'), + + posts: DS.attr('string'), + postIds: function() { + return this.get('posts').split(','); + }.property('posts'), + + readNumber: DS.attr('number'), + unreadCount: function() { + return this.get('lastPostNumber') - this.get('readNumber'); + }.property('lastPostNumber', 'readNumber'), + + //-------------------------------- + // Prototype generated properties + + // category: function() { + // var categories = [null, 'Announcements', 'General', 'Support', 'Feedback', 'Core', 'Plugins', 'Themes']; + // return categories[Math.floor(Math.random() * categories.length)]; + // }.property(), + category: DS.attr('string'), + + _recent: function() { + var cutoff = new Date('September 19, 2014'); + return this.get('lastTime') > cutoff; + }.property('lastTime'), + + unread: function() { + return Math.round(Math.random() * (this.get('_recent') ? 0.8 : 0) * this.get('postsCount')); + }.property(), + + // sticky: function() { + // return Math.random() > (this.get('_recent') ? 0.95 : 0.99); + // }.property(), + sticky: DS.attr('boolean'), + + excerpt: function() { + // return 'I want to get your thoughts on this one TV Addicts: what new show have you been getting into this year, and why?'; + // return 'Here\'s the near-final game list, in no particular order. The list may be subject to amendments, as we\'re still chasing up copies of some games.'; + // return 'Nominating for the Annual General Meeting is easy. Read this to find out how.' + return 'There are many apps made with Ninetech in the Mac App Store. If you\'d like, take a moment to share your Nintech-made apps in this thread.'; + }.property(), + + locked: function() { + return Math.random() > 0.95; + }.property(), + + following: function() { + return Math.random() > 0.95; + }.property() +}); + +export default Discussion; diff --git a/ember/app/models/group.js b/ember/app/models/group.js new file mode 100644 index 000000000..35c19344a --- /dev/null +++ b/ember/app/models/group.js @@ -0,0 +1,10 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +export default DS.Model.extend({ + + name: DS.attr('string'), + + users: DS.hasMany('group'), + +}); diff --git a/ember/app/models/post-result.js b/ember/app/models/post-result.js new file mode 100644 index 000000000..b343270c8 --- /dev/null +++ b/ember/app/models/post-result.js @@ -0,0 +1,20 @@ +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/app/models/post-stream.js b/ember/app/models/post-stream.js new file mode 100644 index 000000000..c4d19fd7d --- /dev/null +++ b/ember/app/models/post-stream.js @@ -0,0 +1,201 @@ +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: Em.A(), + content: Em.A(), + store: null, + discussion: null, + + postLoadCount: 20, + + _init: function() { + this.clear(); + }.on('init'), + + setup: function(ids) { + this.set('ids', ids); + this.clear(); + }, + + count: function() { + return this.get('ids.length'); + }.property('ids'), + + firstLoaded: function() { + var first = this.objectAt(0); + return first && ! first.gap; + }.property('content.@each'), + + lastLoaded: function() { + var last = this.objectAt(this.get('length') - 1); + return last && ! last.gap; + }.property('content.@each'), + + // Clear the contents of the post stream, resetting it to one big gap. + clear: function() { + var stream = this.get('content'); + stream.enumerableContentWillChange(); + stream.clear().pushObject(Em.Object.create({ + gap: true, + indexStart: 0, + indexEnd: this.get('count') - 1, + loading: true + })); + stream.enumerableContentDidChange(); + }, + + loadRange: function(start, end, backwards) { + var limit = this.get('postLoadCount'); + end = end || start + limit; + + // 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.gap && ( + (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('post.number') == number) { + return Ember.RSVP.resolve([item.get('post')]); + } else if (item.gap) { + item.set('direction', 'down').set('loading', true); + } + } + + var stream = this; + return this.store.find('post', { + discussions: this.get('discussion.id'), + near: number + }).then(function(posts) { + stream.addPosts(posts); + }); + }, + + loadNearIndex: function(index) { + // 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.gap) { + return Ember.RSVP.resolve([item.get('post')]); + } else { + item.set('direction', 'down').set('loading', true); + } + return this.loadRange(Math.max(item.indexStart, index - 10), item.indexEnd); + } + + return Ember.RSVP.reject(); + }, + + addPosts: function(posts) { + this.trigger('postsLoaded', posts); + + var stream = this; + posts.forEach(function(post) { + stream.addPost(post); + }); + + this.trigger('postsAdded'); + }, + + addPost: function(post) { + var stream = this; + var index = this.get('ids').indexOf(post.get('id')); + var content = this.get('content'); + + // 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(Ember.Object.create({ + gap: true, + indexStart: item.indexStart, + indexEnd: index - 1 + })); + } + newItems.push(Ember.Object.create({ + indexStart: index, + indexEnd: index, + post: post + })); + if (item.indexEnd > index) { + newItems.push(Ember.Object.create({ + gap: true, + indexStart: index + 1, + indexEnd: item.indexEnd + })); + } + content.enumerableContentWillChange(); + content.replace(i, 1, newItems); + content.enumerableContentDidChange(); + return true; + } + }); + }, + + findNearestToNumber: function(number) { + var nearestItem; + this.get('content').some(function(item) { + var thisNumber = item.get('post.number'); + if (thisNumber > number) { + return true; + } + nearestItem = item; + }); + return nearestItem; + }, + + findNearestToIndex: function(index) { + var nearestItem; + this.get('content').some(function(item) { + if (item.indexStart <= index && item.indexEnd >= index) { + nearestItem = item; + return true; + } + }); + return nearestItem; + } + +}); diff --git a/ember/app/models/post.js b/ember/app/models/post.js new file mode 100644 index 000000000..cab32437c --- /dev/null +++ b/ember/app/models/post.js @@ -0,0 +1,37 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +export default DS.Model.extend({ + + discussion: DS.belongsTo('discussion', {inverse: 'actualPosts'}), + number: DS.attr('number'), + + time: DS.attr('string'), + user: DS.belongsTo('user'), + type: DS.attr('string'), + content: DS.attr('string'), + contentHtml: DS.attr('string'), + + editTime: DS.attr('string'), + editUser: DS.belongsTo('user'), + edited: Ember.computed.notEmpty('editTime'), + + deleteTime: DS.attr('string'), + deleteUser: DS.belongsTo('user'), + deleted: Ember.computed.notEmpty('deleteTime'), + + replyTo: DS.belongsTo('post', {inverse: 'replies'}), + replyToNumber: DS.attr('number'), + replyToUser: DS.belongsTo('user'), + + replies: DS.hasMany('post', {inverse: 'replyTo'}), + repliesCount: DS.attr('number'), + + canEdit: DS.attr('boolean'), + canDelete: DS.attr('boolean'), + + likes: function() { + return Math.floor(Math.random() * (Math.random() < 0.3 ? 10 : 1)); + }.property() + +}); diff --git a/ember/app/models/result-stream.js b/ember/app/models/result-stream.js new file mode 100644 index 000000000..7555f1d77 --- /dev/null +++ b/ember/app/models/result-stream.js @@ -0,0 +1,44 @@ +import Ember from 'ember'; + +// Represents a collection of results (e.g. a list of discussions) + +export default Ember.Object.extend({ + + // An array of the results. + results: Em.A(), + + // The currently-active result. + currentResult: null, + + sort: null, + + // The index of the currently-active result (determined by ID.) Returns '?' + // if the currently-active result is not in the results list. + index: function() { + var index = '?'; + var id = this.get('currentResult.id'); + this.get('results').some(function(result, i) { + if (result.get('id') == id) { + index = i + 1; + return true; + } + }); + return index; + }.property('currentResult', 'results'), + + // The number of results. + count: function() { + return this.get('results.length'); + }.property('results'), + + // The previous result. + previous: function() { + return this.get('results').objectAt(this.get('index') - 2); + }.property('index'), + + // The next result. + next: function() { + return this.get('results').objectAt(this.get('index')); + }.property('index'), + +}); diff --git a/ember/app/models/user.js b/ember/app/models/user.js new file mode 100644 index 000000000..991c9bb7a --- /dev/null +++ b/ember/app/models/user.js @@ -0,0 +1,21 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +export default DS.Model.extend({ + + username: DS.attr('string'), + avatarUrl: DS.attr('string'), + joinTime: DS.attr('date'), + lastSeenTime: DS.attr('date'), + discussionsCount: DS.attr('number'), + postsCount: DS.attr('number'), + + canEdit: DS.attr('boolean'), + canDelete: DS.attr('boolean'), + + groups: DS.hasMany('group'), + + avatarNumber: function() { + return Math.random() > 0.3 ? Math.floor(Math.random() * 19) + 1 : null; + }.property() +}); diff --git a/ember/app/router.js b/ember/app/router.js new file mode 100644 index 000000000..12a6ce560 --- /dev/null +++ b/ember/app/router.js @@ -0,0 +1,24 @@ +import Ember from 'ember'; + +var Router = Ember.Router.extend({ + location: FlarumENV.locationType +}); + +Router.map(function() { + + this.resource('categories', { path: '/categories' }); + + this.resource('discussions', { path: '/' }, function() { + this.resource('discussion', { path: '/:id/:slug' }); + }); + + this.resource('user', { path: '/user/:username' }, function() { + this.route('activity'); + this.route('posts'); + this.route('discussions'); + this.route('preferences'); + }); + +}); + +export default Router; diff --git a/ember/app/routes/.gitkeep b/ember/app/routes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ember/app/routes/application.js b/ember/app/routes/application.js new file mode 100644 index 000000000..17c3eea4a --- /dev/null +++ b/ember/app/routes/application.js @@ -0,0 +1,28 @@ +import Ember from 'ember'; +import ApplicationRouteMixin from 'simple-auth/mixins/application-route-mixin'; + +export default Ember.Route.extend(ApplicationRouteMixin, { + + actions: { + + login: function() { + return this.render('login', { + into: 'application', + outlet: 'modal' + }); + }, + + doLogin: function() { + this.get('session').authenticate('authenticator:custom', {}); + }, + + closeModal: function() { + return this.disconnectOutlet({ + outlet: 'modal', + parentView: 'application' + }); + } + + } + +}); diff --git a/ember/app/routes/discussion.js b/ember/app/routes/discussion.js new file mode 100644 index 000000000..d67ae363d --- /dev/null +++ b/ember/app/routes/discussion.js @@ -0,0 +1,64 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + + queryParams: { + start: {replace: true} + }, + + renderTemplate: function() { + this.render(); + this.render('discussion-sidebar', { + into: 'application', + outlet: 'sidebar' + }); + }, + + model: function(params) { + return this.store.find('discussion', params.id); + }, + + 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('postStream', null); + }, + + setupController: function(controller, model) { + controller.setup(model); + + this.controllerFor('application').set('showDiscussionStream', true); + this.controllerFor('discussions').set('paned', true); + this.controllerFor('discussions').set('current', model); + }, + + actions: { + + queryParamsDidChange: function(params) { + // We're only interested in changes to the ?start param, and we're + // not interested if nothing has actually changed. If the start + // param has changed, we want to tell the controller to load posts + // near it. + if (! params.start || params.start == this.get('controller.start') || ! this.get('controller.loaded')) { + return; + } + this.get('controller').send('jumpToNumber', params.start); + }, + + willTransition: function(transition) { + // If we're going to transition out, we need to abort any unfinished + // AJAX requests. We need to do this because sometimes a transition + // to another discussion will happen very rapidly (i.e. when using + // the arrow buttons on the result stream.) If a previous + // discussion's posts finish loading while displaying a new + // discussion, strange things will happen. + this.store.adapterFor('discussion').xhr.forEach(function(xhr) { + xhr.abort(); + }); + } + + } +}); diff --git a/ember/app/routes/discussions.js b/ember/app/routes/discussions.js new file mode 100644 index 000000000..b3f55afa8 --- /dev/null +++ b/ember/app/routes/discussions.js @@ -0,0 +1,36 @@ +import Ember from 'ember'; +import Discussion from '../models/discussion'; + +export default Ember.Route.extend({ + + setupController: function(controller, model) { + controller.set('model', model); + + if ( ! model.get('length')) { + controller.set('resultsLoading', true); + + controller.getResults().then(function(results) { + controller + .set('resultsLoading', false) + .set('meta', results.get('meta')) + .set('model.content', results); + }); + } + }, + + model: function(params) { + var model = Ember.ArrayProxy.create(); + + return Ember.RSVP.resolve(model); + }, + + actions: { + queryParamsDidChange: function(newParams, params) { + var self = this; + Ember.run.scheduleOnce('afterRender', function() { + self.refresh(); + }); + } + } + +}); diff --git a/ember/app/routes/discussions/index.js b/ember/app/routes/discussions/index.js new file mode 100644 index 000000000..1cf728ba1 --- /dev/null +++ b/ember/app/routes/discussions/index.js @@ -0,0 +1,20 @@ +import Ember from 'ember'; + +export default Ember.Route.extend({ + + renderTemplate: function() { + this.render(); + this.render('discussions-sidebar', { + into: 'application', + outlet: 'sidebar' + }); + }, + + setupController: function(controller, model) { + this.controllerFor('discussions').set('paneShowing', false); + this.controllerFor('discussions').set('paned', false); + this.controllerFor('application').set('showDiscussionStream', false); + this._super(controller, model); + } + +}); diff --git a/ember/app/serializers/application.js b/ember/app/serializers/application.js new file mode 100644 index 000000000..52fa80497 --- /dev/null +++ b/ember/app/serializers/application.js @@ -0,0 +1,14 @@ +import Ember from 'ember'; +import DS from 'ember-data'; + +export default DS.JsonApiSerializer.extend({ + normalize: function(type, hash, property) { + var json = {}; + + for (var prop in hash) { + json[prop.camelize()] = hash[prop]; + } + + return this._super(type, json, property); +} +}); diff --git a/ember/app/styles/.gitkeep b/ember/app/styles/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ember/app/styles/app.less b/ember/app/styles/app.less new file mode 100644 index 000000000..c0ff2e3f5 --- /dev/null +++ b/ember/app/styles/app.less @@ -0,0 +1,14 @@ +@import "config.less"; + +@flarum-base: "flarum/"; +@bootstrap-base: "../../vendor/bootstrap/less/"; +@font-awesome-base: "../../vendor/font-awesome/less/"; + +@import "@{flarum-base}bootstrap/bootstrap.less"; +@import "@{font-awesome-base}font-awesome.less"; + +@fa-font-path: "../font-awesome/fonts"; + +@import "@{flarum-base}global.less"; +@import "@{flarum-base}discussions.less"; +@import "@{flarum-base}discussion.less"; diff --git a/ember/app/styles/config.less b/ember/app/styles/config.less new file mode 100644 index 000000000..3831f28cd --- /dev/null +++ b/ember/app/styles/config.less @@ -0,0 +1,32 @@ +// Default blue +@flarum-hue: 210; +@flarum-saturation: 30%; +@flarum-lightness: 90%; +@flarum-body-saturation: 30%; +@flarum-body-lightness: 99.99%; + +@flarum-primary-color: hsl(@flarum-hue, @flarum-saturation, 35%); + +@flarum-background-color: hsl(@flarum-hue, @flarum-saturation, @flarum-lightness); +@flarum-background-image: none; +@flarum-background-repeat: no-repeat; + +@flarum-bg-primary-color: contrast(@flarum-background-color, @flarum-primary-color, #fff); +@flarum-bg-secondary-color: contrast(@flarum-background-color, rgba(255, 255, 255, 0.1), darken(@flarum-background-color, 7%)); +@flarum-bg-text-color: @flarum-bg-primary-color; +@flarum-bg-link-color: @flarum-bg-primary-color; +@flarum-bg-muted-color: contrast(@flarum-background-color, rgba(255, 255, 255, 0.25), desaturate(darken(@flarum-bg-secondary-color, 25%), 10%)); + +// styles for a background image +// @flarum-bg-primary-color: rgba(255, 255, 255, 0.8); +// @flarum-bg-secondary-color: rgba(255, 255, 255, 0.2); +// @flarum-bg-text-color: @flarum-bg-primary-color; +// @flarum-bg-link-color: @flarum-bg-primary-color; +// @flarum-bg-muted-color: contrast(@flarum-background-color, lighten(@flarum-bg-secondary-color, 20%), darken(@flarum-bg-secondary-color, 20%)); + +@flarum-body-background-color: hsl(@flarum-hue, @flarum-body-saturation, @flarum-body-lightness); +@flarum-body-primary-color: contrast(@flarum-body-background-color, @flarum-primary-color, #fff); +@flarum-body-secondary-color: contrast(@flarum-body-background-color, lighten(@flarum-body-background-color, 13%), desaturate(darken(@flarum-body-background-color, 10%), 10%)); +@flarum-body-text-color: contrast(@flarum-body-background-color, #555, #fff); +@flarum-body-link-color: @flarum-body-primary-color; +@flarum-body-muted-color: contrast(@flarum-body-background-color, lighten(@flarum-body-secondary-color, 20%), darken(@flarum-body-secondary-color, 25%)); diff --git a/ember/app/styles/flarum/bootstrap/bootstrap.less b/ember/app/styles/flarum/bootstrap/bootstrap.less new file mode 100644 index 000000000..46b334bf0 --- /dev/null +++ b/ember/app/styles/flarum/bootstrap/bootstrap.less @@ -0,0 +1,49 @@ +// Core variables and mixins +@import "variables.less"; +@import "@{bootstrap-base}mixins.less"; + +// Reset +@import "@{bootstrap-base}normalize.less"; +@import "@{bootstrap-base}print.less"; + +// Core CSS +@import "@{bootstrap-base}scaffolding.less"; +@import "@{bootstrap-base}type.less"; +@import "@{bootstrap-base}code.less"; +@import "@{bootstrap-base}grid.less"; +@import "@{bootstrap-base}tables.less"; +@import "@{bootstrap-base}forms.less"; +@import "@{bootstrap-base}buttons.less"; + +// Components +@import "@{bootstrap-base}component-animations.less"; +// @import "@{bootstrap-base}glyphicons.less"; +@import "@{bootstrap-base}dropdowns.less"; +@import "@{bootstrap-base}button-groups.less"; +@import "@{bootstrap-base}input-groups.less"; +// @import "@{bootstrap-base}navs.less"; +// @import "@{bootstrap-base}navbar.less"; +// @import "@{bootstrap-base}breadcrumbs.less"; +@import "@{bootstrap-base}pagination.less"; +// @import "@{bootstrap-base}pager.less"; +// @import "@{bootstrap-base}labels.less"; +// @import "@{bootstrap-base}badges.less"; +// @import "@{bootstrap-base}jumbotron.less"; +// @import "@{bootstrap-base}thumbnails.less"; +// @import "@{bootstrap-base}alerts.less"; +@import "@{bootstrap-base}progress-bars.less"; +// @import "@{bootstrap-base}media.less"; +// @import "@{bootstrap-base}list-group.less"; +// @import "@{bootstrap-base}panels.less"; +// @import "@{bootstrap-base}wells.less"; +@import "@{bootstrap-base}close.less"; + +// Components w/ JavaScript +@import "@{bootstrap-base}modals.less"; +@import "@{bootstrap-base}tooltip.less"; +@import "@{bootstrap-base}popovers.less"; +// @import "@{bootstrap-base}carousel.less"; + +// Utility classes +@import "@{bootstrap-base}utilities.less"; +@import "@{bootstrap-base}responsive-utilities.less"; diff --git a/ember/app/styles/flarum/bootstrap/variables.less b/ember/app/styles/flarum/bootstrap/variables.less new file mode 100644 index 000000000..10fcaca40 --- /dev/null +++ b/ember/app/styles/flarum/bootstrap/variables.less @@ -0,0 +1,832 @@ +// +// Variables +// -------------------------------------------------- + + +//== Colors +// +//## Gray and brand colors for use across Bootstrap. + +@gray-darker: lighten(#000, 13.5%); // #222 +@gray-dark: lighten(#000, 20%); // #333 +@gray: lighten(#000, 33.5%); // #555 +@gray-light: lighten(#000, 60%); // #999 +@gray-lighter: lighten(#000, 93.5%); // #eee + +@brand-primary: @flarum-body-primary-color; // CHANGED +@brand-success: #5cb85c; +@brand-info: #5bc0de; +@brand-warning: #f0ad4e; +@brand-danger: #d9534f; + + +//== Scaffolding +// +// ## Settings for some of the most global styles. + +//** Background color for ``. +@body-bg: @flarum-background-color; // CHANGED +//** Global text color on ``. +@text-color: @flarum-bg-text-color; // CHANGED + +//** Global textual link color. +@link-color: @brand-primary; +//** Link hover color set via `darken()` function. +@link-hover-color: darken(@link-color, 15%); + + +//== Typography +// +//## Font, line-height, and color for body text, headings, and more. + +@font-family-sans-serif: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; // CHANGED +@font-family-serif: Georgia, "Times New Roman", Times, serif; +//** Default monospace fonts for ``, ``, and `
`.
+@font-family-monospace:   Menlo, Monaco, Consolas, "Courier New", monospace;
+@font-family-base:        @font-family-sans-serif;
+
+@font-size-base:          13px; // CHANGED
+@font-size-large:         ceil((@font-size-base * 1.25)); // ~18px
+@font-size-small:         ceil((@font-size-base * 0.85)); // ~12px
+
+@font-size-h1:            floor((@font-size-base * 2.6)); // ~36px
+@font-size-h2:            floor((@font-size-base * 2.15)); // ~30px
+@font-size-h3:            ceil((@font-size-base * 1.7)); // ~24px
+@font-size-h4:            ceil((@font-size-base * 1.25)); // ~18px
+@font-size-h5:            @font-size-base;
+@font-size-h6:            ceil((@font-size-base * 0.85)); // ~12px
+
+//** Unit-less `line-height` for use in components like buttons.
+@line-height-base:        1.538461538; // 20/13 CHANGED
+//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
+@line-height-computed:    floor((@font-size-base * @line-height-base)); // ~20px
+
+//** By default, this inherits from the ``.
+@headings-font-family:    inherit;
+@headings-font-weight:    500;
+@headings-line-height:    1.1;
+@headings-color:          inherit;
+
+
+//-- Iconography
+//
+//## Specify custom locations of the include Glyphicons icon font. Useful for those including Bootstrap via Bower.
+
+@icon-font-path:          "../fonts/";
+@icon-font-name:          "glyphicons-halflings-regular";
+@icon-font-svg-id:        "glyphicons_halflingsregular";
+
+//== Components
+//
+//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
+
+@padding-base-vertical:     9px; // CHANGED
+@padding-base-horizontal:   15px; // CHANGED
+
+@padding-large-vertical:    10px;
+@padding-large-horizontal:  16px;
+
+@padding-small-vertical:    5px;
+@padding-small-horizontal:  10px;
+
+@padding-xs-vertical:       1px;
+@padding-xs-horizontal:     5px;
+
+@line-height-large:         1.33;
+@line-height-small:         1.5;
+
+@border-radius-base:        4px;
+@border-radius-large:       6px;
+@border-radius-small:       3px;
+
+//** Global color for active items (e.g., navs or dropdowns).
+@component-active-color:    #fff;
+//** Global background color for active items (e.g., navs or dropdowns).
+@component-active-bg:       @brand-primary;
+
+//** Width of the `border` for generating carets that indicator dropdowns.
+@caret-width-base:          4px;
+//** Carets increase slightly in size for larger components.
+@caret-width-large:         5px;
+
+
+//== Tables
+//
+//## Customizes the `.table` component with basic values, each used across all table variations.
+
+//** Padding for ``s and ``s.
+@table-cell-padding:            8px;
+//** Padding for cells in `.table-condensed`.
+@table-condensed-cell-padding:  5px;
+
+//** Default background color used for all tables.
+@table-bg:                      transparent;
+//** Background color used for `.table-striped`.
+@table-bg-accent:               #f9f9f9;
+//** Background color used for `.table-hover`.
+@table-bg-hover:                #f5f5f5;
+@table-bg-active:               @table-bg-hover;
+
+//** Border color for table and cell borders.
+@table-border-color:            #ddd;
+
+
+//== Buttons
+//
+//## For each of Bootstrap's buttons, define text, background and border color.
+
+@btn-font-weight:                normal;
+
+@btn-default-color:              @flarum-body-primary-color; // CHANGED
+@btn-default-bg:                 darken(@flarum-body-secondary-color, 7%); // CHANGED
+@btn-default-border:             @btn-default-bg; // CHANGED
+
+@btn-primary-color:              contrast(@brand-primary, @flarum-body-secondary-color, #fff); // CHANGED
+@btn-primary-bg:                 @brand-primary;
+@btn-primary-border:             @btn-primary-bg; // CHANGED
+
+@btn-success-color:              #fff;
+@btn-success-bg:                 @brand-success;
+@btn-success-border:             @btn-success-bg; // CHANGED
+
+@btn-info-color:                 #fff;
+@btn-info-bg:                    @brand-info;
+@btn-info-border:                @btn-info-bg; // CHANGED
+
+@btn-warning-color:              #fff;
+@btn-warning-bg:                 @brand-warning;
+@btn-warning-border:             @btn-warning-bg; // CHANGED
+
+@btn-danger-color:               #fff;
+@btn-danger-bg:                  @brand-danger;
+@btn-danger-border:              @btn-danger-bg; // CHANGED
+
+@btn-link-disabled-color:        @gray-light;
+
+
+//== Forms
+//
+//##
+
+//** `` background color
+@input-bg:                       #fff;
+//** `` background color
+@input-bg-disabled:              @gray-lighter;
+
+//** Text color for ``s
+@input-color:                    @gray;
+//** `` border color
+@input-border:                   #ccc;
+//** `` border radius
+@input-border-radius:            @border-radius-base;
+//** Border color for inputs on focus
+@input-border-focus:             #66afe9;
+
+//** Placeholder text color
+@input-color-placeholder:        @gray-light;
+
+//** Default `.form-control` height
+@input-height-base:              (@line-height-computed + (@padding-base-vertical * 2) + 2);
+//** Large `.form-control` height
+@input-height-large:             (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
+//** Small `.form-control` height
+@input-height-small:             (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
+
+@legend-color:                   @gray-dark;
+@legend-border-color:            #e5e5e5;
+
+//** Background color for textual input addons
+@input-group-addon-bg:           @gray-lighter;
+//** Border color for textual input addons
+@input-group-addon-border-color: @input-border;
+
+
+//== Dropdowns
+//
+//## Dropdown menu container and contents.
+
+//** Background for the dropdown menu.
+@dropdown-bg:                    #fff;
+//** Dropdown menu `border-color`.
+@dropdown-border:                rgba(0,0,0,.15);
+//** Dropdown menu `border-color` **for IE8**.
+@dropdown-fallback-border:       #ccc;
+//** Divider color for between dropdown items.
+@dropdown-divider-bg:            #e5e5e5;
+
+//** Dropdown link text color.
+@dropdown-link-color:            @gray-dark;
+//** Hover color for dropdown links.
+@dropdown-link-hover-color:      darken(@gray-dark, 5%);
+//** Hover background for dropdown links.
+@dropdown-link-hover-bg:         #f5f5f5;
+
+//** Active dropdown menu item text color.
+@dropdown-link-active-color:     @component-active-color;
+//** Active dropdown menu item background color.
+@dropdown-link-active-bg:        @component-active-bg;
+
+//** Disabled dropdown menu item background color.
+@dropdown-link-disabled-color:   @gray-light;
+
+//** Text color for headers within dropdown menus.
+@dropdown-header-color:          @gray-light;
+
+// Note: Deprecated @dropdown-caret-color as of v3.1.0
+@dropdown-caret-color:           #000;
+
+
+//-- Z-index master list
+//
+// Warning: Avoid customizing these values. They're used for a bird's eye view
+// of components dependent on the z-axis and are designed to all work together.
+//
+// Note: These variables are not generated into the Customizer.
+
+@zindex-navbar:            1000;
+@zindex-dropdown:          1000;
+@zindex-popover:           1010;
+@zindex-tooltip:           1030;
+@zindex-navbar-fixed:      1030;
+@zindex-modal-background:  1040;
+@zindex-modal:             1050;
+
+
+//== Media queries breakpoints
+//
+//## Define the breakpoints at which your layout will change, adapting to different screen sizes.
+
+// Extra small screen / phone
+// Note: Deprecated @screen-xs and @screen-phone as of v3.0.1
+@screen-xs:                  480px;
+@screen-xs-min:              @screen-xs;
+@screen-phone:               @screen-xs-min;
+
+// Small screen / tablet
+// Note: Deprecated @screen-sm and @screen-tablet as of v3.0.1
+@screen-sm:                  768px;
+@screen-sm-min:              @screen-sm;
+@screen-tablet:              @screen-sm-min;
+
+// Medium screen / desktop
+// Note: Deprecated @screen-md and @screen-desktop as of v3.0.1
+@screen-md:                  992px;
+@screen-md-min:              @screen-md;
+@screen-desktop:             @screen-md-min;
+
+// Large screen / wide desktop
+// Note: Deprecated @screen-lg and @screen-lg-desktop as of v3.0.1
+@screen-lg:                  1200px;
+@screen-lg-min:              @screen-lg;
+@screen-lg-desktop:          @screen-lg-min;
+
+// So media queries don't overlap when required, provide a maximum
+@screen-xs-max:              (@screen-sm-min - 1);
+@screen-sm-max:              (@screen-md-min - 1);
+@screen-md-max:              (@screen-lg-min - 1);
+
+
+//== Grid system
+//
+//## Define your custom responsive grid.
+
+//** Number of columns in the grid.
+@grid-columns:              12;
+//** Padding between columns. Gets divided in half for the left and right.
+@grid-gutter-width:         15px;
+// Navbar collapse
+//** Point at which the navbar becomes uncollapsed.
+@grid-float-breakpoint:     @screen-sm-min;
+//** Point at which the navbar begins collapsing.
+@grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
+
+
+//== Navbar
+//
+//##
+
+// Basics of a navbar
+@navbar-height:                    50px;
+@navbar-margin-bottom:             @line-height-computed;
+@navbar-border-radius:             @border-radius-base;
+@navbar-padding-horizontal:        floor((@grid-gutter-width / 2));
+@navbar-padding-vertical:          ((@navbar-height - @line-height-computed) / 2);
+@navbar-collapse-max-height:       340px;
+
+@navbar-default-color:             #777;
+@navbar-default-bg:                #f8f8f8;
+@navbar-default-border:            darken(@navbar-default-bg, 6.5%);
+
+// Navbar links
+@navbar-default-link-color:                #777;
+@navbar-default-link-hover-color:          #333;
+@navbar-default-link-hover-bg:             transparent;
+@navbar-default-link-active-color:         #555;
+@navbar-default-link-active-bg:            darken(@navbar-default-bg, 6.5%);
+@navbar-default-link-disabled-color:       #ccc;
+@navbar-default-link-disabled-bg:          transparent;
+
+// Navbar brand label
+@navbar-default-brand-color:               @navbar-default-link-color;
+@navbar-default-brand-hover-color:         darken(@navbar-default-brand-color, 10%);
+@navbar-default-brand-hover-bg:            transparent;
+
+// Navbar toggle
+@navbar-default-toggle-hover-bg:           #ddd;
+@navbar-default-toggle-icon-bar-bg:        #888;
+@navbar-default-toggle-border-color:       #ddd;
+
+
+// Inverted navbar
+// Reset inverted navbar basics
+@navbar-inverse-color:                      @gray-light;
+@navbar-inverse-bg:                         #222;
+@navbar-inverse-border:                     darken(@navbar-inverse-bg, 10%);
+
+// Inverted navbar links
+@navbar-inverse-link-color:                 @gray-light;
+@navbar-inverse-link-hover-color:           #fff;
+@navbar-inverse-link-hover-bg:              transparent;
+@navbar-inverse-link-active-color:          @navbar-inverse-link-hover-color;
+@navbar-inverse-link-active-bg:             darken(@navbar-inverse-bg, 10%);
+@navbar-inverse-link-disabled-color:        #444;
+@navbar-inverse-link-disabled-bg:           transparent;
+
+// Inverted navbar brand label
+@navbar-inverse-brand-color:                @navbar-inverse-link-color;
+@navbar-inverse-brand-hover-color:          #fff;
+@navbar-inverse-brand-hover-bg:             transparent;
+
+// Inverted navbar toggle
+@navbar-inverse-toggle-hover-bg:            #333;
+@navbar-inverse-toggle-icon-bar-bg:         #fff;
+@navbar-inverse-toggle-border-color:        #333;
+
+
+//== Navs
+//
+//##
+
+//=== Shared nav styles
+@nav-link-padding:                          10px 15px;
+@nav-link-hover-bg:                         @gray-lighter;
+
+@nav-disabled-link-color:                   @gray-light;
+@nav-disabled-link-hover-color:             @gray-light;
+
+@nav-open-link-hover-color:                 #fff;
+
+//== Tabs
+@nav-tabs-border-color:                     #ddd;
+
+@nav-tabs-link-hover-border-color:          @gray-lighter;
+
+@nav-tabs-active-link-hover-bg:             @body-bg;
+@nav-tabs-active-link-hover-color:          @gray;
+@nav-tabs-active-link-hover-border-color:   #ddd;
+
+@nav-tabs-justified-link-border-color:            #ddd;
+@nav-tabs-justified-active-link-border-color:     @body-bg;
+
+//== Pills
+@nav-pills-border-radius:                   @border-radius-base;
+@nav-pills-active-link-hover-bg:            @component-active-bg;
+@nav-pills-active-link-hover-color:         @component-active-color;
+
+
+//== Pagination
+//
+//##
+
+@pagination-color:                     @link-color;
+@pagination-bg:                        #fff;
+@pagination-border:                    #ddd;
+
+@pagination-hover-color:               @link-hover-color;
+@pagination-hover-bg:                  @gray-lighter;
+@pagination-hover-border:              #ddd;
+
+@pagination-active-color:              #fff;
+@pagination-active-bg:                 @brand-primary;
+@pagination-active-border:             @brand-primary;
+
+@pagination-disabled-color:            @gray-light;
+@pagination-disabled-bg:               #fff;
+@pagination-disabled-border:           #ddd;
+
+
+//== Pager
+//
+//##
+
+@pager-bg:                             @pagination-bg;
+@pager-border:                         @pagination-border;
+@pager-border-radius:                  15px;
+
+@pager-hover-bg:                       @pagination-hover-bg;
+
+@pager-active-bg:                      @pagination-active-bg;
+@pager-active-color:                   @pagination-active-color;
+
+@pager-disabled-color:                 @pagination-disabled-color;
+
+
+//== Jumbotron
+//
+//##
+
+@jumbotron-padding:              30px;
+@jumbotron-color:                inherit;
+@jumbotron-bg:                   @gray-lighter;
+@jumbotron-heading-color:        inherit;
+@jumbotron-font-size:            ceil((@font-size-base * 1.5));
+
+
+//== Form states and alerts
+//
+//## Define colors for form feedback states and, by default, alerts.
+
+@state-success-text:             #3c763d;
+@state-success-bg:               #dff0d8;
+@state-success-border:           darken(spin(@state-success-bg, -10), 5%);
+
+@state-info-text:                #31708f;
+@state-info-bg:                  #d9edf7;
+@state-info-border:              darken(spin(@state-info-bg, -10), 7%);
+
+@state-warning-text:             #8a6d3b;
+@state-warning-bg:               #fcf8e3;
+@state-warning-border:           darken(spin(@state-warning-bg, -10), 5%);
+
+@state-danger-text:              #a94442;
+@state-danger-bg:                #f2dede;
+@state-danger-border:            darken(spin(@state-danger-bg, -10), 5%);
+
+
+//== Tooltips
+//
+//##
+
+//** Tooltip max width
+@tooltip-max-width:           200px;
+//** Tooltip text color
+@tooltip-color:               #fff;
+//** Tooltip background color
+@tooltip-bg:                  #000;
+@tooltip-opacity:             .9;
+
+//** Tooltip arrow width
+@tooltip-arrow-width:         5px;
+//** Tooltip arrow color
+@tooltip-arrow-color:         @tooltip-bg;
+
+
+//== Popovers
+//
+//##
+
+//** Popover body background color
+@popover-bg:                          #fff;
+//** Popover maximum width
+@popover-max-width:                   276px;
+//** Popover border color
+@popover-border-color:                rgba(0,0,0,.2);
+//** Popover fallback border color
+@popover-fallback-border-color:       #ccc;
+
+//** Popover title background color
+@popover-title-bg:                    darken(@popover-bg, 3%);
+
+//** Popover arrow width
+@popover-arrow-width:                 10px;
+//** Popover arrow color
+@popover-arrow-color:                 #fff;
+
+//** Popover outer arrow width
+@popover-arrow-outer-width:           (@popover-arrow-width + 1);
+//** Popover outer arrow color
+@popover-arrow-outer-color:           rgba(0,0,0,.25);
+//** Popover outer arrow fallback color
+@popover-arrow-outer-fallback-color:  #999;
+
+
+//== Labels
+//
+//##
+
+//** Default label background color
+@label-default-bg:            @gray-light;
+//** Primary label background color
+@label-primary-bg:            @brand-primary;
+//** Success label background color
+@label-success-bg:            @brand-success;
+//** Info label background color
+@label-info-bg:               @brand-info;
+//** Warning label background color
+@label-warning-bg:            @brand-warning;
+//** Danger label background color
+@label-danger-bg:             @brand-danger;
+
+//** Default label text color
+@label-color:                 #fff;
+//** Default text color of a linked label
+@label-link-hover-color:      #fff;
+
+
+//== Modals
+//
+//##
+
+//** Padding applied to the modal body
+@modal-inner-padding:         20px;
+
+//** Padding applied to the modal title
+@modal-title-padding:         15px;
+//** Modal title line-height
+@modal-title-line-height:     @line-height-base;
+
+//** Background color of modal content area
+@modal-content-bg:                             #fff;
+//** Modal content border color
+@modal-content-border-color:                   rgba(0,0,0,.2);
+//** Modal content border color **for IE8**
+@modal-content-fallback-border-color:          #999;
+
+//** Modal backdrop background color
+@modal-backdrop-bg:           #000;
+//** Modal backdrop opacity
+@modal-backdrop-opacity:      .5;
+//** Modal header border color
+@modal-header-border-color:   #e5e5e5;
+//** Modal footer border color
+@modal-footer-border-color:   @modal-header-border-color;
+
+@modal-lg:                    900px;
+@modal-md:                    600px;
+@modal-sm:                    300px;
+
+
+//== Alerts
+//
+//## Define alert colors, border radius, and padding.
+
+@alert-padding:               15px;
+@alert-border-radius:         @border-radius-base;
+@alert-link-font-weight:      bold;
+
+@alert-success-bg:            @state-success-bg;
+@alert-success-text:          @state-success-text;
+@alert-success-border:        @state-success-border;
+
+@alert-info-bg:               @state-info-bg;
+@alert-info-text:             @state-info-text;
+@alert-info-border:           @state-info-border;
+
+@alert-warning-bg:            @state-warning-bg;
+@alert-warning-text:          @state-warning-text;
+@alert-warning-border:        @state-warning-border;
+
+@alert-danger-bg:             @state-danger-bg;
+@alert-danger-text:           @state-danger-text;
+@alert-danger-border:         @state-danger-border;
+
+
+//== Progress bars
+//
+//##
+
+//** Background color of the whole progress component
+@progress-bg:                 #f5f5f5;
+//** Progress bar text color
+@progress-bar-color:          #fff;
+
+//** Default progress bar color
+@progress-bar-bg:             @brand-primary;
+//** Success progress bar color
+@progress-bar-success-bg:     @brand-success;
+//** Warning progress bar color
+@progress-bar-warning-bg:     @brand-warning;
+//** Danger progress bar color
+@progress-bar-danger-bg:      @brand-danger;
+//** Info progress bar color
+@progress-bar-info-bg:        @brand-info;
+
+
+//== List group
+//
+//##
+
+//** Background color on `.list-group-item`
+@list-group-bg:                 #fff;
+//** `.list-group-item` border color
+@list-group-border:             #ddd;
+//** List group border radius
+@list-group-border-radius:      @border-radius-base;
+
+//** Background color of single list elements on hover
+@list-group-hover-bg:           #f5f5f5;
+//** Text color of active list elements
+@list-group-active-color:       @component-active-color;
+//** Background color of active list elements
+@list-group-active-bg:          @component-active-bg;
+//** Border color of active list elements
+@list-group-active-border:      @list-group-active-bg;
+@list-group-active-text-color:  lighten(@list-group-active-bg, 40%);
+
+@list-group-link-color:         #555;
+@list-group-link-heading-color: #333;
+
+
+//== Panels
+//
+//##
+
+@panel-bg:                    #fff;
+@panel-body-padding:          15px;
+@panel-border-radius:         @border-radius-base;
+
+//** Border color for elements within panels
+@panel-inner-border:          #ddd;
+@panel-footer-bg:             #f5f5f5;
+
+@panel-default-text:          @gray-dark;
+@panel-default-border:        #ddd;
+@panel-default-heading-bg:    #f5f5f5;
+
+@panel-primary-text:          #fff;
+@panel-primary-border:        @brand-primary;
+@panel-primary-heading-bg:    @brand-primary;
+
+@panel-success-text:          @state-success-text;
+@panel-success-border:        @state-success-border;
+@panel-success-heading-bg:    @state-success-bg;
+
+@panel-info-text:             @state-info-text;
+@panel-info-border:           @state-info-border;
+@panel-info-heading-bg:       @state-info-bg;
+
+@panel-warning-text:          @state-warning-text;
+@panel-warning-border:        @state-warning-border;
+@panel-warning-heading-bg:    @state-warning-bg;
+
+@panel-danger-text:           @state-danger-text;
+@panel-danger-border:         @state-danger-border;
+@panel-danger-heading-bg:     @state-danger-bg;
+
+
+//== Thumbnails
+//
+//##
+
+//** Padding around the thumbnail image
+@thumbnail-padding:           4px;
+//** Thumbnail background color
+@thumbnail-bg:                @body-bg;
+//** Thumbnail border color
+@thumbnail-border:            #ddd;
+//** Thumbnail border radius
+@thumbnail-border-radius:     @border-radius-base;
+
+//** Custom text color for thumbnail captions
+@thumbnail-caption-color:     @text-color;
+//** Padding around the thumbnail caption
+@thumbnail-caption-padding:   9px;
+
+
+//== Wells
+//
+//##
+
+@well-bg:                     #f5f5f5;
+@well-border:                 darken(@well-bg, 7%);
+
+
+//== Badges
+//
+//##
+
+@badge-color:                 #fff;
+//** Linked badge text color on hover
+@badge-link-hover-color:      #fff;
+@badge-bg:                    @gray-light;
+
+//** Badge text color in active nav link
+@badge-active-color:          @link-color;
+//** Badge background color in active nav link
+@badge-active-bg:             #fff;
+
+@badge-font-weight:           bold;
+@badge-line-height:           1;
+@badge-border-radius:         10px;
+
+
+//== Breadcrumbs
+//
+//##
+
+@breadcrumb-padding-vertical:   8px;
+@breadcrumb-padding-horizontal: 15px;
+//** Breadcrumb background color
+@breadcrumb-bg:                 #f5f5f5;
+//** Breadcrumb text color
+@breadcrumb-color:              #ccc;
+//** Text color of current page in the breadcrumb
+@breadcrumb-active-color:       @gray-light;
+//** Textual separator for between breadcrumb elements
+@breadcrumb-separator:          "/";
+
+
+//== Carousel
+//
+//##
+
+@carousel-text-shadow:                        0 1px 2px rgba(0,0,0,.6);
+
+@carousel-control-color:                      #fff;
+@carousel-control-width:                      15%;
+@carousel-control-opacity:                    .5;
+@carousel-control-font-size:                  20px;
+
+@carousel-indicator-active-bg:                #fff;
+@carousel-indicator-border-color:             #fff;
+
+@carousel-caption-color:                      #fff;
+
+
+//== Close
+//
+//##
+
+@close-font-weight:           bold;
+@close-color:                 #000;
+@close-text-shadow:           0 1px 0 #fff;
+
+
+//== Code
+//
+//##
+
+@code-color:                  #c7254e;
+@code-bg:                     #f9f2f4;
+
+@kbd-color:                   #fff;
+@kbd-bg:                      #333;
+
+@pre-bg:                      #f5f5f5;
+@pre-color:                   @gray-dark;
+@pre-border-color:            #ccc;
+@pre-scrollable-max-height:   340px;
+
+
+//== Type
+//
+//##
+
+//** Text muted color
+@text-muted:                  @gray-light;
+//** Abbreviations and acronyms border color
+@abbr-border-color:           @gray-light;
+//** Headings small color
+@headings-small-color:        @gray-light;
+//** Blockquote small color
+@blockquote-small-color:      @gray-light;
+//** Blockquote font size
+@blockquote-font-size:        (@font-size-base * 1.25);
+//** Blockquote border color
+@blockquote-border-color:     @gray-lighter;
+//** Page header border color
+@page-header-border-color:    @gray-lighter;
+//** Width of horizontal description list titles
+@dl-horizontal-offset:        @component-offset-horizontal;
+//** Horizontal line color.
+@hr-border:                   @gray-lighter;
+
+//== Miscellaneous
+//
+//##
+
+//** Horizontal line color.
+@hr-border:                   @gray-lighter;
+
+//** Horizontal offset for forms and lists.
+@component-offset-horizontal: 180px;
+
+
+//== Container sizes
+//
+//## Define the maximum width of `.container` for different screen sizes.
+
+// Small screen / tablet
+@container-tablet:             ((720px + @grid-gutter-width));
+//** For `@screen-sm-min` and up.
+@container-sm:                 @container-tablet;
+
+// Medium screen / desktop
+@container-desktop:            ((940px + @grid-gutter-width));
+//** For `@screen-md-min` and up.
+@container-md:                 @container-desktop;
+
+// Large screen / wide desktop
+@container-large-desktop:      ((1140px + @grid-gutter-width));
+//** For `@screen-lg-min` and up.
+@container-lg:                 @container-large-desktop;
diff --git a/ember/app/styles/flarum/discussion.less b/ember/app/styles/flarum/discussion.less
new file mode 100644
index 000000000..d8f894746
--- /dev/null
+++ b/ember/app/styles/flarum/discussion.less
@@ -0,0 +1,357 @@
+.discussion-header, .post, .items div.gap:first-of-type:last-of-type {
+	max-width: 800px;
+}
+
+.posts {
+	// margin-bottom: 15px;
+}
+.posts .post {
+	padding-top: 25px;
+	padding-bottom: 25px;
+	border-bottom: 1px solid @flarum-body-secondary-color;
+}
+.item.highlight .post {
+	box-shadow: inset 0 -5px 0 rgba(255, 255, 0, 0.2), inset 0 5px 0 rgba(255, 255, 0, 0.2);
+}
+.items .item:first-of-type {
+	border-top: 0;
+}
+.items .item:last-of-type {
+	border-bottom: 0;
+}
+.post {
+	padding-left: 65px;
+	transition: 0.2s box-shadow;
+}
+.gap {
+	padding: 20px 0;
+	text-align: center;
+	color: @flarum-bg-muted-color;
+	margin: -1px -30px 0;
+	cursor: pointer;
+	border-top: 1px dashed @flarum-body-background-color !important;
+	border-bottom: 1px dashed @flarum-body-background-color !important;
+	transition: color 0.2s, padding 0.2s;
+	background: lighten(@flarum-background-color, 0%);
+	text-transform: uppercase;
+	font-size: 12px;
+	letter-spacing: 1px;
+	overflow: hidden;
+	position: relative;
+}
+.items div.gap:first-of-type {
+	margin-top: -30px;
+	position: relative;
+}
+.items div.gap:last-of-type {
+	margin-bottom: -30px;
+	border-bottom: 0;
+}
+.items div.gap:first-of-type:last-of-type {
+	margin: 0;
+	background: @flarum-body-background-color;
+	color: @flarum-body-muted-color;
+}
+.items div.gap:first-of-type:last-of-type:after {
+	display: none;
+}
+.gap.active, .gap:hover, .gap.loading {
+	padding: 50px 0;
+}
+.gap.loading {
+	color: @flarum-bg-muted-color;
+	transition: none;
+}
+.gap.down:after {
+	content: '\f078';
+	font-family: 'FontAwesome';
+	display: block;
+	opacity: 0;
+	transition: opacity 0.2s;
+	margin-bottom: -25px;
+	margin-top: 10px;
+	height: 15px;
+	color: @flarum-body-muted-color;
+}
+.gap.up:before {
+	content: '\f077';
+	font-family: 'FontAwesome';
+	display: block;
+	opacity: 0;
+	transition: opacity 0.4s;
+	margin-top: -25px;
+	margin-bottom: 10px;
+	height: 15px;
+}
+.gap:hover:before, .gap:hover:after, .gap.loading:before, .gap.loading:after, .gap.active:before, .gap.active:after {
+	opacity: 1;
+}
+
+
+
+.post {
+	line-height: 1.75em;
+	position: relative;
+}
+.item .controls {
+	// position: absolute;
+	// right: 10px;
+	// top: -2px;
+	// display: none;
+	float: right;
+	margin: 0 0 0 10px;
+}
+.item:hover .controls {
+	display: block;
+}
+.post .controls .btn {
+	margin-top: -2px;
+}
+.post header {
+	margin-bottom: 10px;
+}
+.post header, .post header a {
+	color: @flarum-body-muted-color;
+}
+.post .user {
+	margin: 0;
+	display: inline;
+}
+.post .user, .post .user a {
+	color: @flarum-body-primary-color;
+	font-weight: 600;
+	font-size: 16px;
+}
+.post .time {
+	font-size: 12px;
+	float: right;
+	&, & a {
+		color: @flarum-body-muted-color;
+	}
+}
+.post .reply-to {
+	margin-left: 5px;
+}
+
+.post {
+	// float: left;
+}
+.post-meta {
+	// float: left;
+	position: absolute;
+	left: 100%;
+	top: 25px;
+	margin-left: 30px;
+	width: 100px;
+	color: @flarum-body-muted-color;
+	// opacity: 0;
+	// transition: opacity 0s;
+}
+.post-icon {
+	float: left;
+
+}
+
+.post.deleted {
+
+	& .post-body,
+	& .post-meta,
+	& .avatar,
+	& .reply-to {
+		display: none;
+	}
+
+	& header {
+		margin-bottom: 0;
+	}
+
+	&, & header, & header a {
+		color: fadeout(@flarum-body-text-color, 50%);
+	}
+}
+
+@media screen and (max-width: 1300px) {
+	.post-meta {
+		position: static;
+		margin: 0;
+		width: auto;
+		margin-bottom: -15px;
+	}
+	.post-meta li {
+		display: inline;
+		margin-right: 15px;
+	}
+	.post-meta .reveal {
+		opacity: 1 !important;
+	}
+}
+.post-meta ul {
+	list-style-type: none;
+	margin: 0;
+	padding: 0;
+	line-height: 2em;
+}
+.post-meta a {
+	color: @flarum-body-muted-color;
+}
+.post-meta a:hover {
+	color: @flarum-body-primary-color;
+	text-decoration: none;
+}
+.post-meta .reveal {
+	opacity: 0;
+	transition: opacity 0.2s;
+}
+.item:hover .post-meta .reveal {
+	opacity: 1
+}
+
+.post header .avatar, .post .post-icon {
+	margin-left: -65px;
+	float: left;
+}
+.activity {
+	font-size: 16px;
+	// padding-top: 20px;
+	// padding-bottom: 20px;
+}
+.activity, .activity a {
+	color: @flarum-body-text-color;
+}
+.post .post-icon {
+	width: 48px;
+	text-align: right;
+	font-size: 22px;
+}
+.activity a {
+	font-weight: 600;
+}
+.discussion-header h4 {
+	font-size: 14px;
+	text-align: center;
+	margin: 0 0 15px 0;
+}
+.discussion-header h4, .discussion-header h4 a {
+	color: @flarum-body-muted-color;
+}
+.discussion-header .category {
+	padding: 3px 7px;
+	font-size: 13px;
+}
+.discussion-header h4 a {
+	font-weight: 600;
+}
+
+
+.discussion-footer {
+	margin-top: 15px;
+}
+.discussion-footer .list-inline {
+	margin-bottom: 0;
+}
+
+.discussion-controls {
+	margin: 0 auto;
+	margin-bottom: 30px;
+}
+.discussion-controls .btn {
+	// margin-top: 10px;
+}
+.discussion-controls .btn-group {
+	width: 100%;
+}
+
+.btn-group-suffix {
+	display: table;
+	border-spacing: 1px;
+}
+.btn-group-suffix .btn {
+	display: table-cell;
+	float: none;
+	width: 100%;
+}
+// .btn-group-suffix .dropdown-toggle {
+// 	float: right;
+// 	margin-right: -40px;
+// 	width: 39px;
+// }
+
+
+@media (min-width: @screen-md-min) {
+
+	.discussion-scrubber {
+		margin: 0 auto;
+		text-align: right;
+		margin-right: 3px;
+	}
+	.scrubber a {
+		color: @flarum-bg-muted-color;
+	}
+	.scrubber a i {
+		font-size: 14px;
+		margin-left: 5px;
+	}
+	.scrubber a:hover {
+		text-decoration: none;
+		color: @flarum-bg-link-color;
+	}
+	.scrubber .scrollbar {
+		margin: 10px 4px 10px 0;
+		position: relative;
+		cursor: pointer;
+	}
+	.scrubber .scrollbar-before, .scrubber .scrollbar-after {
+		border-right: 1px solid @flarum-bg-secondary-color;
+	}
+	.scrubber .scrollbar-slider {
+		position: relative;
+		width: 100%;
+		padding: 5px 0;
+	}
+	.scrubber .handle {
+		height: 100%;
+		width: 9px;
+		background: @flarum-bg-primary-color;
+		border-radius: 9px;
+		float: right;
+		margin-right: -4px;
+		transition: background 0.2s;
+	}
+	.scrubber .disabled .handle {
+		background: @flarum-bg-secondary-color;
+	}
+	.scrubber .info {
+		height: (2em * @line-height-base);
+		margin-top: (-1em * @line-height-base);
+		position: absolute;
+		top: 50%;
+		width: 100%;
+		right: 15px;
+	}
+	.scrubber .info strong {
+		display: block;
+	}
+	.scrubber .info .description {
+		color: @flarum-bg-muted-color;
+	}
+	.scrollbar-highlights {
+		position: absolute;
+		left: 0;
+		right: 0;
+		top: 0;
+		bottom: 0;
+		list-style-type: none;
+	}
+	.scrollbar-highlights li {
+		position: absolute;
+		right: -6px;
+		background: #fc0;
+		height: 8px;
+		width: 13px;
+		border-radius: 4px;
+		box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15), inset 0 0 0 1px rgba(255, 255, 255, 0.5);
+		opacity: 0.99;
+	}
+}
+
+
diff --git a/ember/app/styles/flarum/discussions.less b/ember/app/styles/flarum/discussions.less
new file mode 100644
index 000000000..9b3b6f259
--- /dev/null
+++ b/ember/app/styles/flarum/discussions.less
@@ -0,0 +1,454 @@
+.discussions-header, .discussions {
+	max-width: 1200px;
+}
+
+.discussions {
+	list-style: none;
+	margin: 0;
+	padding: 0;
+}
+.discussions > li {
+	.clearfix();
+	border-bottom: 1px solid @flarum-body-secondary-color;
+	position: relative;
+	line-height: 20px;
+	padding-left: 45px;
+	padding-right: 30px;
+}
+.discussions > li.highlight, .discussions > li.active {
+	background: lighten(@flarum-body-secondary-color, 8%);
+}
+.discussions, .discussions a {
+	color: @flarum-body-muted-color;
+}
+.discussions .action {
+	float: left;
+	width: 45px;
+	padding-right: 12px;
+	margin-left: -45px;
+	text-align: right;
+	// visibility: hidden;
+	// opacity: 0.5;
+	min-height: 1px;
+	padding-top: 12px;
+	padding-bottom: 12px;
+	font-size: 12px;
+	color: @flarum-body-muted-color;
+}
+.discussions .action .unread {
+	color: #fff;
+	background: @flarum-body-primary-color;
+	padding: 0 4px;
+	border-radius: 4px;
+	font-weight: 600;
+}
+.discussions li:hover .action {
+	visibility: visible;
+}
+.discussions .action:hover {
+	text-decoration: none;
+	// opacity: 0.75;
+}
+.discussions .action:active {
+	opacity: 0.5;
+}
+.discussions .info {
+	float: left;
+	width: 74%;
+	margin-right: 2%;
+	padding-top: 12px;
+	padding-bottom: 12px;
+	display: block;
+}
+.discussions .info .title {
+	font-weight: 300;
+	font-size: 16px;
+	color: @flarum-body-link-color;
+	margin-right: 5px;
+}
+.discussions .icon {
+	margin-right: 5px;
+	font-size: 14px;
+	color: @flarum-body-link-color;
+	margin-left: 0;
+}
+.discussions .info:hover {
+	text-decoration: none;
+}
+.discussions .info:hover .title {
+	text-decoration: underline;
+}
+// .discussions .info > span {
+// 	font-size: 12px;
+// 	margin-left: 5px;
+// }
+.discussions .info .draft {
+	color:#4ea11b;
+}
+.discussions .info .excerpt {
+	display: block;
+	white-space: normal;
+	color: @flarum-body-muted-color;
+	line-height: 1.5em;
+	margin: 10px 0 5px;
+}
+.discussions .category {
+	float: right;
+	margin: -1px 0 -1px 10px;
+}
+.discussions .terminal-post {
+	float: left;
+	width: 15%;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	padding-top: 12px;
+	padding-bottom: 12px;
+	font-size: 90%;
+}
+.discussions .terminal-post .avatar {
+	margin: -1px 5px -1px 0;
+}
+.discussions .replies {
+	float: left;
+	width: 8%;
+	font-size: 20px;
+	font-weight: 300;
+	text-align: center;
+	padding-top: 12px;
+	padding-bottom: 12px;
+}
+.discussions .unread .title {
+	font-weight: 600;
+}
+.discussions .locked .title, .discussions .icon-locked {
+	color: #777;
+}
+.discussions .locked .unread {
+	background-color: #777;
+}
+.discussions .sticky .title, .discussions .icon-sticky {
+	color: #D03202;
+}
+.discussions .sticky .unread {
+	background-color: #D03202;
+}
+.discussions .following .title {
+	color: #F5A623 !important;
+}
+.discussions .icon-following {
+	color: #F5A623;
+}
+.discussions .following .unread {
+	background-color: #F5A623 !important;
+}
+.discussions .controls {
+	position: absolute;
+	right: 0;
+	top: 11px;
+	display: none;
+}
+.discussions li:hover .controls {
+	display: block;
+}
+.discussions .relevant-posts {
+	clear: both;
+	// border: solid lighten(@flarum-body-text-color, 62%);
+	// border-width: 1px;
+	border-radius: 3px;
+	margin-bottom: 30px;
+	// padding: 0 10px;
+}
+.discussions .relevant-posts .post {
+	padding: 5px 0 5px 35px;
+	display: block;
+	// border-color: lighten(@flarum-body-text-color, 62%);
+	border: 0;
+	// margin-top: -1px;
+	color: @flarum-body-muted-color;
+}
+.discussions .relevant-posts .post:hover {
+	text-decoration: none;
+	// background: lighten(@flarum-body-secondary-color, 8%);
+	color: @flarum-body-text-color;
+	// padding-left: 45px;
+	// padding-right: 10px;
+	// margin-left: -10px;
+	// margin-right: -10px;
+}
+.discussions .relevant-posts .avatar {
+	margin-left: -35px;
+	opacity: 0.25;
+	float: left;
+}
+.discussions .relevant-posts .post:hover .avatar {
+	opacity: 1;
+}
+
+.load-more {
+	text-align: center;
+	margin-top: 20px;
+}
+.load-more .loading {
+	padding: 10px 0;
+}
+
+.discussions-pane {
+	left: 300px - 375px;
+	width: 100%;
+}
+.discussions-pane.paned {
+	position: fixed;
+	z-index: 10;
+	overflow: auto;
+	top: 0;
+	bottom: 0;
+	width: 375px;
+	padding: 2.5vh 0;
+	background: #fff;
+	border-right: 5px solid @flarum-background-color;
+	transition: left 0.2s;
+
+	&.showing {
+		left: 300px;
+	}
+
+	& .page-header .pull-right {
+		right: 20px;
+	}
+
+	& .discussions > li {
+		padding-right: 15px;
+		padding-left: 20px;
+
+		& .action {
+			padding-top: 15px;
+			padding-bottom: 15px;
+		}
+		& .info {
+			padding: 15px 0;
+			width: 85%;
+			min-height: 70px;
+			& .category {
+				padding: 1px 4px;
+				font-size: 11px;
+				margin-top: 0;
+			}
+			& .name {
+				display: block;
+			}
+			& .title {
+				font-size: 14px;
+			}
+			& .excerpt {
+				display: none;
+			}
+		}
+		& .controls {
+			display: none;
+		}
+		& .terminal-post {
+			width: 13%;
+			text-align: right;
+			float: right;
+			padding: 15px 0 5px;
+			& .avatar {
+				display: none;
+			}
+			& a {
+				margin: 0;
+			}
+		}
+		// & .replies {
+		// 	width: auto;
+		// 	float: right;
+		// 	clear: right;
+		// 	padding: 0 5px;
+		// 	border-radius: 4px;
+		// 	background: @flarum-body-secondary-color;
+		// 	color: #fff;
+		// 	font-size: 12px;
+		// 	font-weight: 600;
+		// 	margin-bottom: 15px;
+		// }
+		& .replies, & .action .unread {
+			width: auto;
+			float: none;
+			position: absolute;
+			top: 37px;
+			right: 15px;
+			padding: 0 5px;
+			border-radius: 4px;
+			color: #fff;
+			font-size: 12px;
+			font-weight: 600;
+			pointer-events: none;
+		}
+		& .replies {
+			background: @flarum-body-secondary-color;
+		}
+		&.unread .replies {
+			display: none;
+		}
+	}
+}
+
+.pinned {
+	& .discussions-pane {
+		left: 300px;
+		transition: left 0.2s, width 0.2s;
+	}
+	& .discussion-pane {
+		margin-left: 375px;
+	}
+}
+
+.discussions-header .select {
+	vertical-align: -1px;
+	margin-left: 10px;
+}
+
+
+
+@media (max-width: @screen-sm-max) {
+	.discussions-pane.paned {
+		display: none;
+	}
+	.discussions-header {
+		display: none;
+	}
+	.discussions {
+		& > li {
+			margin-left: 20px;
+			padding: 0;
+			line-height: inherit;
+		}
+		& .discussion {
+			position: relative;
+			background: #fff;
+		}
+		& .controls {
+			position: absolute;
+			left: 0;
+			right: 0;
+			bottom: 0;
+			top: 0;
+			display: block;
+
+			& .dropdown-toggle {
+				display: none;
+			}
+			& .dropdown-menu {
+				float: none;
+				position: static;
+				display: block;
+				text-align: center;
+				margin: 0;
+				padding: 0;
+				border: 0;
+				box-shadow: none;
+				width: auto;
+				min-width: 0;
+				background: none;
+				height: 100%;
+
+				& li {
+					float: left;
+					margin: 0;
+					height: 100%;
+
+					& a {
+						height: 100%;
+						padding: 25px 10px;
+						background: @flarum-body-primary-color;
+						color: #fff;
+						&.delete {
+							background: #e74135;
+						}
+					}
+					& .fa {
+						font-size: 22px;
+					}
+					& span {
+						display: none;
+					}
+				}
+			}
+		}
+		& .info {
+			display: block;
+			padding: 15px 75px 15px 20px;
+			width: auto;
+			float: none;
+			min-height: 70px;
+			margin: 0 0 0 -20px;
+			&.pressed {
+				background: @flarum-body-secondary-color;
+			}
+			& .category {
+				padding: 1px 4px;
+				font-size: 11px;
+				margin-top: 0;
+			}
+			& .name {
+				display: block;
+			}
+			& .excerpt {
+				display: none;
+			}
+		}
+		& .info:hover .title {
+			text-decoration: none;
+		}
+		& .info:after {
+			content: '\f054';
+			font-family: FontAwesome;
+			position: absolute;
+			right: 10px;
+			top: 15px;
+			color: @flarum-body-secondary-color;
+		}
+		& .info .title {
+			font-weight: 400;
+			font-size: 13px;
+		}
+		& .unread .title {
+			font-weight: 600;
+		}
+		& .terminal-post {
+			width: auto;
+			float: none;
+			position: absolute;
+			top: 15px;
+			right: 25px;
+			padding: 0;
+			pointer-events: none;
+			& .avatar {
+				display: none;
+			}
+			& a {
+				margin: 0;
+			}
+		}
+		
+		& .replies, & .action .unread {
+			width: auto;
+			float: none;
+			position: absolute;
+			top: 37px;
+			right: 25px;
+			padding: 0 5px;
+			border-radius: 4px;
+			color: #fff;
+			font-size: 12px;
+			font-weight: 600;
+			pointer-events: none;
+		}
+		& .replies {
+			background: @flarum-body-secondary-color;
+		}
+		& .unread .replies {
+			display: none;
+		}
+	}
+}
diff --git a/ember/app/styles/flarum/global.less b/ember/app/styles/flarum/global.less
new file mode 100644
index 000000000..fccf58d20
--- /dev/null
+++ b/ember/app/styles/flarum/global.less
@@ -0,0 +1,858 @@
+@import url(http://fonts.googleapis.com/css?family=Open+Sans:400,300,600);
+
+@import url(http://fonts.googleapis.com/css?family=Fugaz+One);
+
+// .pace .pace-progress {
+//   background: @flarum-bg-muted-color;
+//   position: fixed;
+//   z-index: 2000;
+//   top: 0;
+//   left: 0;
+//   height: 2px;
+
+//   -webkit-transition: width 1s;
+//   -moz-transition: width 1s;
+//   -o-transition: width 1s;
+//   transition: width 1s;
+// }
+
+// .pace-inactive {
+//   display: none;
+// }
+
+body {
+	background-color: @flarum-background-color;
+	background-image: @flarum-background-image;
+	color: @flarum-bg-text-color;
+	// -webkit-font-smoothing: antialiased;
+}
+body, input, button, select, textarea {
+	font-size: 13px;
+}
+body, h1, h2, h3, h4, h5, h6 {
+	font-family: 'Open Sans', sans-serif;
+}
+a {
+	color: @flarum-bg-link-color;
+}
+a:hover {
+	color: @flarum-bg-link-color;
+}
+#wrapper {
+	padding-top: 2.5vh;
+	padding-bottom: 2.5vh;
+}
+@media (min-width: @screen-md-min) {
+	#sidebar {
+		position: fixed;
+		top: 0;
+		bottom: 0;
+		height: 100%;
+		width: 295px;
+		padding: 2.5vh 25px 2.5vh 0;
+		display: table;
+		z-index: 20;
+	}
+	#sidebar-body {
+		display: table-row;
+		height: 100%;
+	}
+	#sidebar-content {
+		overflow: auto;
+		height: 100%;
+		box-sizing: content-box;
+		padding: 10px 25px;
+		margin: 0 -25px;
+	}
+	#sidebar-content .toolbar, #sidebar-content .body {
+		list-style-type: none;
+		margin: 0 0 10px;
+		padding: 0;
+	}
+	#sidebar-footer {
+		display: table-row;
+	}
+	#sidebar footer {
+		border-top: 1px solid @flarum-bg-secondary-color;
+		padding-top: 15px;
+	}
+	#sidebar-footer .statistics {
+		border-bottom: 1px solid @flarum-bg-secondary-color;
+		padding: 10px 0;
+	}
+	#sidebar-footer .statistics i {
+		margin-right: 10px;
+	}
+	#sidebar-footer .statistics a {
+		margin-right: 10px;
+	}
+	#sidebar-footer .meta {
+		padding-top: 10px;
+		.clearfix();
+	}
+	#sidebar-footer .meta > a, #sidebar-footer .meta > .dropdown > a {
+		color: @flarum-bg-muted-color;
+	}
+	#sidebar-footer .language {
+		float: left;
+	}
+	#sidebar-footer .powered-by {
+		float: right;
+	}
+	#sidebar-header {
+		display: table-row;
+	}
+	#sidebar header {
+	}
+	#sidebar header h1 {
+		font-weight: 600;
+		font-size: 18px;
+		margin: 0 0 15px;
+		line-height: 1.5em;
+		text-align: center;
+
+		// font-family: Fugaz One;
+		// font-weight: normal;
+		// font-size: 22px;
+	}
+	#sidebar header h1 .logo {
+		display: block;
+		margin: 0 auto;
+		padding-bottom: 10px;
+	}
+}
+#sidebar .search {
+	white-space: nowrap;
+	margin-bottom: 15px;
+}
+#sidebar .search input, #sidebar .search .search-nav {
+	border-radius: 20px;
+	background: transparent;
+	border: 1px solid @flarum-bg-secondary-color;
+	box-shadow: none;
+	padding: @padding-base-vertical @padding-base-horizontal;
+}
+#sidebar .search input {
+	padding-right: 30px;
+}
+#sidebar .search input::-webkit-input-placeholder {
+	color: @flarum-bg-muted-color;
+}
+#sidebar .search input::-moz-placeholder {
+	color: @flarum-bg-muted-color;
+}
+.search-input {
+	position: relative;
+}
+.search-input input[type=text] {
+	padding-right: 30px;
+	-webkit-appearance: none;
+}
+.search-input .clear {
+	position: absolute;
+	right: 13px;
+	top: @padding-base-vertical;
+	line-height: 18px;
+	color: @flarum-bg-muted-color;
+	font-size: 18px;
+	transition: transform 0.1s;
+	transform: scale(0.01);
+}
+.search-input.clearable .clear {
+	transform: scale(1);
+}
+#sidebar .search input:focus, #sidebar .search .search-nav, #sidebar .search-input.active input {
+	background: @flarum-body-background-color;
+	color: @flarum-body-text-color;
+	border-color: transparent;
+}
+#sidebar .search-nav {
+	text-align: center;
+}
+#sidebar .search-nav, #sidebar .search-nav a {
+	color: @flarum-body-muted-color;
+}
+#sidebar .search-nav a {
+	padding: 5px 10px;
+	margin: 0 -5px;
+}
+#sidebar .search-nav a:hover {
+	color: @flarum-body-primary-color;
+	text-decoration: none;
+}
+#sidebar .search-nav a.disabled {
+	color: @flarum-body-secondary-color;
+	cursor: default;
+}
+.nav-list {
+	list-style: none;
+	padding: 0;
+	margin: 0;
+	margin-top: 10px;
+}
+.nav-list li a {
+	display: block;
+	padding: (@padding-base-vertical + 1) @padding-base-horizontal;
+	color: @flarum-bg-link-color;
+	border-top: 1px solid @flarum-bg-secondary-color;
+}
+.nav-list li a i {
+	float: left;
+	margin-right: 10px;
+	margin-top: 3px;
+	text-decoration: none;
+	font-size: 14px;
+}
+
+.nav-list li.active + li a, .nav-list:not(.categories) li:first-of-type > a {
+	border-top: 0;
+}
+.nav-list li.active a {
+	color: contrast(@flarum-bg-primary-color, #000, #fff) !important;
+	background: @flarum-bg-primary-color;
+	border-radius: @border-radius-base;
+	font-weight: 600;
+	border-top: 0;
+}
+.nav-list li.active a:hover {
+	text-decoration: none;
+}
+.nav-list li a.count {
+	float: right;
+	border: 0;
+	background: none;
+}
+
+.nav-list .header {
+	display: block;
+	color: @flarum-bg-muted-color;
+	text-transform: uppercase;
+	font-weight: 600;
+	font-size: 12px;
+	border-top: 0;
+	padding: 8px 0;
+	margin-top: 15px;
+}
+.nav-list-small li a {
+	padding: 7px 12px;
+}
+
+.nav-list.categories {
+	margin-left: 35px;
+}
+.nav-list ul {
+	list-style: none;
+	margin: 0;
+	padding: 0;
+}
+.nav-list ul ul {
+	padding-left: 20px;
+	font-size: 12px;
+	display: none;
+}
+.nav-list.categories .color {
+	width: 16px;
+	height: 16px;
+	display: inline-block;
+	box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
+	border-radius: 4px;
+	margin-top: 2px;
+}
+
+.category {
+	padding: 2px 6px;
+	border-radius: 4px;
+	font-size: 12px;
+	display: inline-block;
+	line-height: @line-height-base;
+	color: @flarum-body-muted-color;
+	border: 1px solid @flarum-body-secondary-color;
+}
+// .category-announcements {
+// 	background-color: #2D4053;
+// 	color: #fff !important;
+// }
+// .category-general {
+// 	background-color: #D9D9D9;
+// 	color: #555 !important;
+// }
+// .category-support {
+// 	background-color: #5A69DB;
+// 	color: #fff !important;
+// }
+// .category-feedback {
+// 	background-color: #529E3C;
+// 	color: #fff !important;
+// }
+// .category-core {
+// 	background-color: #F7D64E;
+// 	color: #7E6500 !important;
+// }
+// .category-plugins {
+// 	background-color: #EC9A3D;
+// 	color: #fff !important;
+// }
+// .category-themes {
+// 	background-color: #DB5A5A;
+// 	color: #fff !important;
+// }
+
+#body {
+	background: @flarum-body-background-color;
+	margin-left: 295px;
+	padding: 2.5vh 30px;
+	border-radius: @border-radius-base;
+	// min-height: 94vh;
+	color: @flarum-body-text-color;
+}
+.page-header {
+	border-bottom: 1px solid @flarum-body-secondary-color;
+	position: relative;
+	padding-bottom: 40px;
+	margin: 0;
+}
+.page-header h2 {
+	text-align: center;
+	font-size: 22px;
+	font-weight: 300;
+	color: @flarum-body-primary-color;
+	margin: 0;
+}
+.page-header h2 i {
+	margin-right: 5px;
+}
+.page-header:before {
+	content: " ";
+	position: absolute;
+	display: block;
+	width: 0;
+	height: 0;
+	border: 15px solid transparent;
+	border-bottom-color: @flarum-body-secondary-color;
+	border-top-width: 0;
+	left: 50%;
+	bottom: -1px;
+	margin-left: -15px;
+}
+.page-header:after {
+	content: " ";
+	position: absolute;
+	display: block;
+	width: 0;
+	height: 0;
+	border: 14px solid transparent;
+	border-bottom-color: @flarum-body-background-color;
+	border-top-width: 0;
+	left: 50%;
+	bottom: -2px;
+	margin-left: -14px;
+}
+.page-header .pull-right {
+	position: absolute;
+	right: 0;
+}
+.page-header .pull-left {
+	position: absolute;
+	left: 0;
+}
+
+.btn {
+	padding: @padding-base-vertical @padding-base-horizontal;
+	font-weight: 600;
+}
+.btn-following {
+	.button-variant(#d80; #ffd; #ffd);
+}
+.btn i {
+	font-size: 14px;
+}
+.btn .icon-caret-down {
+	margin: 0 2px;
+}
+.btn:active,
+.btn.active,
+.btn-group.open .dropdown-toggle {
+	.box-shadow(inset 0 1px 4px rgba(0,0,0,.05));
+}
+
+.btn-default {
+	&,
+	&:hover,
+	&:focus,
+	&:active,
+	&.active,
+	.open .dropdown-toggle& {
+		background: transparent;
+		border-color: @flarum-body-secondary-color;
+		color: @flarum-body-muted-color;
+		font-weight: normal;
+	}
+}
+
+#sidebar .btn-default {
+	.button-variant(@flarum-bg-primary-color; @flarum-bg-secondary-color; @flarum-bg-secondary-color);
+	border: 0;
+	font-weight: 600;
+}
+
+.btn-sm {
+	padding: @padding-small-vertical @padding-small-horizontal;
+	border-radius: @border-radius-base;
+	font-size: 13px;
+}
+.btn-xs {
+	padding: @padding-xs-vertical @padding-xs-horizontal;
+	border-radius: @border-radius-base;
+}
+
+.select {
+	// margin-right: @padding-small-horizontal;
+}
+.select select {
+	-webkit-appearance: none;
+	background: transparent;
+	border-color: @flarum-body-secondary-color;
+	color: @flarum-body-muted-color;
+	padding: @padding-small-vertical @padding-small-horizontal;
+	padding-right: @padding-small-horizontal + 16;
+	outline: none;
+	border-radius: @border-radius-base;
+	cursor: pointer;
+	margin-right: -3px;
+	line-height: 1.5em;
+}
+.select i {
+	margin-left: -@padding-small-horizontal - 12;
+	pointer-events: none;
+	font-size: 12px;
+	color: @flarum-body-muted-color;
+}
+
+
+.controls.open {
+	display: block !important;
+}
+
+
+.tooltip-inner {
+	padding: 5px 8px;
+}
+
+#message-container {
+	position: fixed;
+	left: 0;
+	right: 0;
+	top: 2vh;
+	text-align: center;
+	z-index: 100;
+	pointer-events: none;
+}
+.message {
+	background: fade(#feb, 95%);
+	box-shadow: 0 0 0 1px fade(#c2991a, 40%), 0 3px 3px rgba(0, 0, 0, 0.1);
+	padding: @padding-base-vertical @padding-base-horizontal;
+	display: inline-block;
+	border-radius: @border-radius-base;
+	pointer-events: auto;
+	text-align: left;
+	&, & a {
+		color: #a61;
+	}
+}
+.message a {
+	font-weight: bold;
+	margin: 0 -10px 0 0;
+	padding: 10px;
+}
+.message-warning {
+	background: fade(#c21a1a, 95%);
+	box-shadow: 0 0 0 1px fade(#911, 80%), 0 3px 3px rgba(0, 0, 0, 0.1);
+	&, & a {
+		color: #fff;
+	}
+}
+.message-text {
+	display: inline-block;
+	max-width: 400px;
+	vertical-align: middle;
+}
+.message-actions {
+	display: inline-block;
+	vertical-align: middle;
+}
+
+.avatar, .avatar img {
+	display: inline-block;
+	width: 48px;
+	height: 48px;
+	border-radius: 24px;
+	color: #fff;
+	font-size: 26px;
+	font-weight: 300;
+	text-align: center;
+	line-height: 48px;
+	vertical-align: top;
+}
+.avatar-thumb, .avatar-thumb img {
+	width: 24px;
+	height: 24px;
+	font-size: 13px;
+	line-height: 24px;
+	vertical-align: middle;
+}
+
+#session {
+	// position: fixed;
+	// top: 10px;
+	// right: 10px;
+	// background: darken(@flarum-body-background-color, 3%);
+	// box-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
+	// border-radius: 24px;
+	// padding: 5px;
+	z-index: 100;
+	text-align: center;
+}
+#session > ul {
+	list-style-type: none;
+	padding: 0;
+	margin: 0;
+	.clearfix();
+}
+#session > ul > li {
+	display: inline-block;
+	// border-right: 1px solid @flarum-bg-secondary-color;
+	vertical-align: middle;
+}
+#session > ul > li:last-of-type {
+	border-right: 0;
+}
+#session > ul > li > a, #session > ul > li > .dropdown > a {
+	display: block;
+	height: 24px;
+	line-height: 24px;
+	padding: 0 10px;
+}
+#session .avatar-thumb {
+	margin-right: 10px;
+	margin-left: -10px;
+	vertical-align: 0;
+}
+#session .notifications-dropdown .dropdown-toggle i {
+	font-size: 14px;
+	// color: @flarum-bg-muted-color;
+}
+#session .badge {
+	background: #e00;
+	color: #fff;
+	font-size: 10px;
+	font-weight: bold;
+	border-radius: 3px;
+	padding: 2px 3px;
+	position: absolute;
+	top: -1px;
+	right: 8px;
+	line-height: 10px;
+}
+
+
+
+.btn-icon {
+	color: @flarum-body-muted-color;
+	font-size: 14px;
+	padding-left: 7px;
+	padding-right: 7px;
+	background: transparent;
+	border-color: transparent;
+}
+.btn-icon:hover {
+	// color: @flarum-body-muted-color;
+	text-decoration: none;
+}
+
+
+.dropdown-menu .fa {
+	font-size: 14px;
+}
+
+
+.loading {
+	color: @flarum-body-muted-color;
+	padding: 50px 0;
+}
+
+/* Full width styles */
+@media (min-width: @screen-md-min) {
+	.container {
+		max-width: none;
+		width: auto;
+		padding: 0;
+	}
+	#sidebar {
+		width: 325px;
+		padding-left: 25px;
+		background: @flarum-background-color;
+		background-image: @flarum-background-image;
+	}
+	#body {
+		margin-left: 325px;
+		border-radius: 0;
+		// max-width: 900px;
+	}
+	#wrapper {
+		padding: 0;
+	}
+}
+
+body {
+	background: @flarum-body-background-color;
+}
+
+
+
+/* New post */
+.composer {
+	position: fixed;
+	left: 330px;
+	right: 30px;
+	bottom: -100%;
+	// right: 0;
+	z-index: 9;
+	max-width: 800px;
+	// transition: bottom 0.5s;
+}
+#body {
+	// transition: padding-bottom 0.5s;
+}
+.composer-handle {
+	// background: @flarum-background-color;
+	cursor: row-resize;
+	// height: 5px;
+}
+.composer-body {
+	padding: 15px 15px 0;
+	border: 1px solid @flarum-body-primary-color;
+	border-radius: 4px;
+	background: @flarum-body-background-color;
+	// background: @flarum-body-background-color;
+	box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
+}
+.composer-controls {
+	position: absolute;
+	right: 15px;
+	top: 15px;
+}
+.composer-controls a {
+	color: @flarum-body-muted-color;
+	font-size: 14px;
+	margin-left: 5px;
+}
+.composer-controls a:hover {
+	color: @flarum-body-primary-color;
+	font-size: 14px;
+	margin-left: 5px;
+}
+.composer h3 {
+	font-size: 13px;
+	color: @flarum-body-muted-color;
+	font-weight: 400;
+	margin: 0 0 15px;
+	padding-bottom: 15px;
+	border-bottom: 1px solid @flarum-body-secondary-color;
+}
+
+.composer-editor {
+
+}
+.composer-editor textarea {
+	.box-shadow(none);
+	min-height: 200px;
+	background: @flarum-body-background-color;
+	border: 0;
+	resize: none;
+	padding: 0;
+	// padding-bottom: 0;
+	&:focus {
+		.box-shadow(none);
+	}
+}
+.composer-editor-controls {
+	padding: 15px 0;
+	.clearfix();
+}
+.composer-editor-controls .btn {
+}
+.composer-editor-controls .pull-left > .btn:first-child {
+	margin-right: 5px;
+}
+.composer-editor-controls .pull-right > .btn {
+	margin-left: 5px;
+}
+
+
+.composer.collapsed .composer-handle {
+	height: 5px;
+}
+.composer.collapsed .composer-body {
+	padding-bottom: 1px;
+}
+.composer.collapsed .composer-editor {
+	display: none;
+}
+
+
+.highlight-keyword {
+	background: #fff28e;
+	padding: 1px 4px;
+	border-radius: 3px;
+	color: @flarum-body-text-color;
+}
+
+
+
+
+/* Small devices (tablets, 768px and up) */
+@media (min-width: @screen-sm-min) {
+	#sidebar-header .mobile-header {
+		display: none;
+	}
+}
+
+/* Medium devices (desktops, 992px and up) */
+@media (min-width: @screen-md-min) {
+
+}
+
+/* Large devices (large desktops, 1200px and up) */
+@media (min-width: @screen-lg-min) {
+
+}
+
+
+@media (max-width: @screen-sm-max) {
+	body {
+		padding-top: 45px;
+	}
+	#sidebar-header header {
+		display: none;
+	}
+	#sidebar-header .mobile-header {
+		display: block;
+		position: fixed;
+		top: 0;
+		left: 0;
+		right: 0;
+		background: @flarum-background-color;
+		padding: 12px 10px;
+		color: @flarum-bg-primary-color;
+		text-align: center;
+		z-index: 20;
+		height: 45px;
+	}
+	#sidebar-header .mobile-header h1 {
+		font-size: 16px;
+		font-weight: 600;
+		display: inline-block;
+		width: 80%;
+		margin: 0;
+		white-space: nowrap;
+		text-overflow: ellipsis;
+		overflow: hidden;
+	}
+	#sidebar-header .mobile-header .back {
+		position: absolute;
+		left: 15px;
+		top: 10px;
+		font-size: 18px;
+	}
+	#sidebar-header .search {
+		padding: 10px 15px;
+		margin: 0;
+		background: lighten(@flarum-body-secondary-color, 5%);
+		& .search-input input {
+			border: 0;
+			background: #fff;
+		}
+	}
+
+	#sidebar-footer {
+		display: none;
+	}
+
+	#sidebar-content {
+		overflow: hidden;
+		height: auto;
+		-webkit-overflow-scrolling: touch;
+		padding: 0;
+		margin: 0;
+		display: block;
+		position: fixed;
+		bottom: 0;
+		left: 0;
+		right: 0;
+		top: auto;
+		background: #fff;
+		box-shadow: 0 -1px 0 rgba(0, 0, 0, 0.15);
+		height: 45px;
+		width: auto;
+		z-index: 20;
+	}
+	#sidebar-content .toolbar {
+		margin: 0;
+		padding: 0;
+		display: table;
+		list-style-type: none;
+		width: 100%;
+
+		& li {
+			display: table-cell;
+			text-align: center;
+		}
+		& li:first-of-type {
+			text-align: left;
+		}
+		& li:last-of-type {
+			text-align: right;
+		}
+
+		& .btn {
+			padding: 13px;
+			transition: opacity 0.3s;
+			&:active {
+				opacity: 0.25;
+			}
+		}
+		& .btn, & .btn-default, & .btn-group {
+			display: inline-block;
+			background: none !important;
+			border: 0 !important;
+			box-shadow: none !important;
+			width: auto;
+
+			& span {
+				display: none;
+			}
+		}
+	}
+	#sidebar-content .body {
+		display: none;
+	}
+
+	.discussion-scrubber {
+		& .scrubber-first, & .scrollbar-before, & .handle, & .description, & .scrollbar-after, & .scrubber-last {
+			display: none;
+		}
+		& .info {
+			padding: 15px;
+		}
+	}
+
+
+
+	#body {
+		margin-left: 0;
+		padding: 0;
+	}
+	#wrapper {
+		padding: 0;
+	}
+}
diff --git a/ember/app/templates/.gitkeep b/ember/app/templates/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/ember/app/templates/application.hbs b/ember/app/templates/application.hbs
new file mode 100644
index 000000000..053e7f0ce
--- /dev/null
+++ b/ember/app/templates/application.hbs
@@ -0,0 +1,56 @@
+{{outlet "modal"}}
+
+{{#if notificationMessage}}
+	
+ {{notification-message message=notificationMessage closeAction="hideMessage"}} +
+{{/if}} + + diff --git a/ember/app/templates/components/.gitkeep b/ember/app/templates/components/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ember/app/templates/components/item-collection.hbs b/ember/app/templates/components/item-collection.hbs new file mode 100644 index 000000000..0eddfdb34 --- /dev/null +++ b/ember/app/templates/components/item-collection.hbs @@ -0,0 +1,3 @@ +{{#each item in items}} + {{view item}} +{{/each}} diff --git a/ember/app/templates/components/menu-list.hbs b/ember/app/templates/components/menu-list.hbs new file mode 100644 index 000000000..40dd5f6ea --- /dev/null +++ b/ember/app/templates/components/menu-list.hbs @@ -0,0 +1,3 @@ +{{#each item in items}} +
  • {{view item}}
  • +{{/each}} diff --git a/ember/app/templates/components/menu-split.hbs b/ember/app/templates/components/menu-split.hbs new file mode 100644 index 000000000..13978bdd6 --- /dev/null +++ b/ember/app/templates/components/menu-split.hbs @@ -0,0 +1,13 @@ +{{#if items}} + +{{/if}} diff --git a/ember/app/templates/components/notification-message.hbs b/ember/app/templates/components/notification-message.hbs new file mode 100644 index 000000000..43cee91e3 --- /dev/null +++ b/ember/app/templates/components/notification-message.hbs @@ -0,0 +1,9 @@ +
    + {{message.text}} + + Undo + {{#if message.dismissable}} + + {{/if}} + +
    diff --git a/ember/app/templates/components/post-type-comment.hbs b/ember/app/templates/components/post-type-comment.hbs new file mode 100644 index 000000000..8af1bacd7 --- /dev/null +++ b/ember/app/templates/components/post-type-comment.hbs @@ -0,0 +1,26 @@ +{{#if post.deleteTime}} + +{{/if}} + +
    +

    + {{#link-to "user" post.user}}{{user-avatar post.user}} {{post.user.username}}{{/link-to}} +

    +
    + +
    + {{{post.contentHtml}}} +
    + + diff --git a/ember/app/templates/components/post-type-title.hbs b/ember/app/templates/components/post-type-title.hbs new file mode 100644 index 000000000..f87bdd2f7 --- /dev/null +++ b/ember/app/templates/components/post-type-title.hbs @@ -0,0 +1,4 @@ +
    + + {{#link-to "user" post.user}}{{post.user.username}}{{/link-to}} named the discussion: {{post.content}}. +
    diff --git a/ember/app/templates/components/search-input.hbs b/ember/app/templates/components/search-input.hbs new file mode 100644 index 000000000..ce9b004ad --- /dev/null +++ b/ember/app/templates/components/search-input.hbs @@ -0,0 +1,2 @@ +{{input type="text" placeholder="Search forum..." class="form-control" value=value action="search"}} +{{fa-icon "times-circle"}} diff --git a/ember/app/templates/components/text-editor.hbs b/ember/app/templates/components/text-editor.hbs new file mode 100644 index 000000000..6c6a884a8 --- /dev/null +++ b/ember/app/templates/components/text-editor.hbs @@ -0,0 +1,9 @@ + + +
    +
    + + + +
    +
    diff --git a/ember/app/templates/composer.hbs b/ember/app/templates/composer.hbs new file mode 100644 index 000000000..ec63b0940 --- /dev/null +++ b/ember/app/templates/composer.hbs @@ -0,0 +1,17 @@ +
    + +
    + +
    + + + +
    + +

    {{{title}}}

    + +
    + {{text-editor placeholder=""}} +
    + +
    diff --git a/ember/app/templates/discussion-header.hbs b/ember/app/templates/discussion-header.hbs new file mode 100644 index 000000000..249d31526 --- /dev/null +++ b/ember/app/templates/discussion-header.hbs @@ -0,0 +1,9 @@ +{{#if category}} +

    + {{category}} +

    +{{/if}} + +

    + {{title}} +

    diff --git a/ember/app/templates/discussion-post.hbs b/ember/app/templates/discussion-post.hbs new file mode 100644 index 000000000..aa18b8411 --- /dev/null +++ b/ember/app/templates/discussion-post.hbs @@ -0,0 +1,13 @@ +{{#if view.controls}} +
    + + {{menu-list items=view.controls class="dropdown-menu pull-right"}} +
    +{{/if}} + +{{#link-to "discussion" view.post.discussion (query-params start=view.post.number) class="time"}} + {{abbreviate-time view.post.time}} + {{!-- #{{view.post.number}} (ID: {{view.post.id}}) --}} +{{/link-to}} + +{{post-content view.post}} diff --git a/ember/app/templates/discussion-scrollbar.hbs b/ember/app/templates/discussion-scrollbar.hbs new file mode 100644 index 000000000..8585e3aa1 --- /dev/null +++ b/ember/app/templates/discussion-scrollbar.hbs @@ -0,0 +1,20 @@ +Original Post +
    +
    +
    +
    +
    + 0 of {{postStream.count}} posts + +
    +
    +
    + {{#if relevantPostRanges}} +
      + {{#each range in relevantPostRanges}} +
    • + {{/each}} +
    + {{/if}} +
    +Now diff --git a/ember/app/templates/discussion.hbs b/ember/app/templates/discussion.hbs new file mode 100644 index 000000000..67084ba6b --- /dev/null +++ b/ember/app/templates/discussion.hbs @@ -0,0 +1,19 @@ + + +
    +{{#each item in postStream}} + + {{#view "discussion-item" item=item}} + {{#if item.post}}{{view "discussion-post" post=item.post}}{{/if}} + {{/view}} + +{{/each}} +
    + +{{#if postStream.lastLoaded}} +
    + {{menu-list items=view.footerControls class="list-inline"}} +
    +{{/if}} diff --git a/ember/app/templates/discussions-header.hbs b/ember/app/templates/discussions-header.hbs new file mode 100644 index 000000000..588e2f38b --- /dev/null +++ b/ember/app/templates/discussions-header.hbs @@ -0,0 +1,7 @@ +

    + {{#if searchQuery}} + {{fa-icon "search"}} {{searchQuery}} + {{else}} + All Discussions + {{/if}} +

    diff --git a/ember/app/templates/discussions-nav.hbs b/ember/app/templates/discussions-nav.hbs new file mode 100644 index 000000000..a95a957a1 --- /dev/null +++ b/ember/app/templates/discussions-nav.hbs @@ -0,0 +1,4 @@ + +{{#link-to "discussions"}}{{index}} of {{count}} {{view.type}}{{/link-to}} +{{#link-to "discussion" previous classNameBindings="previous::disabled"}}{{fa-icon "chevron-up"}}{{/link-to}} +{{#link-to "discussion" next classNameBindings="next::disabled"}}{{fa-icon "chevron-down"}}{{/link-to}} diff --git a/ember/app/templates/discussions-result.hbs b/ember/app/templates/discussions-result.hbs new file mode 100644 index 000000000..75a56acfe --- /dev/null +++ b/ember/app/templates/discussions-result.hbs @@ -0,0 +1,60 @@ +
    + + {{menu-list items=view.controls class="dropdown-menu pull-right"}} +
    + +
    + + {{!-- {{#if view.action}} + + {{partial view.action}} + + {{/if}} --}} + + + {{#if discussion.unread}} + {{discussion.unread}} + {{/if}} + + + {{#link-to "discussion" discussion.content (query-params searchQuery=searchQuery) class="info"}} + {{#if discussion.category}} + {{discussion.category}} + {{/if}} + + {{!-- {{#if discussion.following}}{{fa-icon "star" class="icon icon-following"}}{{/if}} --}} + {{#if discussion.sticky}}{{fa-icon "thumb-tack" class="icon icon-sticky"}}{{/if}} + {{#if discussion.locked}}{{fa-icon "lock" class="icon icon-locked"}}{{/if}} + {{highlight-words discussion.title searchQuery}} + + {{#if discussion.sticky}} + {{discussion.excerpt}} + {{/if}} + {{/link-to}} + + + {{#if displayStartPosts}} + {{#link-to "user" discussion.startUser}}{{user-avatar discussion.startUser class="avatar-thumb"}}{{/link-to}} + {{#link-to "discussion" discussion.content}}{{abbreviate-time discussion.startTime}}{{/link-to}} + {{else}} + {{#link-to "user" discussion.lastUser}}{{user-avatar discussion.lastUser class="avatar-thumb"}}{{/link-to}} + {{#link-to "discussion" discussion.content (query-params start="last")}}{{abbreviate-time discussion.lastTime}}{{/link-to}} + {{/if}} + + + {{abbreviate-number discussion.repliesCount}} + + {{#if view.relevantPosts}} +
    + {{#each post in view.relevantPosts}} + {{#link-to "discussion" discussion.content (query-params start=post.number) class="post item"}} + {{user-avatar post.user class="avatar-thumb"}} + {{highlight-words post.relevantContent searchQuery}} + {{/link-to}} + {{/each}} +
    + {{/if}} + + {{render-hook "discussions-result"}} + +
    diff --git a/ember/app/templates/discussions.hbs b/ember/app/templates/discussions.hbs new file mode 100644 index 000000000..2f6891cab --- /dev/null +++ b/ember/app/templates/discussions.hbs @@ -0,0 +1,46 @@ +
    + + + {{#if resultsLoading}} + {{loading-indicator size="small"}} + {{else}} +
      + {{#each discussion in content}} + {{view "discussions-result" discussion=discussion}} + {{/each}} +
    + + {{#if moreResults}} +
    + {{#if loadingMore}} + {{loading-indicator size="small"}} + {{else}} + Load More + {{/if}} +
    + {{/if}} + {{/if}} +
    + +
    + {{liquid-outlet}} +
    diff --git a/ember/app/templates/error.hbs b/ember/app/templates/error.hbs new file mode 100644 index 000000000..de7c858be --- /dev/null +++ b/ember/app/templates/error.hbs @@ -0,0 +1,5 @@ +

    Oops! Something went wrong.

    + +

    {{message}}

    + +
    {{stack}}
    diff --git a/ember/app/templates/loading.hbs b/ember/app/templates/loading.hbs new file mode 100644 index 000000000..646f8bae4 --- /dev/null +++ b/ember/app/templates/loading.hbs @@ -0,0 +1 @@ +{{loading-indicator size="large"}} diff --git a/ember/app/templates/login.hbs b/ember/app/templates/login.hbs new file mode 100644 index 000000000..f81da3270 --- /dev/null +++ b/ember/app/templates/login.hbs @@ -0,0 +1,27 @@ + diff --git a/ember/app/templates/session.hbs b/ember/app/templates/session.hbs new file mode 100644 index 000000000..f8b1f9514 --- /dev/null +++ b/ember/app/templates/session.hbs @@ -0,0 +1,36 @@ +
    + {{#if session.user}} + + {{else}} +
    +
    + Log In +
    +
    + Sign Up +
    +
    + {{/if}} +
    diff --git a/ember/app/transitions.js b/ember/app/transitions.js new file mode 100644 index 000000000..52c15b3b9 --- /dev/null +++ b/ember/app/transitions.js @@ -0,0 +1,12 @@ +export default function() { + this.transition( + this.fromRoute('discussions-sidebar'), + this.toRoute('discussion-sidebar'), + this.use('slideLeft') + ); + this.transition( + this.fromRoute('discussions'), + this.toRoute('discussion'), + this.use('slideLeft') + ); +} diff --git a/ember/app/transitions/slide-left.js b/ember/app/transitions/slide-left.js new file mode 100644 index 000000000..4fca2cf96 --- /dev/null +++ b/ember/app/transitions/slide-left.js @@ -0,0 +1,2 @@ +import { curryTransition } from "vendor/liquid-fire"; +export default curryTransition('slide', 'x', -1, { duration: 300 }); diff --git a/ember/app/transitions/slide-right.js b/ember/app/transitions/slide-right.js new file mode 100644 index 000000000..5be9c7b5b --- /dev/null +++ b/ember/app/transitions/slide-right.js @@ -0,0 +1,2 @@ +import { curryTransition } from "vendor/liquid-fire"; +export default curryTransition('slide', 'x', 1, { duration: 300 }); diff --git a/ember/app/transitions/slide.js b/ember/app/transitions/slide.js new file mode 100644 index 000000000..9026f0ba8 --- /dev/null +++ b/ember/app/transitions/slide.js @@ -0,0 +1,48 @@ +import { stop, animate, Promise, isAnimating, finish } from "vendor/liquid-fire"; + +export default function slide(oldView, insertNewView, dimension, direction, opts) { + var oldParams = {}, + newParams = {}, + firstStep, + property, + measure; + + if (dimension.toLowerCase() === 'x') { + property = 'translateX'; + measure = 'width'; + } else { + property = 'translateY'; + measure = 'height'; + } + + if (isAnimating(oldView, 'moving-in')) { + firstStep = finish(oldView, 'moving-in'); + } else { + stop(oldView); + firstStep = Promise.cast(); + } + + + return firstStep.then(insertNewView).then(function(newView){ + // if (newView && newView.$() && oldView && oldView.$()) { + // var sizes = [parseInt(newView.$().css(measure), 10), + // parseInt(oldView.$().css(measure), 10)]; + // var bigger = Math.max.apply(null, sizes); + var bigger = 20; + oldParams[property] = (bigger * direction) + 'px'; + newParams[property] = ["0px", (-1 * bigger * direction) + 'px']; + // } + // else { + // oldParams[property] = (100 * direction) + '%'; + // newParams[property] = ["0%", (-100 * direction) + '%']; + // } + + oldParams['opacity'] = [0, 1]; + newParams['opacity'] = [1, 0]; + + return Promise.all([ + animate(oldView, oldParams, opts), + animate(newView, newParams, opts, 'moving-in') + ]); + }); +} diff --git a/ember/app/utils/.gitkeep b/ember/app/utils/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ember/app/utils/menu.js b/ember/app/utils/menu.js new file mode 100644 index 000000000..ec1b655fb --- /dev/null +++ b/ember/app/utils/menu.js @@ -0,0 +1,33 @@ +import Ember from 'ember'; + +import NamedContainerView from './named-container-view'; +import MenuItemSeparator from '../components/menu-item-separator'; + +export default NamedContainerView.extend({ + + tagName: 'ul', + + active: null, + + i: 1, + addSeparator: function(index) { + var item = MenuItemSeparator; + this.addItem('separator'+(this.i++), item, index); + }, + + activeChanged: function() { + var active = this.get('active'); + if (typeof active != 'array') { + active = [active]; + } + + var namedViews = this.get('namedViews'); + var view; + for (var name in namedViews) { + if (namedViews.hasOwnProperty(name) && (view = namedViews.get(name))) { + view.set('active', active.indexOf(name) !== -1); + } + } + }.observes('active') + +}); diff --git a/ember/app/utils/named-container-view.js b/ember/app/utils/named-container-view.js new file mode 100644 index 000000000..539d4d5a2 --- /dev/null +++ b/ember/app/utils/named-container-view.js @@ -0,0 +1,65 @@ +import Ember from 'ember'; + +export default Ember.ArrayProxy.extend({ + + content: null, + + namedViews: null, + + init: function() { + this.set('content', Ember.A()); + this.set('namedViews', Ember.Object.create()); + this._super(); + }, + + // Add an item to the container. + addItem: function(name, viewClass, index) { + // view = this.createChildView(view); + + if (typeof index == 'undefined') { + index = this.get('length'); + } + this.replace(index, 0, [viewClass]); + this.get('namedViews').set(name, viewClass); + }, + + // Remove an item from the container. + removeItem: function(name) { + this.removeObject(this.get('namedViews').get(name)); + this.get('namedViews').set(name, null); + }, + + // Replace an item in the container with another one. + replaceItem: function(name, viewClass) { + // view = this.createChildView(view); + + var oldView = this.get('namedViews').get(name); + var index = this.indexOf(oldView); + this.replace(index, 1, [viewClass]) + this.get('namedViews').set(name, viewClass); + }, + + // Move an item in the container to a new position. + moveItem: function(name, index) { + var view = this.get('namedViews').get(name); + this.removeItem(name); + this.addItem(name, view, index); + }, + + firstItem: function() { + return this.objectAt(0); + }.property(), + + secondItem: function() { + return this.objectAt(1); + }.property(), + + remainingItems: function() { + return this.slice(2); + }.property(), + + getItem: function(name) { + return this.get('namedViews').get(name); + } + +}); diff --git a/ember/app/utils/plugin.js b/ember/app/utils/plugin.js new file mode 100644 index 000000000..74cf1971f --- /dev/null +++ b/ember/app/utils/plugin.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.Object.extend({ + +}); diff --git a/ember/app/utils/scrollbar.js b/ember/app/utils/scrollbar.js new file mode 100644 index 000000000..9bbbba800 --- /dev/null +++ b/ember/app/utils/scrollbar.js @@ -0,0 +1,62 @@ +// TODO probably change this into an Ember object/merge it into discussion-scrollbar + +var Scrollbar = function(element) { + this.$ = $(element); + this.count = 1; + this.index = 0; + this.visible = 1; + this.disabled = false; +}; + +Scrollbar.prototype = { + + setIndex: function(index) { + this.index = index; + }, + + setVisible: function(visible) { + this.visible = visible; + }, + + setCount: function(count) { + this.count = count; + }, + + setDisabled: function(disabled) { + this.disabled = disabled; + }, + + percentPerPost: function() { + // 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. Subsequently, 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.$.outerHeight() * 100; + var percentPerVisiblePost = Math.max(100 / this.count, minPercentVisible / this.visible); + var percentPerPost = this.count == this.visible ? 0 : (100 - percentPerVisiblePost * this.visible) / (this.count - this.visible); + + return { + index: percentPerPost, + visible: percentPerVisiblePost + }; + }, + + update: function(animate) { + var percentPerPost = this.percentPerPost(); + + var before = percentPerPost.index * this.index, + slider = Math.min(100 - before, percentPerPost.visible * this.visible), + func = animate ? 'animate' : 'css'; + + this.$.find('.scrollbar-before').stop(true)[func]({height: before+'%'}).css('overflow', 'visible'); + this.$.find('.scrollbar-slider').stop(true)[func]({height: slider+'%'}).css('overflow', 'visible'); + this.$.find('.scrollbar-after').stop(true)[func]({height: (100 - before - slider)+'%'}).css('overflow', 'visible'); + + this.$.toggleClass('disabled', this.disabled || slider >= 100); + } + +}; + +export default Scrollbar; diff --git a/ember/app/views/.gitkeep b/ember/app/views/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ember/app/views/application.js b/ember/app/views/application.js new file mode 100644 index 000000000..1a9d1a916 --- /dev/null +++ b/ember/app/views/application.js @@ -0,0 +1,9 @@ +import Ember from 'ember'; + +export default Ember.View.extend({ + + title: function() { + return this.get('controller.forumTitle'); + }.property('controller.forumTitle') + +}); diff --git a/ember/app/views/composer.js b/ember/app/views/composer.js new file mode 100644 index 000000000..ee49d24c8 --- /dev/null +++ b/ember/app/views/composer.js @@ -0,0 +1,37 @@ +import Ember from 'ember'; + +export default Ember.View.extend({ + + classNames: ['composer'], + classNameBindings: ['controller.showing:showing'], + + showingChanged: function() { + if (this.$()) { + var view = this; + this.$().animate({bottom: this.get('controller.showing') ? 20 : -this.$().height()}, 'fast', function() { + if (view.get('controller.showing')) { + $(this).find('textarea').focus(); + } + }); + $('#body').animate({marginBottom: this.get('controller.showing') ? this.$().height() + 20 : 0}, 'fast'); + } + }.observes('controller.showing'), + + panePinnedChanged: function() { + if (this.$()) { + var discussions = this.get('controller.controllers.discussions'); + var $this = this.$(); + Ember.run.scheduleOnce('afterRender', function() { + var discussion = $('.discussion-pane'); + var width = discussion.length ? discussion.offset().left : $('#body').offset().left; + $this.css('left', width); + }); + } + }.observes('controller.controllers.discussions.paned', 'controller.controllers.discussions.panePinned'), + + didInsertElement: function() { + this.showingChanged(); + this.panePinnedChanged(); + } + +}); diff --git a/ember/app/views/discussion-item.js b/ember/app/views/discussion-item.js new file mode 100644 index 000000000..060a75d2c --- /dev/null +++ b/ember/app/views/discussion-item.js @@ -0,0 +1,132 @@ +import Ember from 'ember'; + +// A discussion 'item' represents one item in the post stream. In other words, a +// single item may represent a single post, or it may represent a gap of many +// posts which have not been loaded. + +export default Ember.View.extend({ + classNames: ['item'], + classNameBindings: ['item.gap:gap', 'loading', 'direction'], + attributeBindings: [ + 'start:data-start', + 'end:data-end', + 'time:data-time', + 'number:data-number' + ], + + start: function() { + return this.get('item.indexStart'); + }.property('item.indexStart'), + + end: function() { + return this.get('item.indexEnd'); + }.property('item.indexEnd'), + + count: function() { + return this.get('end') - this.get('start') + 1; + }.property('start', 'end'), + + time: function() { + return this.get('item.post.time'); + }.property('item.post.time'), + + number: function() { + return this.get('item.post.number'); + }.property('item.post.number'), + + loading: function() { + return this.get('item.loading'); + }.property('item.loading'), + + direction: function() { + return this.get('item.direction'); + }.property(), + + loadingChanged: function() { + this.rerender(); + }.observes('loading'), + + render: function(buffer) { + if (! this.get('item.gap')) { + return this._super(buffer); + } + + buffer.push(''); + if (this.get('loading')) { + buffer.push(' '); + } else { + buffer.push(this.get('count')+' more post'+(this.get('count') != 1 ? 's' : '')); + } + buffer.push(''); + }, + + didInsertElement: function() { + if (! this.get('item.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) { + 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('item.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. + // However, we want to maintain the current scroll position relative to + // the content after the gap. To do this, we need to find item directly + // after the gap and use it as an anchor. + if (this.get('direction') === 'up') { + var anchor = this.$().nextAll('.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('controller.postStream').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.get('controller').send( + 'loadRange', + this.get('start') + (relativeIndex || 0), + this.get('end'), + this.get('direction') === 'up' + ); + }, + + click: function() { + this.load(); + } +}); diff --git a/ember/app/views/discussion-post.js b/ember/app/views/discussion-post.js new file mode 100644 index 000000000..4feb66cfe --- /dev/null +++ b/ember/app/views/discussion-post.js @@ -0,0 +1,51 @@ +import Ember from 'ember'; + +import Menu from '../utils/menu'; +import MenuItem from '../components/menu-item'; + +export default Ember.View.extend({ + tagName: 'article', + templateName: 'discussion-post', + + controls: null, + + classNames: ['post'], + classNameBindings: ['post.deleted', 'post.edited'], + + construct: function() { + this.set('controls', Menu.create()); + + var post = this.get('post'); + + if (post.get('deleted')) { + this.addControl('restore', 'Restore', 'reply', 'canEdit'); + this.addControl('delete', 'Delete', 'times', 'canDelete'); + } else { + if (post.get('type') == 'comment') { + this.addControl('edit', 'Edit', 'pencil', 'canEdit'); + this.addControl('hide', 'Delete', 'times', 'canEdit'); + } else { + this.addControl('delete', 'Delete', 'times', 'canDelete'); + } + } + }.on('init'), + + didInsertElement: function() { + this.$().hide().fadeIn('slow'); + }, + + addControl: function(tag, title, icon, permissionAttribute) { + if (permissionAttribute && ! this.get('post').get(permissionAttribute)) { + return; + } + + var self = this; + var action = function(post) { + self.get('controller').send(actionName, post); + }; + + var item = MenuItem.extend({title: title, icon: icon, action: action}); + this.get('controls').addItem(tag, item); + } + +}); diff --git a/ember/app/views/discussion-scrollbar.js b/ember/app/views/discussion-scrollbar.js new file mode 100644 index 000000000..f5589189c --- /dev/null +++ b/ember/app/views/discussion-scrollbar.js @@ -0,0 +1,280 @@ +import Ember from 'ember'; + +import MenuItem from '../components/menu-item'; +import Scrollbar from '../utils/scrollbar'; +import PostStreamMixin from '../mixins/post-stream'; + +export default Ember.View.extend(PostStreamMixin, { + + /** + @property templateName + @type String + */ + templateName: 'discussion-scrollbar', + classNames: ['scrubber', 'discussion-scrubber'], + + // An object which represents/ecapsulates the scrollbar. + scrollbar: null, + + // Right after the controller finished loading a discussion, we want to + // trigger a scroll event on the window so the interface is kept up-to-date. + loadedChanged: function() { + this.scrollbar.setDisabled(! this.get('controller.loaded')); + }.observes('controller.loaded'), + + countChanged: function() { + this.scrollbar.setCount(this.get('controller.postStream.count')); + }.observes('controller.postStream.count'), + + windowWasResized: function(event) { + var view = event.data.view; + view.scrollbar.$.height($('#sidebar-content').height() + $('#sidebar-content').offset().top - view.scrollbar.$.offset().top - 80); + view.scrollbar.update(); + }, + + windowWasScrolled: function(event) { + var view = event.data.view, + $window = $(window); + + if (! view.get('controller.loaded') || $window.data('disableScrollHandler')) { + return; + } + + var scrollTop = $window.scrollTop(), + windowHeight = $window.height(); + + // Before looping through all of the posts, we reset the scrollbar + // properties to a 'default' state. These values reflect what would be + // seen if the browser were scrolled right up to the top of the page, + // and the viewport had a height of 0. + var index = $('.posts .item:first').data('end'); + var visiblePosts = 0; + var period = ''; + + var first = $('.posts .item[data-start=0]'); + var offsetTop = first.length ? first.offset().top : 0; + + // Now loop through each of the items in the discussion. An 'item' is + // either a single post or a 'gap' of one or more posts that haven't + // been loaded yet. + // @todo cache item top positions to speed this up? + $('.posts .item').each(function(k) { + var $this = $(this), + top = $this.offset().top - offsetTop, + height = $this.outerHeight(); + + // If this item is above the top of the viewport, skip to the next + // post. If it's below the bottom of the viewport, break out of the + // loop. + if (top + height < scrollTop) { + return; + } + if (top > scrollTop + windowHeight) { + return false; + } + + // If the bottom half of this item is visible at the top of the + // viewport, then add the visible proportion to the visiblePosts + // counter, and set the scrollbar index to whatever the visible + // proportion represents. For example, if a gap represents indexes + // 0-9, and the bottom 50% of the gap is visible in the viewport, + // then the scrollbar index will be 5. + if (top <= scrollTop && top + height > scrollTop) { + visiblePosts = (top + height - scrollTop) / height; + index = parseFloat($this.data('end')) + 1 - visiblePosts; + } + + // If the top half of this item is visible at the bottom of the + // viewport, then add the visible proportion to the visiblePosts + // counter. + else if (top + height >= scrollTop + windowHeight) { + visiblePosts += (scrollTop + windowHeight - top) / height; + } + + // If the whole item is visible in the viewport, then increment the + // visiblePosts counter. + else { + visiblePosts++; + } + + // If this item has a time associated with it, then set the + // scrollbar's current period to a formatted version of this time. + if ($this.data('time')) { + period = $this.data('time'); + } + }); + + // Now that we've looped through all of the items and have worked out + // the scrollbar's current index and the number of posts visible in the + // viewport, we can update the scrollbar. + view.scrollbar.setIndex(index); + view.scrollbar.setVisible(visiblePosts); + view.scrollbar.update(); + + view.scrollbar.$.find('.index').text(Math.ceil(index + visiblePosts)); + view.scrollbar.$.find('.description').text(moment(period).format('MMMM YYYY')); + }, + + mouseWasMoved: function(event) { + var view = event.data.view; + + if ( ! event.data.handle) { + return; + } + + var offsetPixels = event.clientY - event.data.mouseStart; + var offsetPercent = offsetPixels / view.scrollbar.$.outerHeight() * 100; + + var offsetIndex = offsetPercent / view.scrollbar.percentPerPost().index; + var newIndex = Math.max(0, Math.min(event.data.indexStart + offsetIndex, view.scrollbar.count - 1)); + + view.scrollToIndex(newIndex); + }, + + mouseWasReleased: function(event) { + var view = event.data.view; + + if (! event.data.handle) { + return; + } + + event.data.mouseStart = 0; + event.data.indexStart = 0; + event.data.handle = null; + + $(window).data('disableScrollHandler', false); + + view.get('controller').send('jumpToIndex', Math.floor(view.scrollbar.index)); + + $(window).scroll(); + $('body').css('cursor', ''); + }, + + didInsertElement: function() { + var view = this; + + // Set up scrollbar object + this.scrollbar = new Scrollbar($('.discussion-scrubber .scrollbar')); + this.scrollbar.setDisabled(true); + this.countChanged(); + this.loadedChanged(); + + // Whenever the window is resized, adjust the height of the scrollbar + // so that it fills the height of the sidebar. + $(window).on('resize', {view: this}, this.windowWasResized).resize(); + + // Define a handler to update the state of the scrollbar to reflect the + // current scroll position of the page. + $(window).on('scroll', {view: this}, this.windowWasScrolled); + + this.get('controller').on('loadingIndex', this, this.loadingIndex); + + // Now we want to make the scrollbar handle draggable. Let's start by + // preventing default browser events from messing things up. + this.scrollbar.$ + .css('user-select', 'none') + .bind('dragstart mousedown', function(e) { + e.preventDefault(); + }); + + // When the mouse is pressed on the scrollbar handle, we need to capture + // some information about the current position. + var scrollData = { + view: this, + mouseStart: 0, + indexStart: 0, + handle: null + }; + + this.scrollbar.$.find('.scrollbar-slider').css('cursor', 'move').mousedown(function(e) { + scrollData.mouseStart = e.clientY; + scrollData.indexStart = view.scrollbar.index; + scrollData.handle = $(this); + $(window).data('disableScrollHandler', true); + $('body').css('cursor', 'move'); + }); + + // When the mouse moves, + $(document) + .on('mousemove', scrollData, this.mouseWasMoved) + .on('mouseup', scrollData, this.mouseWasReleased); + + // When any part of the whole scrollbar is clicked, we want to jump to + // that position. + this.scrollbar.$.click(function(e) { + + // Calculate the index which we want to jump to. + // @todo document how this complexity works. + var offsetPixels = e.clientY - view.scrollbar.$.offset().top + $('body').scrollTop(); + var offsetPercent = offsetPixels / view.scrollbar.$.outerHeight() * 100; + + var handleHeight = parseFloat(view.scrollbar.$.find('.scrollbar-slider')[0].style.height); + + var offsetIndex = (offsetPercent - handleHeight / 2) / view.scrollbar.percentPerPost().index; + var newIndex = Math.max(0, Math.min(view.scrollbar.count - 1, offsetIndex)); + + view.get('controller').send('jumpToIndex', Math.floor(newIndex)); + }) + + // Exempt the scrollbar handle from this 'jump to' click event. + this.scrollbar.$.find('.scrollbar-slider').click(function(e) { + e.stopPropagation(); + }); + + }, + + actions: { + firstPost: function() { + this.get('controller').send('jumpToIndex', 0); + }, + + lastPost: function() { + this.get('controller').send('jumpToIndex', this.scrollbar.count - 1); + } + }, + + loadingIndex: function(index) { + this.scrollToIndex(index, true); + }, + + // 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, animate) { + index = Math.max(0, Math.min(index, this.scrollbar.count - 1)); + var indexFloor = Math.floor(index); + + // Find + var nearestItem = this.findNearestToIndex(indexFloor); + var first = $('.posts .item[data-start=0]'); + var offsetTop = first.length ? first.offset().top : 0; + + var pos = nearestItem.offset().top - offsetTop; + if (! nearestItem.is('.gap')) { + pos += nearestItem.outerHeight() * (index - indexFloor); + } else { + nearestItem.addClass('active'); + } + + $('.posts .item.gap').not(nearestItem).removeClass('active'); + + if (animate) { + // $('html, body').animate({scrollTop: pos}); + } else { + $('html, body').scrollTop(pos); + } + this.scrollbar.setIndex(index); + this.scrollbar.update(animate); + }, + + willDestroyElement: function() { + $(window) + .off('resize', this.windowWasResized) + .off('scroll', this.windowWasScrolled); + + $(document) + .off('mousemove', this.mouseWasMoved) + .off('mouseup', this.mouseWasReleased); + + this.get('controller').off('loadingIndex', this, this.loadingIndex); + } +}); diff --git a/ember/app/views/discussion-sidebar.js b/ember/app/views/discussion-sidebar.js new file mode 100644 index 000000000..c798e8545 --- /dev/null +++ b/ember/app/views/discussion-sidebar.js @@ -0,0 +1,50 @@ +import Ember from 'ember'; + +import NamedContainerView from '../utils/named-container-view'; +import Menu from '../utils/menu'; +import MenuSplit from '../components/menu-split'; +import MenuItem from '../components/menu-item'; +import DiscussionScrollbar from './discussion-scrollbar'; + +export default Ember.View.extend({ + + // NamedContainerView which will be rendered in the template. + content: null, + controls: null, + + template: Ember.Handlebars.compile('{{menu-list items=view.toolbar class="toolbar"}}{{menu-list items=view.content class="body"}}'), + + construct: function() { + this.set('toolbar', NamedContainerView.create()); + this.set('content', NamedContainerView.create()); + this.set('controls', Menu.create()); + }.on('init'), + + didInsertElement: function() { + var view = this; + var toolbar = this.get('toolbar'); + var content = this.get('content'); + + var ReplyItem = MenuItem.extend({ + title: 'Reply', + icon: 'reply', + classNameBindings: ['className', 'replying:disabled'], + replying: function() { + return this.get('parentController.controllers.composer.showing'); + }.property('parentController.controllers.composer.showing'), + action: function() { view.get('controller').send('reply'); }, + parentController: this.get('controller'), + }); + this.get('controls').addItem('reply', ReplyItem); + + toolbar.addItem('menu', MenuSplit.extend({ + items: this.get('controls'), + classNames: ['discussion-controls'] + })); + + toolbar.addItem('scrollbar', DiscussionScrollbar.extend({ + controller: this.get('controller') + })); + } + +}); diff --git a/ember/app/views/discussion.js b/ember/app/views/discussion.js new file mode 100644 index 000000000..89d280172 --- /dev/null +++ b/ember/app/views/discussion.js @@ -0,0 +1,237 @@ +import Ember from 'ember'; + +import Menu from '../utils/menu'; +import MenuItem from '../components/menu-item'; +import PostStreamMixin from '../mixins/post-stream'; + +export default Ember.View.extend(Ember.Evented, PostStreamMixin, { + + // Set up a new menu view that will contain controls to be shown in the + // footer. The template will only render these controls if the last post is + // showing. + construct: function() { + // this.set('footerControls', this.createChildView(Menu)); + // this.set('footerControls.controller', this.get('controller')); + // console.log(this.get('controller')); + }.on('init'), + + // Whenever the model's title changes, we want to update that document's + // title the reflect the new title. + updateTitle: function() { + this.set('controller.controllers.application.pageTitle', this.get('controller.model.title')); + }.observes('controller.model.title'), + + didInsertElement: function() { + + this.set('footerControls', Menu.create()); + + // We've just inserted the discussion view. Let's start off by + // populating the footer controls menu object. + this.trigger('populateControls', this.get('footerControls')); + + // Whenever the window's scroll position changes, we want to check to + // see if any terminal 'gaps' are in the viewport and trigger their + // loading mechanism if they are. We also want to update the + // controller's 'start' query param with the current position. + $(window).on('scroll', {view: this}, this.windowWasScrolled); + + // We need to listen for some events on the controller. Whenever the + // controller says that it's loading or has loaded posts near a certain + // post number, we want to scroll down to this post (or the gap which + // the post is in) and highlight it. + var controller = this.get('controller'); + controller.on('loadingNumber', this, this.loadingNumber); + controller.on('loadedNumber', this, this.loadedNumber); + controller.on('loadingIndex', this, this.loadingIndex); + controller.on('loadedIndex', this, this.loadedIndex); + }, + + willDestroyElement: function() { + $(window).off('scroll', this.windowWasScrolled); + + var controller = this.get('controller'); + controller.off('loadingNumber', this, this.loadingNumber); + controller.off('loadedNumber', this, this.loadedNumber); + controller.off('loadingIndex', this, this.loadingIndex); + controller.off('loadedIndex', this, this.loadedIndex); + }, + + // By default, we just populate the footer controls with a 'reply' button. + addDefaultControls: function(controls) { + var view = this; + var ReplyItem = MenuItem.extend({ + title: 'Reply', + icon: 'reply', + className: 'btn btn-primary', + classNameBindings: ['className', 'replying:disabled'], + replying: function() { + return this.get('parentController.controllers.composer.showing'); + }.property('parentController.controllers.composer.showing'), + action: function() { + var lastPost = $('.posts .item:last'); + $('html, body').animate({scrollTop: lastPost.offset().top + lastPost.outerHeight() - $(window).height() + $('.composer').height() + 19}, 'fast'); + view.get('controller').send('reply'); + }, + parentController: this.get('controller'), + }); + controls.addItem('reply', ReplyItem); + }.on('populateControls'), + + // This function handles the window's scroll event. 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. + windowWasScrolled: function(event) { + var view = event.data.view; + + if (! view.get('controller.loaded') || $(window).data('disableScrollHandler')) { + return; + } + + var posts = view.$().find('.posts'), + $this = $(this), + scrollTop = $this.scrollTop(), + viewportHeight = $this.height(), + firstItem = posts.find('.item[data-start=0]'), + firstItemOffset = firstItem.length ? firstItem.offset().top : 0, + currentNumber; + + // Loop through each of the items in the discussion. An 'item' is + // either a single post or a 'gap' of one or more posts that haven't + // been loaded yet. + posts.find('.item').each(function() { + var $this = $(this), + top = $this.offset().top - firstItemOffset, + height = $this.outerHeight(); + + // If this item is above the top of the viewport, skip to the + // next one. If it's below the bottom of the viewport, break + // out of the loop. + if (top + height < scrollTop) { + return; + } + if (top > scrollTop + viewportHeight) { + return false; + } + + // Now we know that this item is in the viewport. 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. + ! currentNumber && (currentNumber = $this.data('number')); + + // If this item is a gap, then we may proceed to check if it's + // a *terminal* gap and trigger its loading mechanism. + var gapView; + if ($this.hasClass('gap') && (gapView = Ember.View.views[$this.attr('id')])) { + if ($this.is(':first-of-type')) { + gapView.set('direction', 'up').load(); + } + else if ($this.is(':last-of-type')) { + gapView.set('direction', 'down').load(); + } + } + }); + + // 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.updateStateTimeout); + this.updateStateTimeout = setTimeout(function() { + view.get('controller').set('start', currentNumber || 1); + }, 250); + }, + + loadingNumber: function(number) { + // 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. + $(window).data('disableScrollHandler', true); + + this.jumpToNumber(number); + }, + + loadedNumber: function(number) { + // The post with this number has been loaded. After we scroll to this + // post, we want to resume scroll events. + var view = this; + this.jumpToNumber(number, function() { + $(window).data('disableScrollHandler', false).scroll(); + }); + }, + + // Scroll down to a certain post (or the gap which the post is in) and + // highlight it. + jumpToNumber: function(number, finish) { + // Clear the highlight class from all posts, and attempt to find and + // highlight a post with the specified number. + var item = this.$() + .find('.posts .item').removeClass('highlight') + .filter('[data-number='+number+']'); + + if (number > 1) { + item.addClass('highlight'); + } + + // If we didn't have any luck, then a post with this number either + // doesn't exist, or it hasn't been loaded yet. We'll find the item + // that's closest to the post with this number and scroll to that + // instead. + if (! item.length) { + item = this.findNearestToNumber(number); + } + + // We have an item to scroll to now. Let's get its position and animate + // a scroll-down! + if (item.length) { + $('html, body').stop(true).animate({scrollTop: number > 1 ? item.offset().top : 0}); + } + if (finish) { + $('html, body').promise().done(finish); + } + }, + + loadingIndex: function(index) { + // 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. + $(window).data('disableScrollHandler', true); + + this.jumpToIndex(index); + }, + + loadedIndex: function(index) { + // The post at this index has been loaded. After we scroll to this post, + // we want to resume scroll events. + var view = this; + this.jumpToIndex(index, function() { + $(window).data('disableScrollHandler', false).scroll(); + }); + }, + + jumpToIndex: function(index, finish) { + var item = this.findNearestToIndex(index); + + // We have an item to scroll to now. Let's get its position and animate + // a scroll-down! + if (item.length) { + $('html, body').stop(true).animate({scrollTop: index > 0 ? item.offset().top : 0}); + } + if (finish) { + $('html, body').promise().done(finish); + } + }, + + // Right after the controller finished loading a discussion, we want to + // trigger a scroll event on the window so the interface is kept up-to-date. + loadedChanged: function() { + if (this.get('controller.loaded')) { + Ember.run.scheduleOnce('afterRender', function() { + $(window).scroll(); + }); + } + }.observes('controller.loaded') +}); diff --git a/ember/app/views/discussions-nav.js b/ember/app/views/discussions-nav.js new file mode 100644 index 000000000..fdbe14951 --- /dev/null +++ b/ember/app/views/discussions-nav.js @@ -0,0 +1,22 @@ +import Ember from 'ember'; + +export default Ember.View.extend({ + + classNames: ['search-nav'], + templateName: 'discussions-nav', + + type: 'discussions', + + mouseEnter: function() { + clearTimeout(this.get('controller.paneTimeout')); + this.set('controller.paneShowing', true); + }, + + mouseLeave: function() { + var view = this; + this.set('controller.paneTimeout', setTimeout(function() { + view.set('controller.paneShowing', false); + }, 500)); + } + +}); diff --git a/ember/app/views/discussions-result.js b/ember/app/views/discussions-result.js new file mode 100755 index 000000000..60c3241bc --- /dev/null +++ b/ember/app/views/discussions-result.js @@ -0,0 +1,154 @@ + +import Ember from 'ember'; +import Menu from '../utils/menu'; +import MenuItem from '../components/menu-item'; + +export default Ember.View.extend({ + + _init: function() { + this.set('controls', Menu.create()); + }.on('init'), + + tagName: 'li', + attributeBindings: ['discussionId:data-id'], + classNameBindings: [ + 'discussion.unread:unread', + 'discussion.sticky:sticky', + 'discussion.locked:locked', + 'discussion.following:following', + 'active' + ], + templateName: 'discussions-result', + + active: function() { + return this.get('childViews').anyBy('active'); + }.property('childViews.@each.active'), + + discussionId: function() { + return this.get('discussion.id'); + }.property('discussion.id'), + + relevantPosts: function() { + if (this.get('controller.show') != 'posts') return []; + + if (this.get('controller.searchQuery')) { + return this.get('discussion.relevantPosts'); + } else if (this.get('controller.sort') == 'newest' || this.get('controller.sort') == 'oldest') { + return [this.get('discussion.startPost')]; + } else { + return [this.get('discussion.lastPost')]; + } + }.property('discussion.relevantPosts', 'discussion.startPost', 'discussion.lastPost'), + + icon: function() { + if (this.get('discussion.unread')) return 'circle'; + }.property('discussion.unread'), + + iconAction: function() { + if (this.get('discussion.unread')) return function() { + + }; + }.property('discussion.unread'), + + categoryClass: function() { + return 'category-'+this.get('discussion.category').toLowerCase(); + }.property('discussion.category'), + + didInsertElement: function() { + this.$().hide().fadeIn('slow'); + + this.$().find('.terminal-post a').tooltip(); + + var view = this; + this.$().find('a.info, .terminal-post a').click(function() { + view.set('controller.paneShowing', false); + }); + + // https://github.com/nolimits4web/Framework7/blob/master/src/js/swipeout.js + this.$().find('.discussion').on('touchstart mousedown', function(e) { + var isMoved = false; + var isTouched = true; + var isScrolling = undefined; + var touchesStart = { + x: e.type === 'touchstart' ? e.originalEvent.targetTouches[0].pageX : e.pageX, + y: e.type === 'touchstart' ? e.originalEvent.targetTouches[0].pageY : e.pageY, + }; + var touchStartTime = (new Date()).getTime(); + + $(this).on('touchmove mousemove', function(e) { + if (! isTouched) return; + $(this).find('a.info').removeClass('pressed'); + var touchesNow = { + x: e.type === 'touchmove' ? e.originalEvent.targetTouches[0].pageX : e.pageX, + y: e.type === 'touchmove' ? e.originalEvent.targetTouches[0].pageY : e.pageY, + }; + if (typeof isScrolling === 'undefined') { + isScrolling = !!(isScrolling || Math.abs(touchesNow.y - touchesStart.y) > Math.abs(touchesNow.x - touchesStart.x)); + } + if (isScrolling) { + isTouched = false; + return; + } + + isMoved = true; + e.preventDefault(); + + var diffX = touchesNow.x - touchesStart.x; + var translate = diffX; + var actionsRightWidth = 150; + + if (translate < -actionsRightWidth) { + translate = -actionsRightWidth - Math.pow(-translate - actionsRightWidth, 0.8); + } + + $(this).css('left', translate); + }); + + $(this).on('touchend mouseup', function(e) { + $(this).off('touchmove mousemove touchend mouseup'); + $(this).find('a.info').removeClass('pressed'); + if (!isTouched || !isMoved) { + isTouched = false; + isMoved = false; + return; + } + isTouched = false; + // isMoved = false; + + if (isMoved) { + e.preventDefault(); + $(this).animate({left: -150}); + } + }); + $(this).find('a.info').addClass('pressed').on('click', function(e) { + if (isMoved) { + e.preventDefault(); + e.stopImmediatePropagation(); + } + $(this).off('click'); + }); + }); + + var discussion = this.get('discussion'); + + var controls = this.get('controls'); + + controls.addItem('sticky', MenuItem.extend({title: 'Sticky', icon: 'thumb-tack', action: 'sticky'})); + controls.addItem('lock', MenuItem.extend({title: 'Lock', icon: 'lock', action: 'lock'})); + + controls.addSeparator(); + + controls.addItem('delete', MenuItem.extend({title: 'Delete', icon: 'times', className: 'delete', action: function() { + // this.get('controller').send('delete', discussion); + var discussion = view.$().slideUp().find('.discussion'); + discussion.css('position', 'relative').animate({left: -discussion.width()}); + }})); + }, + + actions: { + icon: function() { + this.get('iconAction')(); + } + } + +}); diff --git a/ember/app/views/discussions-sidebar.js b/ember/app/views/discussions-sidebar.js new file mode 100644 index 000000000..438b6f2df --- /dev/null +++ b/ember/app/views/discussions-sidebar.js @@ -0,0 +1,127 @@ +import Ember from 'ember'; + +import NamedContainerView from '../utils/named-container-view'; +import Menu from '../utils/menu'; +import NavItem from '../components/nav-item'; +import ButtonItem from '../components/button-item'; +import MenuList from '../components/menu-list'; +import ItemCollection from '../components/item-collection'; + +export default Ember.View.extend({ + + // NamedContainerView which will be rendered in the template. + content: null, + + template: Ember.Handlebars.compile('{{menu-list items=view.toolbar class="toolbar"}}{{menu-list items=view.content class="body"}}'), + + construct: function() { + this.set('toolbar', NamedContainerView.create()); + this.set('content', NamedContainerView.create()); + }.on('init'), + + didInsertElement: function() { + var self = this; + var content = this.get('content'); + var toolbar = this.get('toolbar'); + + // Add the 'New Discussion' button. When clicked, this will trigger the + // application's composer or something + toolbar.addItem('newDiscussion', ButtonItem.extend({ + title: 'New Discussion', + icon: 'plus', + class: 'btn-default btn-block', + action: function() { + self.set('controller.controllers.composer.showing', true); + }, + disabled: function() { + return this.get('parentController.controllers.composer.showing'); + }.property('parentController.controllers.composer.showing'), + parentController: this.get('controller') + })); + + // Add the discussions navigation list. + var nav = Menu.create(); + + nav.addItem('all', NavItem.extend({ + title: 'All Discussions', + icon: 'comments-o', + linkTo: '"discussions" (query-params filter="")' + })); + + nav.addItem('private', NavItem.extend({ + title: 'Private', + icon: 'envelope-o', + linkTo: '"discussions" (query-params filter="private")' + })); + + nav.addItem('following', NavItem.extend({ + title: 'Following', + icon: 'star', + linkTo: '"discussions" (query-params filter="following")' + })); + + nav.addItem('categories', NavItem.extend({ + title: 'Categories', + icon: 'reorder', + linkTo: '"categories"' + })); + + content.addItem('nav', ItemCollection.extend({classNames: ['nav-list'], items: nav})); + + // var tree = { + // 'Flarum': ['Announcements', 'General', 'Support', 'Feedback'], + // 'Extend': ['Core', 'Plugins', 'Themes'] + // }; + + var tree = { + 'Ninetech': ['Announcements', 'Sales', 'General', 'Off-Topic'], + 'Development': ['Getting Started', 'Databases', 'Targets', 'Add-Ons'] + }; + + // var tree = { + // 'TV Addicts': ['General'], + // 'Shows': ['Breaking Bad', 'Game of Thrones', 'Doctor Who', 'Sherlock', 'Arrested Development', '72 more...'] + // }; + + // var tree = { + // 'Categories': ['GameToAid', 'General', 'Journals', 'Gaming', 'Technology', 'Music', 'Movies, TV & Books'] + // }; + + // var tree = { + // 'Society': ['News', 'Committee', 'General'], + // 'Year Levels': ['First Year', 'Second Year', 'Third Year', 'Fourth Year', 'Fifth Year', 'Sixth Year', 'Honours'] + // }; + + var items = Menu.create(); + + var CategoryNavItem = NavItem.extend({ + iconTemplate: function() { + return ''; + // return ''; + }.property('title'), + linkTo: '"discussions" (query-params filter="category")' + }); + + for (var section in tree) { + var categories = tree[section]; + var categoryItems = Menu.create(); + + categories.forEach(function(category) { + categoryItems.addItem(category.replace(/\./g, ''), CategoryNavItem.extend({ + title: category + })); + }); + + items.addItem(section, Ember.View.extend({ + tagName: 'li', + template: Ember.Handlebars.compile('{{view.title}}{{item-collection items=view.items}}'), + title: section, + items: categoryItems + })); + } + + content.addItem('categories', ItemCollection.extend({classNames: ['nav-list', 'nav-list-small', 'categories'], items: items})); + + } + +}); diff --git a/ember/app/views/discussions.js b/ember/app/views/discussions.js new file mode 100644 index 000000000..abe05c12d --- /dev/null +++ b/ember/app/views/discussions.js @@ -0,0 +1,57 @@ +import Ember from 'ember'; + +export default Ember.View.extend({ + + classNameBindings: ['pinned'], + + pinned: function() { + return this.get('controller.panePinned'); + }.property('controller.panePinned'), + + didInsertElement: function() { + + var view = this; + + this.$().find('.discussions-pane').on('mouseenter', function() { + if (! $(this).hasClass('paned')) return; + clearTimeout(view.get('controller.paneTimeout')); + view.set('controller.paneShowing', true); + }).on('mouseleave', function() { + view.set('controller.paneShowing', false); + }); + + if (this.get('controller.test') !== null) { + var row = this.$().find('li[data-id='+this.get('controller.controllers.application.resultStream.currentResult.id')+']'); + if (row.length) { + row.addClass('highlight'); + } + // TODO: work out if the highlighted row is in view of the saved scroll position. + // If it isn't, don't use the saved scroll position - generate a new one. + $(window).scrollTop(this.get('controller.test')); + this.set('controller.test', null); + } + + var self = this; + + $(window).on('scroll.loadMore', function() { + if (self.get('controller.loadingMore') || ! self.get('controller.moreResults')) { + return; + } + + var w = $(window), + d = $('.discussions'), + curPos = w.scrollTop() + w.height(), + endPos = d.offset().top + d.height() - 200; + + if (curPos > endPos) { + self.get('controller').send('loadMore'); + } + }); + }, + + willDestroyElement: function() { + this.set('controller.test', $(window).scrollTop()); + $(window).off('scroll.loadMore'); + } + +}); diff --git a/ember/app/views/discussions/index.js b/ember/app/views/discussions/index.js new file mode 100644 index 000000000..59291a971 --- /dev/null +++ b/ember/app/views/discussions/index.js @@ -0,0 +1,12 @@ +import Ember from 'ember'; + +export default Ember.View.extend({ + _updateTitle: function() { + var q = this.get('controller.searchQuery'); + this.get('controller.controllers.application').set('pageTitle', q ? '"'+q+'"' : ''); + }.observes('controller.searchQuery'), + + didInsertElement: function() { + this._updateTitle(); + } +}); diff --git a/ember/app/views/login.js b/ember/app/views/login.js new file mode 100644 index 000000000..f0c3652a7 --- /dev/null +++ b/ember/app/views/login.js @@ -0,0 +1,29 @@ +import Ember from 'ember'; + +export default Ember.View.extend({ + classNames: ['modal', 'fade'], + templateName: 'login', + + didInsertElement: function() { + var self = this; + this.$().modal('show').on('hidden.bs.modal', function() { + self.get('controller').send('closeModal'); + }); + + this.get('controller.session').on('sessionAuthenticationSucceeded', this, this.hide); + }, + + willDestroyElement: function() { + this.get('controller.session').off('sessionAuthenticationSucceeded', this, this.hide) + }, + + hide: function() { + this.$().modal('hide'); + }, + + actions: { + close: function() { + this.$().modal('hide'); + } + } +}); diff --git a/ember/app/views/session.js b/ember/app/views/session.js new file mode 100644 index 000000000..e5377f420 --- /dev/null +++ b/ember/app/views/session.js @@ -0,0 +1,5 @@ +import Ember from 'ember'; + +export default Ember.View.extend({ + templateName: 'session' +}); diff --git a/ember/bower.json b/ember/bower.json new file mode 100644 index 000000000..2fd2d9ef8 --- /dev/null +++ b/ember/bower.json @@ -0,0 +1,21 @@ +{ + "name": "flarum", + "dependencies": { + "handlebars": "~1.3.0", + "jquery": "^1.11.1", + "qunit": "~1.12.0", + "ember-qunit": "~0.1.8", + "ember": "~1.7.0", + "ember-resolver": "~0.1.5", + "loader": "stefanpenner/loader.js#1.0.0", + "ember-cli-shims": "stefanpenner/ember-cli-shims#0.0.2", + "ember-load-initializers": "stefanpenner/ember-load-initializers#0.0.2", + "ember-qunit-notifications": "^0.0.3", + "ember-cli-test-loader": "rjackson/ember-cli-test-loader#0.0.2", + "bootstrap": "~3.2.0", + "font-awesome": "~4", + "spin.js": "~1.3.3", + "pace": "~0.4.16", + "moment": "~2.5.1" + } +} diff --git a/ember/config/environment.js b/ember/config/environment.js new file mode 100644 index 000000000..24065b0b2 --- /dev/null +++ b/ember/config/environment.js @@ -0,0 +1,38 @@ +/* jshint node: true */ + +module.exports = function(environment) { + var ENV = { + 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') { + + } + + if (environment === 'production') { + + } + + return ENV; +}; diff --git a/ember/package.json b/ember/package.json new file mode 100644 index 000000000..f57a0f604 --- /dev/null +++ b/ember/package.json @@ -0,0 +1,35 @@ +{ + "name": "flarum", + "version": "0.0.0", + "private": true, + "directories": { + "doc": "doc", + "test": "test" + }, + "scripts": { + "start": "ember server", + "build": "ember build", + "test": "ember test" + }, + "repository": "https://github.com/stefanpenner/ember-cli", + "engines": { + "node": ">= 0.10.0" + }, + "author": "", + "license": "MIT", + "devDependencies": { + "body-parser": "^1.2.0", + "broccoli-asset-rev": "0.0.17", + "broccoli-ember-hbs-template-compiler": "^1.5.0", + "broccoli-less-single": "^0.1.4", + "ember-cli": "0.0.40", + "ember-cli-autoprefixer": "^0.1.0", + "ember-cli-ember-data": "0.1.0", + "ember-cli-ic-ajax": "0.1.1", + "ember-cli-simple-auth": "^0.6.4-1", + "express": "^4.1.1", + "glob": "^3.2.9", + "liquid-fire": "^0.9.2", + "originate": "0.1.5" + } +} diff --git a/ember/public/avatars/001.jpg b/ember/public/avatars/001.jpg new file mode 100755 index 0000000000000000000000000000000000000000..316e9f8de0bfb591e631f0df3b1323eacea87ec4 GIT binary patch literal 5064 zcmbtXXHZjXw|+x_gl;H;NH?f76$GVs2%&eR9fY6~B+{jK5Wyo5nu=5*bP%K|MG-~X zq4%Z&A|(RSiy(J%&fGco$9I3+xogjU*E8!`eb3%A`*`g5D>$XCiPZ!!7z}7Z7dW1! z$i}Lv*%=xeXkv9WAOZl21Q#5^lky4xgnM2d#u!z;85v+&`ZJP44F3xv@G z7jLMCzvW~S`djzROd!qm_w5Oc0S2f7KHvbn0Uo#jFCYf#d(h7QbDYHAar(d&iorpr z8}I`jP{bYJ1jUL%R&U@5ZbAAsWWNPnE|3Q#C%Zq<0Q|dC&)Zi|@*82a;|Fdr>_ep+`I|_hl006q(06a?r;5<~1 z$>w+xr~xWCB_$=C3OcB$sHhS2NCYHIbaXWIC?;lR6cY-?a+-^sg_V;Hh2r4n;N<4! z<>h5z7eEW}pt*Q>c}|kRpil%gf)R;iTP25&X>JNEIV{76JD# z31SYbs+w`-`*YzmXph7f{cNYNN}4!$CJoH2LrKoS094_Blz_58DXAce7X>INPRM_0 z7zGN>FV4(DsUmPZ0qEf{s987)D1rJ3`J3iNMiEn}8=rhHb6<#c!}F`=&&Hp>>J+P6 z!9NhJ8?UV;`O|4zkV8sAO?jWQy_H})*zH)HRi0p4@y!iTGLeKF?iQNMe9ucHgx#6f zqDYW&0_pJs6+2d!62iJ7niPHto!gv7C;An5TPt%c<-HP{L5HTBzewRoF1_t_I6_N@c7pGU}?RMkMI-0X3f)uxA2|0g-B5DH79%C{yLz?7n2fO3!8!l4Fg<(Ix zWnCYF6`V1@&YYN?)ZTif_f^A?TTEYf?uw&};FZ=AEgycC&!^yeJ$qT%JW;(MN-+9Y#a=BZ1iGTH^ z8>Efzr}h5q3rvYy=#pMjgZD^pc6pDUo{PI!bk{1zIA#;QD^+t=R{UmDMXOzmZ5??@ zhkf>HTn>%&!EagOQqc6|ivGrpIy z7vr)v3%-RP_8f5Ay(+w1$umaM^CEGDUPQ(hOG$bkYD%$9lj0@2-FV3c>ppkf&$W1P zdh51vx+`$&N0#Avij2-6KF13@`R;na>c8ObmHNzG*Jky{3oYK=`x|rbRtxGrmlRo( z^M=-GoR{+!-u?J+3{n`%>*vbLMs2L)M7M>Ujf9Q1(YvK0*t2$tY!wat4bO64zB(J* zI-ZX%oe{%QkuDxk_$p;siQiBqro)M83;9}`C4(P!e(Kgt;g&K?#5+!pK6FMuVKh7A zI3r{nFNly{sAH9K^a!?KbEEOG=f^}220pjekzV?q)x*}YW@S!{8%~pHsumSP--p*= zyodK?J+-ISXwMz#OH6A9jvzXf*7Cj_EK?_AvN<*DQc+XPP3YA&k|IcGEhRWb zo70+#A`$cWgus!imXMbAR=T`iF1rsG^JIq4s_(0Gd?Xjpa}CM zNJ(|dp#5!nx(B20i`DltWGAs7e06zaX>Lj%Q~29roA#v-qi)T5x;&rCX!4iWJbS&% z&GeCTuSaR%%ANk3ICAVmU*&1DWsx&|vN8HKg+Csr_OJJ_^C$BpF#3^QBMW_+5hGy| zcYVefa$naPH?h-%2M9}ybnqMlq2fWyJ(Yot3F~?jLmR^7T^-}*#16%0Giu}{_0K9V z4Mk*#Zze6vYFD-N^Qy&A=H;pR6~Sm9nYjht$X_DVIZ`8Z)5X*NHSM*wH!EdXlUe0I zEYhkD1U{6c_st%wtKG3qd#*2NfcImfOnqhgn$E(pFmoj-U8E~CFowZW&7!enc$u2> z7pgdOsKK4^L@>zX2DM0Mub|#~(yAtd!P|>`o@u%A!U$AdB3s13J;tqLP@g_rU)QN} zW{zyUT`!;hdRae)%g@Ohj*$7`oVt!HvaMW9Evu~HX_U>UeRwE_zU8|s179@1;7YIG$JUH5WIW@D3T^HBvNEGVGIg zEkmvVnXob4_QCrnZ9-BetueytZN$jaI*Gdt9kbNhk{{K*rfyxQMiDrc@$a6@mHeQM zucguul?y9zvARUgU9s@itgTS)h^uf4Hce{NNow^itZLbsClC-YG8Ql-F zIUw4s9V#?77^36K$6T+K9F;X>q|KcUypCxca!YbsD=YlY79d9uloiW zfBRr>$5f`4B0ZX5el_{Nh2kAzgxmG&Hsm_k(8L3qd}>*T?8A#od^jZr=`(XOGT%c> z=(`qT70POt&#&m|X1F=NBrUDu*f4j$T%b~$<~v%R3Tn_S8q`t!81OiMft|}MqKYMxojaVe3vTnSJJPdHj z8s|0SDk=zJ3<_^MG`;LXCKBJA_Ugnl@{hjd^#9~D4i3L!dEoGt(YN`v$B(@ z)JouX<=S<(DcPPSNpM_95BX(HFp52^?eE@}A;Yo%{?px5;pwValUIyS$u}3$18Ao< z9zA9i*(|t_7|oJ^DMJTk6DQ-H(+C-i??M9_wD3(I8mhar@JKJTAPo&~VvD^+8qcMGcb_F7H#dK%#41JRiMM5TERi%{zliTmXXcsi;@cC#XE zuVZ~ec8Er>**K5DyB7Z63FAG_WECe^)f6PM47V-XiARfizldA8n6^*E49|huAWIsO z^zgGb&Zo>0hJ#Jgc6~z9iqEne{r5wx`UGaeihK~k+tjI(U8S`)G2DITbEp zbeVY-IK1w{Q2(!j6?f58E%S#AhtdhTFXU*pRGFETR7?vZR!Tk9orag`hD_^%8cCOg zgn8wC_d3riMlKYoEy@?KvpAeNed9SyYTa@sjHIRu(ITkOekk+a=%eJf_gqBECb;{%&Khd7Fgm0e zCvNVe^G>P2_&iL;h{{{+ilPEFA|G~28R-u%b~k42BrGG#gO}J-cr7eHtF#am!j*Pe zEP{VLJ_ddM%rgbvk6YO@E-O$7=L_M9gNd@rZlSZ-=4Nx}sFQy*^X}%!j1ul z(nQkN65T~nO-&~5n6hPM1o4LY%-izA5gm*3Z;rTFRdWUL`fNuc16br*ma~tIF5}Px z8)M^gnHBwuZM9)pA6rT9{UQQX*?vyl-SIJt>%?}3Trrr}KIB>D(qnj|6Xlcmbm#dT z_D^z&w2lGQEmD?C3OWn=B(4&|o;M}x$^iS1fyKYg7(0J)vaMa)zt-@mPcWD9S}Csv zlYqiwOShE|N=CUQ<<&!}r@OjyM$)m`eHE-J@b{EF$U?TNTeY2&#UW(009q9LE^XqjpA4Ld9~o?z4QQsmiU6}h@GN7>y~r2sLlEo(5zFmS{?tLE9{G;E#B6k zphlyHok77b+VjuqD5^HEi}5?rURe%QPT6a53>2S5c3xUqUzc4e6-%9b{AtkJOYxaF zGpQ_UD>Q$EWfSe_7T^$<9=n6^2Md365N^L7@!#p^pR}D2tK}NWN^wh#$`KQ!rR5j& z6~u4=|4|Kb;zXme)Q@;85Kb1$ne4U;ytiE_*(l@LA6F2bJnqR|f1pKo46ZrbJJ%^! zTR*Lvw6A15OM}EeIEz$eSp;9r*`GI+cT;(mk8)H9)JoJj)~602hSd$S{JJeA%+G>?6u@aYP36{LoJj`E) zg*p_Y{us8|vl}Egtm^ns)td2)^!sVI@uKX65!*`6We$~!oA-4X1ecQL8@_9&&JOvZ zchO0SPJLrd$dR~#G7T$h)7|F#xT2%^EWRL?G4l(;ni8@xj#3-7BCY83ksxVa7jq}r z1-5R%K$+bBH#C%7qr)0HF(ko6IfqvUo#oD{20c6KQf%qc13%-g1Qrvg^pL}Sg<@2U z6x!y*%?roirSti3wb9mmafhaIh7%K(MvGW<-*e%bYHIeQGlA7?Wky^?ZlzYdf^rbX zT{m^VitsA(d0@wtJf_(;Z#~hV0hL_jT4ej&a!y)OUkg>eS4?!U^n-}-QAmv`NelAYglX?vuf5aNUW-wy=phir&lPGlz?p1^x z(B2ibj4#zbz3toZN8U4w=cVLFU-lBe2@NP_sLy74J%}1Q$oY1DoZxjOpxu{M5>ajt zQtR|$P)>@p(xvuz94GcMJ*tr|`nPb^kFw`RYO2jFQr+#AEYG6^h%F+U2#Y`QYCQvoDr_UDlCy(BxDlOOz>C$qsZ7MD%f0u{l z(G^$qeqt?^!i{G!XGlyR2p>I&dgd0a`H3_IUJV1{U(z0#o9X40k!5K&k|TCVZiEh( zg=|lKf;}$HFtwSB_Cv7t#Ujpedt@6cN$^|^-mc5)w@_~lJ|x=*Wl{`7`#HQgh|?m_ ze$Tw6CFF8AOf_8PmO`JoSE{4tC#m7e4quOx2y7S(uwYENS&~(D&2~O#b*n>x)5LVA7KYR8|Av6*!1MqE=NU0ZE=6vAtK0rCaiyo}1x!2! z=r(vj4X=dqfMH?#DOCsr(1Ag~e@Xw-KEp`Qzyv;3IQ1%X{#W?)9~c<^m-|$h6QE}h zQ#`|^ZzX>FX*#!mK@B6iZQv)2M+q~3=VTIKJ=LS*JPp$YbW33$lE=K$Er>js#iZJ^ zPAsOG7wdnYfA2fQa*z6+6OC$2bIg?tMo%c}=m4 zw({2LCRsPwo0}Rd+(Dwv>wM#_iiID%w#^BCGV+sHqp-QU+T5ouX+dP;;nFe_EYUUz z8=*2X?=5vdv&wfQa_o~eYp@Fn&^ZY+pG=nPs)`uf{nF8K&2l{P93+6dA0!nPuse>? zmX+@c9vxwF=``Y@??t8l37Q5Tc}guj^1>R1S+92`QD=fi=*5`$(O63W+_aqZ{I~Mf z#Zpm4U(r3CQPpExL#x%PF^CFDjg~25Or5@wgNfl9zZn`8(ye0nZk~1wi>tCn3#LIN zTD+z^hWjkVs++AmIv0sOa3DM+Ii^$3`>zm9o;Ci{TDV}f)kTp`Z?sJ&=de5c>!I-4 zTjNk6>g~vh_k{2=HJqQs ztOZuVRcYm{!XxYbIZi#2GWS1j^IKS^x$W*;8j-ar%c+z>sS@2eCp*kNy`kd?rCESoWW z%P+rRFhxsgezza;Po__v|8wn<8TNc}T^aK(cJt|9VYaDPy==N%lLibps=keGrfrZuMWIOg4yq>-#p#l6ZZzfN9d{ox%L{@Uw} zH2gn`JJOAHrd$s-@tckJP`z)x4&>&fNjEJD=bD_%naBTPDxJ6#$HF>UQs(UqP=&bf zqO1$TUJS42$q#i7iymN`V$H!QR=#Mco2Nxcve#ksH&se|=YTg}sRqb}a7h{UL=%J` zYuQw=~LL#XDGTbO_P0NP2j)!uH z8UMIs?<0K%F3DGE`$+os3qHJ z0Ae@n1TPj(v1tv`uYF9$3(YG9zhIbK8)uJ|qVJCR#l_LQ`=)1d`sSGvpyUCW>UNki z*DvcB7Y6syY+TSNS&t~vSc{X zrW9Rx#E;i+*4z@#tT~3|Yvb8jA~p zk3imN{T!g>Efwy5Q>xTE8b`V1ZT#HPw5l658SKDcR=_hjn$ta6Q5q8tiGY=RV5@V# za*GV>`IEFJCxgVy9FR5~54DZ&&WhhpwOu9821bwa)#SHudgYKPnbZe_H*W9-40m5L z#w2jg0YZkZanY_Izf{9A;fv5PsCP!^A8m!rqT*@!v;6lJUepU^@UMI-Wkx}$Q)+84 z#xWk-4oX*ms;mPBnFe8iGo;MRigu*r21^ki`bh0-93qfUkUjJWuITYn(S+!+;;(kF zP)sb0a0~Y}mHAeb#_)53th(m+h_Um@p>`;LiiRE0w^v}Gql8ELw0P@=!QMiAkwZP` zh5ntGrRswI2A!aGV+&8bQ+nAtp^hr2Lt8b~4Usu`D!jm7{=?jNs_WH5E=)cXr_?p` zp;>H}r93aq+KmpRu}%{h#oT^TR2%<8^~IF07?Xhsot_{Fb<}57f?Qp_jv4p&1zx0u z$#|>rZ=V2VugXmMiYiTFu_)#7#-X^OSLgb@e)nzIFSvvz!?1`{#5ufFO|tm%Uh(V+ zz=HsJX)a%Nu<*7k?J7@*zPc zY2LsY)93f~x0!UwFf-NXp7u!TW9W{6tA3#XR(<+w@bt!!eY(05`VIE!hh853dznP6eAG23F*lBCo)8DFfNY5y-Ds`^w8e+P$W{7rfkQ`*`k&(a_l|1`v z!{y)DA7Ps6*A3rcBS2xnxhfjoXQsSLTpg_AE{r`&cK-#U$K4ZHQTdYlYm~#^#6S_9 zgEWgRlSiAk8WnFxB($9XDncef&xpP0i7_cWph4e4m3N9KKA#77`&yeR#$b z#^irYUm5n7l65x!#< z$kW1$vHtBgMFV8U_M)X`4l0F`tlAwoqJ$qecz&hZyJc`8x-4VK*0}Jk^5rY7?kq3L znGd`#notG9IZptY$>NNxnu8FC%y&9dRLGIQNJy!H~L{X@L;L-|WKc(}Ch5nJyjff(L7)m82u+!u%>Pi55(@?;5%FiYfbp9_<3-%`*7 zM|m+{SFXN>m_;Uxk*jLTSS#s{FK7FkUppVh?n(kwH&-{Urrk!@Y3Gkzr^fpI$bqW8 z8$ox!xfHi@h<&#~@b+GIjPY?(rOsm4?0qFVO<_l05WTID=c>pjfTQz`C8WL)j1Xa3 z`1&v7r>8{u4N8^#!ihRG3rq{=#?{2JTitg02 zcK&O52(Zc&GSkyWOdcmD4tzoL_*f^Nx55NfJnx?1a^F%~%$CQVfeu~uP0AOAM!Mw) zP)0rbx=X{vcf!v)GEDTI)q5-8qtE=KRj(BVcU9+`5|2r_Y+sa+lSFJ|VP-IjViJeE z$u;SCK$QQ$VJSNe!=|N)MzKB>4ij#p8Q)R9`SQ_0Dp^Xo%xe@2i^YA#r5qXAB*`mB zG$>_T`BuJMI{`k`P{)NA2|pNf6>8>>v*>&=HU(8NT&8dG!eIWRQx=VSA`foNn-t_l zkf`3@6$9WV;qDbnPKkn#+RBsLxi2G>~?^%|2XAkVwH-xXGzym5_Fry5R5qFisUs6Rs=|;0P z5srsbAEXS4)L9NrU#MQKYuag`XssZaAAy}oaRTesRx&4;|!EI>H&2zTGpGTi>J&q%f#T$O6 zV8!%=;V(7T^N&WfHw7IgORv^O;pt3om%J%i)&IlL#vd^2O)H<0Gcjj2(mLLF{CNHJ z@rbW|@}^#_7bhKkNihWwJ_tCU?)_kKr6A+o=)3a{$(D;7k&o+#-&2KM$4wA~L>X#99T4R^^@eHmpru~!;pU}B>uBK3Ati1Or~H4l#HgNlO; zts}cMuVeiCR`MElE455tn((&W<6aQw(j=^}TvnNj+dAJjC-(9uSXRgRk!5>K2u|-Z zKXYC4@c_2Ac=U+QNpzD!zxh3PP0{nnCTeQCm^bm0-@&jgr%J)Zb#ViaI|=ZR#Pn=J zm9nq@kIjCoI9Nad?g`^(;mDx2vC*dVIpnci#z#prFL$v!Fp#*(&+vp*y)|DJQ36;X zRpM%TPV>r5NehzJNaexyT$G+~-B+I4!1_O{r@g>wmC}vIMMRt0ri<75BgzC*VuB?I zf0>s;J^GK0vpP}0~cW53JW z<;i3_FWURIKKJ_Np=|*MV;M$zc8WF!wXXPV{CdE?+)XY)h0n4!dhlp^j()C0YDJN+!YS9tI<))C7YT%< zt>r78GC3N=wV;?t=qF?*w;j~`sa$+EC00+<>@i50SkGaS(5eX9HjhmIZmPKK`9zn zXoGe`<%UYoT$uX9t72@QpXJ%`T2{zHq>DTi2<$ga3!r)0(f#pu(OcuwxcTQWxwC|K zFxxuxA2;0Vd#RavoHxx1lRUhayOzp}M`S&zcTp-YQ-+*w)_c$6Ni~J!WHuyCb4^7x zKO@+^P7J?YKq@C7P0o0WT*{g^_jYsN)@uKdT+ZMAwC7TMTa~P32#@;5jIj{erl6ZD z2ts->?o)30J9)7e(J~@XiNRt#?T@ScqR#q}fz7y9(@Q;mK21Z(OrM}eH0-O6?K z^YESXmDf^^2PL|iWYo(g-4!aayvJo$0&C`DV@uBb3D?=_c@#N8VK)ev{=*mu8VMT-!!bZaY_;+73zw1trj%N(>VCBFxSlxmuH>9sm<6h+W<>+H`}i4EX+a6Vg*`|w3CVd E000V}dH?_b literal 0 HcmV?d00001 diff --git a/ember/public/avatars/003.jpg b/ember/public/avatars/003.jpg new file mode 100755 index 0000000000000000000000000000000000000000..bcde0fc5157645aceb5e7495b99ed18d32850320 GIT binary patch literal 5207 zcmbuBc{J4R`^WDY#!#|P+1F7%HVzRFhqHH0?m{B47E~4xuOCe;* zmL)y5^caujk!*?I=sD;6{PX?&@jJikbLPHZ=en-<`*YvdnK@?;#}8)!8&(^m4ImH* z(4{@#a1NS-L8EUNkC;ef zF1)LQ6O9XKe9o74KpGDoU}D=Z@I%A3nN^Fy7VI zgVy6tW2A?B0PPIFtuiH z41pI-gQuip44-daf0szkd;8*P7;m^Fo!(aIT(2WDoO8!^xMW&5&m6j*|n?pVYfb~8Ab#MO7 zITiv?Pn$9K7jFk&hu`m^qpeUE7XUVj0XSg=04HtM$87$;^?&9~^FHzi`JVtVy90oJ z9{^9%0XR*2AFJ)*6hH$Q9X&lg9gMcXU@!(aGb5Z9tjCWtF|)IBaImwovvZ#0KgG$# zi(qHx7UAX<5D*d);yfja6ct4B3knGynLub#I0Kx8k^i=9jG{~U*{fSnOUfCxH> zD1fp<=-45L?~c9@5ZKZ0=|2Gj5PAkUl#Y>By}||{baZr3ItDrz9Cp+Mfzr_f7&`-p zh>QlOs0p0Q!6!0BHn$ofhI4FVM9OJ?oLxs>HO1e{qsjRozs>(+O#_-7#sGoSiWk@c z^tU-}DF1Xopn#5^o#qVFFk#>nm362VL(<$e(YYVj4<`XL9fWpnI(DE2{#bY0vb;FTdbJ zJ$UNn?_v8C?I|9=*|ef>HpLJSsmEo|aH3Kn(c(5$W-RYby9tSD*Nhq#I67^%K$v%J ztMO*~Tg)--L;$w3;99kzmSW-6Gcd8COvrT{m!=Oko2w-1S|K*mgmI)pou&43`zwDD z)Yg<#6S!456Wo8tF+$Rhl-Wax928e!wh$Y!du~TD9x7_|de&IAq&<7FdQyMH;7JhU zfXp{6H3Az*6)%PANsS-FA*Qs{RrT*axYog-^O!%^E~Zi5TvrHU zg1iI`!qbOSDwe+F5Jt5mXPDQoR#v!_H4)$EWu+7&N{OOeo24d9-;CmYuyI#WrgJgW zTY2Z|cpf2U(lV4Gt1sK{s=epct1>@#M@#gSeSYf6ig44kBI0+A6NUDda+;@NSSQQt z<}+`O6?Zoz#KgWR-G%t+`(GA;L);_{eG9hwSnK@vK1GD&)_$q(59I$TF>A!E~Jlq*Vvg>syqH`}y~&cSr-&2RCWXuS!SBA+@%ZvNZkCnJz2nF3-fIO`AP zpK4v^R1hh=qc?B1Y8-Ibenrm^c2CHedblYdsu~}-!pGXNHn=Vr22NzP^-G*TQ(=E& zBEzhAgm*|@Q7LFk@8m0J{tde?d8Hd~OUHjcq1!H4+qbej`wdtQDkzZN!a5|xl}RL{ zT*VU0yJ@eq92w~aBG{?wBRKkvLc<(f9;=CgT^!48@;kh1DX#E(X6A4_ff}b}e&xo+ zCq_E=`9-!#TYSfPygF1^lvDnm@eR(~iwT*#YU56dHOv0nXx3fu5FkPz<~G(=DXJ>k zQ2P8v1F5&)4B?VHVV8GC&ZcJ%DtC|hAA*KEx|n)wWMEpO?{%VCLC)*kh&;G(n4ea~ zb=BJI4Lr<$h-;cr8hTmCk%_RgJ7sOI`_R{7BZPZPo21ZhAHJ9jR2A%`DT)>HxJmfH zj$_Rz$Y^o2@cr?mxqEF?RfOaZhJen&O&gEtpI6(VPttnbtTZDBP?pCqf9m*rNg0%B zQzUN_KQMlr7rw|7bIl7GB|om|w;b8Q;kO zH=1nE(iFY8KHs^W(exC4~_~iCFrrN3NEDcv;>>yiXC7gi-Se5zFI} zp-EvdYhky|k`o(bApnmr4YNbfu2%qD)Vc0hoFWJgs+EDkzrSa=wt=< zAaKKdoFGoWK2ZFymZ8;-kW*?R%T{f6Md?X;%M1 zA1DClI%~d!A{EN+D?cE}n$~u5QAyWLRQ$P=mJ3}v;!{>vOEuKC21q^|Gh@BO)8}`j zn<{0SBi6VptUpajB+&_7DcGG%zIw(~X}YaI#wQUSt~ykZniPp1R$MS|)?;bc`|zyc z;j37Ia?o_mMjz4b^h}CJ?_o(WNym09`LB>o+u=VGcL!o#L*ycH|Ouvwl zHeR$P<`0NygNvTT0Ze_=%TYHdR?*+P0;@r>$|HSHX< znMFcEn+gB^pW68&15@|N!?z$Pdd2K_nR_gUpi)xQ^j6Fly^wPES9-eJ63NxjS=9KN zirj2&E2F?h{@$_m8oL!4N7Xv=uvc(`7k|Z7mL9dG_`U+$N;xm8uZa(VT^*Zl+DO}ScB9ODahMGUiR)5%>UW{i(yYlM==;+z-{=_#JPmv?XPw@5i(F9p%za@-F!;w$ zbDL{7R?+5Zy>S7ISSB0;ThWLvT%bB0eyWoNDgJUccNArHqv$V|u(Qn=)R^eOuK{P_nHo_C!s26cupws(IvNzXUV)~p_| z`E_0H!^tn!j;_^3y(+16N+E{&%2`}-@OczcHFeHS@8R0JxH7{_ui03PeLf3+6Y5Q8 z@_bp!h<$xt8DD#~{9^?bm;kk$LxB5K{8^65;&(1^+Nz9g54WS9}3a6wlm4BWxAN84LEoc9}2=3)4X4{?; z_uaXLH7OQm1>Y!5JD!hQCEf5aLg@voM3_fXSYDfVbfG_hq8_4gK{j862DcbL36+tc zWEddml%O|RBA;~Hb*+XWKr$oJm0J(aQbP(gKX(^JO`)oWNgF1+*6Jp>dl2sY9E$l` zkePG$!q22ba4h%zU>&*WN_(F`Nl*}iIL9x-*ooCqixPwjzM(jNOnhD0&;5BfWwA2^?Qp^a9o$a#Bi^wOI%t-ZsmjAUb8OTW zN90EpA~$jBKe$|+CplT93#6*1;+Q^9GPQ0Fj3@3H*RQgw&TL=vRAQ1kWg$((Z2UN| z*BLSIQ1(SFnMhDA8H%p0pn~)qJ+|o`3Zx`@ZIWT=5_y6_nfTbq7h{N^psxZ;%Oh*+ z_Kwr;(wn1fKZ|{FgZ)F~)ccy7!_U#{qiZbUbS^VhK8eZOmAnL>H|q$-eum+(X-DUT zm$&s)ImT+9mWh#=^6IB$=Pzvsi9|~mKvkl)u6)-j=pa6_@$Pco+7Cr2Y#0xG#Xaon z%#l=ioWLj*0H2Td9%XtvGcP_Yke8GJFi?!|X(5rfDCjoP?{`{S^qjmmL9k|xn*Xw~^~b(JFlAd=XDsP@ z&G+wXRrn@ceskAn>)~2;B%a?pv`0*>*KW^kk*}%e@%-5UeOb|a%ATsCu@fjE>nfYx zVK1*9hx(J;u5yiStzs0&-;!rGDo2JvzzGkUcJ2`iP+}zXFB}p z3sSyL9Nc&x?s89WL`o8W9DU3UGs{v}F|JEB|Ks_OO#~)^gW4-nmo^e5q9K(Vrt_)A z0q5{RlOMMry>I&T0g4BMy*o0D2r$!>%j_x%=X+TFU{mUanag9}UZuj&de2Q5@tR-irZWdaH1gUauBJ9pznCo#G-0@ z@1@!!bbDonHdtbd?ZTX;4iszuhdq{2&0O3rAt#YTuj9MX)LS zyH7%qFyyACW=Qg*Wt@4j*QH|9fDERw{GB(UQENCdS>|cUDv= zD0%~ZMt}Peb4$acX0gi1+%MTYRZ3vi2c2B;PK$aQoP}((C$w7o zYeP@dM2*BAB$tt%zrZeQhYQzR}BE&GcsPRo=lFvgRMm7m<4KucXLLH3sSXnc;%R zwjz@wyb$<$2CFFc8if1jLMc3jlXO?9q8j2OWkM?Hu^Vr_9euWE^kr&WC&oMd{96n` z1`;zILEf!i+V80x(TIHgmACk^XaI{o{x12o4!t~Fav(&^JH1CZQL$e|CD=5psJyP2 z?R@HJlad(+93mvRBX2h^BJ%JaR$Z9`%d{E}EO{R@i*{Iu)U7%lzt=oxDxqm-VS7Wu zO8~NFMf=YbC}6wuu@}`PH1`@>#Sr3BBfRW|Ny6C$JLrjZccFNwt}K=Hl3E+8cp`D~ z+|b(*it+WbVTN5CbV8IHZ}v=`$`0yrY002mxWh z3Ao)o$)uC^_BP;u+Il~LDgp5J&~B~&x$VCP1PNYbPXIXhps=AQDTD&?35YS5DWqLa zgIL1zf;$1?Vu-aupaVkuX4miWi>G(l=NE78atI!04&6-%Vg;XHthLJ?zj*g93Z552 zh|rz@h*gLI7oju!W4kvI@(OfxfL7UmUNXP|BrpRCzyn+az90Zlz#(W2gf#o_dAk4P zVL%X+;|a|q5C%e^3?Fa-%GH9r7XcY~L8~|9_ku~9(X@ZJI7M?9OoQ^;ojC<1_M0DxxNzxhElNY2lYKkZ+iN-h8*F#xcd{`Gk< z0H}jBmKr0uhq(XKhYOlHy}bcgDFFa>1^{tLYXR5)Z~T|uQ0?9Npy)jS_$vU|bOCUi z4!{AZpNJcK7?=YdE^cmaE*@y&;o;fCE5yeOnTVhuzmS*+94;mzCMLdDcAvO}jHH;D zl%kXj0wFIiFTPJnMM+LYR!&}y-3$czKs1Qv;1C1s7Jz`H4JYsOgJ-j+6#g(w`!B_9{Nhs@@;nF2N{tn5w|NFI` zugmNqz_}|rjXJ$8(f%Lcle z)^SI4zvnl-!n7R^5x)7OW6oqhy}5+Ebxud@d>eKAd%Qf9ONgf!m08BKzFWrt1q!;OQwHcSt3eQ5`1@)MDD73pd^-mwWb7eaw1!9jL2* z;sruEM5efi2YH++b8E?vZl)Obo|cm85^-4Pi_33$ZZ>PZ6HQXg`h?*3H~dk9?|J9h z03BH}q3K{pedqL0g|qsdzi0!Y(!!%arC?3ZP;O0;h~+VrSv_FxZXVod-gzUFAh5@p zhhGrnTwMQauWmFiPUE&iMS7Z5*(Fuhf?j^6w6uk&@Kk@XXKBomr9Nj_(pxqNet@Ct zYav}@CgH(rtsyC61b;XFebQg{+(F(B)C@tfL58hg!)vMb29$%7Ci%56y+3DaT3`Nm zEb&=m6d7&m6YLz3>OZZj zNR!sbQ6y)XE3ig{IWA*jWoe|pYEqC5U_)kwtzjkBS3!3yOB{C-VL0R}U%&Y#c3^ITN^i((T2tuhHtkSV>TAb2WL#JD+tr za&wqk9}3eOy5>|nsHn2KP+*7Yo6AmN&15hc=Hc!w!i98PM6sBy>iwaz6SIut&&bL6 zWCCTaXb5FoYI2azPQ-SZ4;^tFUKDL0*%~oW-`zBppQN;q8K!IrCY5J)6qikwu%0p&nl~Dcg zlGn;&pyBi2VM>}MLo!WrDfPF4FSrQfj>uyTFLu7bN0;oq7oMFF>rt3PboB0-Rq<4t z!W>;EFiqxNN06G`_`?2-tD@0(1PvS$IvO-!_Ikqm-%;h{XiT@gYnqu&gwA%4{L9I;f zXc-^R{s1G#V6V2bPh+cempb^eh6N63)m7+6UCP^(He9a?**PIy=cKiFE5cFRf6F;; zs*5E-(<*1;~G{c6`kS9lzZX#o(+5#ed|LE5=3bWcmnfa)W8C2Tinh1??gANeFm%37LQKd z{MjeV28m05G*&ruTL-(mTj*$C>t=%)hE#;Ax&j25y13q+XRNm2wW@PFC)%weF2u5G zbi}00NP4+wHdvB;Axb>%+IhMzijNJjf%3`2H_qWR!I9#d?+AznXKU~BBWKEIs_7Ly z*i`h*JzgaH|OnmZ~>@4eHn#jtjuq`>G$^#pIxrpY)SalrdWx5~h&=`p*o1q{b_Qx5fc&m2N;$$*D!{6l|QxV^GPQEzseKAnO%3SrVg!opZWW`oV zx0LLGr}H|6Kengs_u?Aw5LLp^_nRCCgkPxj);~`Ot(|Y#ce%7<)@T@62xXLRXd@<(Q zr=*5p?rF>O1pTWGshaC|jq#B&^^z?k%`=ikHw-(XB+OY#gH`sbas7CP8b&a2smyhD zzfWEG)rJn{Ibvd@40rY&4TpQ^uf@uCnX24Ar32KKZTz^vH|nCt2Nf6Dxbo{+XBA66 zJ|V?jdd4s3CDz2R%!TlG?fA)vdVrFyTNaKhH!WMgtelYL~BjE;(mOuRP+Sv2A?siK*Yy_@z^=K@7Ad5}iS%=9b59 z%}27vw&E}49{6ao5czc~*yG`Y4|h2%RQ!y^>nsvm2R=x**K|C^Bv$jYoUoF#PyCpc z4P)|3g?{f&|Hdm?nF})g_1SHimsm>L#Pa^I5}YMz$`||7sz4jAO_qQ!((fSPd&Ivx zzz!Fu>eIf@XYb9JffrUsS6k@y`IcYDUDNm=Q7tl*lsImC4{c^Qj2Z+AVNN+~dId2HzUQPD zjCy;=1A1(f*?^2Zd~?xdCoj8Sne@%c@-KZtd)iIK;__EnX#(AM2jJ;$%vRE{wP04f zY(FETSX3u7ti}Xss&iT*I%D3fCQ~jt@2Nl>{pGv6+W6ICF4vhq{H7RJGUIp zSZpHkYf+`{UPS;(W7wzqS+T}+tJz>wdHdt z?iyp>jr@|fhpLbLSMt-9{1?J344IW^m2&r-y!w%Mr(TsUh9JlnjTldm+tl$BnxL_* z<3UQDDLPi<&>zt&$1%;TdYUN(<<|a3y2x9$e%hNbi*Z> zTC?8{PSdr!w@0XpD6A$$-L(BgQFUm47IsS%46JiWCXCU!45cx#L8}$0W}4yNsEdT- z2>-y#hXd|A!^(&q8F93?Me2&K@JQ*`9VxRV{Leqg>3YK&ixBlXxGhht{e{ef0hmq0 Z?qflx(YFE$A{D!iOsH+7&OBug{s%?v)5rh- literal 0 HcmV?d00001 diff --git a/ember/public/avatars/005.jpg b/ember/public/avatars/005.jpg new file mode 100755 index 0000000000000000000000000000000000000000..9916c8f51e9ca257d382d20edb8752ac122aeccb GIT binary patch literal 5524 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 literal 0 HcmV?d00001 diff --git a/ember/public/avatars/006.jpg b/ember/public/avatars/006.jpg new file mode 100755 index 0000000000000000000000000000000000000000..7dbc5888772061ba969640936d676ff1d863face GIT binary patch literal 7417 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 t=lGqPEmc)J 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 K&X@4(FaHCTr0bmk literal 0 HcmV?d00001 diff --git a/ember/public/avatars/008.jpg b/ember/public/avatars/008.jpg new file mode 100755 index 0000000000000000000000000000000000000000..2458a42b43a34442898fc6d02c056237254487f9 GIT binary patch literal 4266 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 QM#PDbqxzYx>VG=t<8 literal 0 HcmV?d00001 diff --git a/ember/public/avatars/009.jpg b/ember/public/avatars/009.jpg new file mode 100755 index 0000000000000000000000000000000000000000..b103b7dd89cf4dc27616fec5f4378823a5ea3a48 GIT binary patch literal 3695 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 Vp;X8(E{7>~5N9*3HaUMf`5&(weHj1% literal 0 HcmV?d00001 diff --git a/ember/public/avatars/010.jpg b/ember/public/avatars/010.jpg new file mode 100755 index 0000000000000000000000000000000000000000..142185afeeb87e572ff15af111fa341849a315a7 GIT binary patch literal 2937 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 literal 0 HcmV?d00001 diff --git a/ember/public/avatars/011.jpg b/ember/public/avatars/011.jpg new file mode 100755 index 0000000000000000000000000000000000000000..d74b535bcfed302e74897aaf07591d3534b45bd6 GIT binary patch literal 15768 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 literal 0 HcmV?d00001 diff --git a/ember/public/avatars/014.jpg b/ember/public/avatars/014.jpg new file mode 100755 index 0000000000000000000000000000000000000000..d7a5b0d02ff7815d8a20f834a81ed057340b1f13 GIT binary patch literal 3195 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

    syo_w>vtEque*6q)d_?B zo|>z|?-Le1M*RXVX_hOLf!dOJXZ(n20v@d$T%zztk5oXbAD?Hv03Rw{c4<8}C?lpy z=$aWzVDuINCSd5j)j0k@p-_Kivu8X_r0W4&MA)SI+c+K%ATqLpr0~y({r8}~5I4GM zibh?!J$p^%)f3U&tjak6!z)Z~Y%;FmvKx58vrm literal 0 HcmV?d00001 diff --git a/ember/public/avatars/015.jpg b/ember/public/avatars/015.jpg new file mode 100755 index 0000000000000000000000000000000000000000..5de26e5d9ac80d903fc58bb4429e5eaf8e5a3ebb GIT binary patch literal 6919 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 Xj7hG$>LcA&m+WA_7V( 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 eWekVFaB4=0S5 z2qGbb5{h(?qVy*4;=AXb_xpW&W_M?H=i51Z=Euy%_{9$Zv%U^o2S7nV0nj54zy%4Q z1)!q*i@!4!8Pq`Pzn}vGX@DR)Fc?G!0)ZKr7{K%pdJu?_g%QHU%*?_JW?*GwWo9Gq zng5bd{Pm=wrXxEt(}U>A*8e9iIshzmfaiecR1|^$N)`$#7K)25055<7KuJXomjdt~ zXz9oyf+%Px$;WEU0P@3>WCIXP^}kmr$O?dl1xU*(AWO%lVZttG=M|dBA@?K{+InfYVr!CrlF*wrThD~JPUw=l9~!g4Wyv?_Xb%7P|>iE zseu|McA<&1f?mUHa+jNo`$kCx%$mu00j((S&B{AJ zk3Ao?g9ZEDVdtBb;}Jb=w&9sEx{^WsydmlY ziqEiZ4y(Gi=s~o%RpOpa3`feXF9vvZzYwV3V(5;dHIG+smg&&VA|6W#1OYAqbIhsx zgMU%FTLi>^WBF=(PK=w={XG+JsAn4f$7$O_NNx)tA!Zh&{Zd=RY>6HI}V*OYVfOu zBfpW|${3^U3TQ39MC7YaQ~R2;(Ars#*LrK70M6(Hp9e`dIk(qsARgo-KYg6qVw6|c z+d5v!F!(}>qUV8=HQeF?5Tq*J8mnorH)A>EHzO2!9$tSO5H6^-xQ+Kc^( zNLIdNheDQ(RvAq?&0aU`5Z7k@PfD!p4(AVXH}Z887Haku8)yqZe+&G$kIDU!JFhIc=q6r(PJ}tQ zhB?;S3vy`YOcqZV@)ogj6raUWyUYF@jXQKBq@<;igo#U^GQRsqW!?hAF8~34=KBcP zms9+5a(kXThM{v$ZF)-6DrV`5$!bn8YunpL(1(>v-l)LEcB}}aZyZpy0e7mV@^H4K zbQeA-G5^*pccp)r=hxOkY&FR$c1J$<2A9zHiiO^G3x3J1*JX3I9*qm}Hyd(LDGM;+=DOA+GwQZ#e=h*ui_8e#SGlU~xe zIsq2}IKqfdD3@dP0^of1<;)pf(Y!DIy(~#%6-lu$%gOElsEo2|!a{DwjKnA;eV z_ws3%Zfo~-9M<1=V|VG726B!Aqv>tc%=#_nYvnKZ525pFyo$j)zNwP88`0a$r(kSv@PvKO8dO?iFY5B>hdG( zf^G-uQGPh6A4@GeSRTMjWbB|hsp~ss8;8JIVqmKnzOM2zE*NFS)dsi7kiK*b_hbhN zHYFc^h*AC;GnZf2DMfK}n?0E|E21}z(u-Bnyk6a4Nb0D za7^?Z3J8xMvT014LBK*Y-;e;_adp3h zsz8dC1CiI!%24kV!2GXwN&Om7|Hm0~S3jqF_?xVBJ(DlFiGV)N)%xK-rkmTKg4&!O zeN~vzZeS;MP5etYzL1v36uHGYVX+rnWw}o1xNDWk*un+rX65lRxQzs} zg&9#S2lx|+<=kzWNfAkQ!~>x=6Ag<=<-#GOWRajy)w_-GRPWLSAGY|og&x)ESFF~D zrcefCesf-UZc0DF_yYtpJ$^v%yH91Wt;VPpNjLwi@OQV4>du;>Z7isf`$4I~XzzU& zc1=^IWnzHp^|&h}PwVcx*j0M#Re7bmuOy#PsLr@J z*TZXN0M&={UjW=XZE|)kAZgQvCJ%r7&_aq@KX%}&_1rGnxmPI{{ZejOoYqSarr?>1 zE*LV3(NpVQa2xRo7{)~S#Jx|o({tCPy8zU%v#ZzXofT+#Y)$vm6HUq!RGLeY6=}pdIG?k?Y|SU2zE`EmLW#mB~E{3mVZXi8sRO z*kpb;Jt4va>E2C_I_k*EB{Pnld~vWU<%a}gegwr2j;9H-oQV9Z2SGF5jaE9d)-!+~ zQaQBDRW1lK4d^5(c4EYvO4H_K)?=hv&Tr9X>sqhA?3mEYvPDVe!A~bl4%{3zg~AVx z>CW@tez@^+XT&Wts9C#)WNC^og1y|x6}oC*31IqG2q83MueDmaey^VpSPf7bAFR7W zD=v3a*FWA91pWPJCvVowRJF67$vuik7yv#4FYXX0rxC`H9l^?%nB* zNh+f(j zn-1tRCEV^eYb9oD{6?7R9u2zIFT8F{MWGUKV&W3;PjFs37_dMT!})4@dE!j1`8>|8 zC&Pg9a4J>Mnl&-}MBj8>phHHbYmtvd(@eCF63J!TW?G;0Fs+jK`#SnCzr&mow z>(EQXR`NBl*#zzb=gJj% zLT(2$^D!wt3fGgqY9u;pTKSJCNE9>#!*H0JR!Jiqv)$qAb{-8giZURd9khnJI7ri& z84DLml=!d^xgf|ODeSWK6-zKT?OOPD6(>5V{evmJ95cSL8H#%Ajq6SlioR~0ZuNrp zTmh>146b+u#n2Z@Re!4W#e-MDoy)9;?{Qz^e%Bw%*zEO`?@P%dtLN z75^hrh6&JWCwZKFBUcDmc@3mk-Uxc7xBWJ(YK8}X6tf%LGv(T?@RL0v_l|^qBv@}z zqby(@F+H57U>odvx*r|_kpz_tW2be8MEiqbZ%ERKJ8j6x`%-h~!!4Z0f#G3Ok^@Jp zY6wE|yP(ht=f%FH%3BB zF_ky*yiT`Qq{{PG=ByAN4SlhZg|?4MrzLa79iyY563^AU%A&BA_L+Mx=GE*J`sFh7k5~M4iwJ>E zMB$A5>`EWYL8%bi1=m&Q)mJTV+XZktv10qkYRUD3+GUANV(eDmBZ5!5kK~jHxw6Bj z2d4rx@gXy9?6ddmQ+MvxRxWh(LMCE!vui!@Yh3w@`P;^ggr`eT1ik=$G#*I&J~Jk$ z;{J66I|Yl7=i1sGE;0^_3O&D`R$SObIA-G>kXxo_f#w$xxbJgcCEc;t#Hr_X#Llx9 zNSYavOE3Qa6GUOxhT$*YDi{Cd>F6T+n`%n#vc2(3HEg zG5NB;aEPoBx%p#4wImf^FWGreI06ie+h(v*!~HsN_faZsHXeAh^VVU&c|>f-u7`*x zj1-eQ8$^EbKVhnLzpIkhfQvpMB*VdlACJOvF@( zj%}{(!s{g5F}a0W8WK~N)rS;IXJ)>$=c(J=^xIPKU|HQ)2yaAwdO+hkjPk`H!AOUY zi9!4b_+G1vL8rJblI3#qtaY%X<1P1C&F8>v?}3K$_F7uDJ+M#%OY`?j2MYB zaRCPxfP{F9ZD{OWxB(`}>9H`V>-b4 W+|aL~?N8i^e>!k3_it8QO#BaMSzwO< literal 0 HcmV?d00001 diff --git a/ember/public/avatars/018.jpg b/ember/public/avatars/018.jpg new file mode 100755 index 0000000000000000000000000000000000000000..f9f78387fb82ba648df8553ae2d58f2e5cc5a762 GIT binary patch literal 6111 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 v>-ET5&ZYsxZ`q=ceuBl|u?Y3yy5 zjAdHvj6oyo$Yd8`&ggvST<83G&i%Wt-}OHCb3ga{T<>+g@AWW;nO^}xQ`9*W00M!4 zv#bwbjsivim~9{Xe=rN|ob3C6aB^~Razl7{xFOu!JiG$DJO}s>aC7rP`S=9{1)+jG zyh6f4g2Jq|;JyiHzY@$2VHFA<;6A`&|3@(215gNX8@LSy$pCCn5Eu$#wgD0V!0Kwh z-G2kj#>oZY=3w7X>j?s^joH}QIoQA)-1`y`zy@aL5aNV#L8Py$ow5!P7E!+tbvp0h zOPY*pU{W*pAsAd%!{}NvtNI8C00aMR&dv$u-~zEhSXou*Z*LAZ4vxRK|LqMG;t=MP zW;tKY6OjpsT2fDX*+W}yW>NrNFiQXhLjglzQ(d8>)D8L=J<8F%YSU35llK^P>M&eW zFHT)al-&(_N8|q?kwm#E>m&>g4+SyoC1XB>8#(hh~&AK+Bt!-a} zqWqRPioa(#1aH*$3NEO4`B%wII(aF2$^W!S)T*!hL*fBKRx%rq)fC%~KuFM|yEdlD z$+agua;(cOURn7>;UbRJj2t)`&=(WA6m8q*c%?GAcqBNX_sd7bNWA5zgtlN=-xFJR zUXC08RhX?b?5M}Ns2JLH%hMxR_@R!xw5m*eoGV79J}oWSeM}iK*WHN4$kk{5Gg?^g zo_Ujd z2SnEk=UED5lwq^%!}`S%U)iC3vh>^EVr{M9kNRgNHa1qkJ(n=po4)6#dd1$uGrD6M&a>pyuDg{%IDjH-7At@?(@@QzOf2oct;XHq2fvMH@D|%tdqkYB?~ln zdRgOI15J#Zj^ufr$Q0n6dV8^bZo2;<>Uo0y@Xn!lxM9|V4QZ?8a#!E9)%6a?1tx$f z{du%w(enG8nBUs8U)Zt8y)W8rA9V|l3}MbI6AqkRCei95C)^1abyt1F6bV`Jt+@u4 zd*y1YpR2sqVM3*AS?rT%gQwNphOxFYPN^i9_Qf?jKHoth1i-Qs(5@v-Bauh_jG zX5NK})Y@4a38Y)H)4Z&|=O0654zt-RQo%2lqqgz6X6^ zfo$Dbso)qS`O}Hel0hA*OZh>u47MK6lD#Xh&1-*OY< ziVE0C^eM!HO-6$`<6q4!*jl*j6OtbD#rA(SBz~hd1#Mk+`0OVn_humuA!%*J1dOlV zw>tkN^hEKgcP>k>{PyA|3^_Uz)g}u2pVXvSi}^jtzz;34XXZ!DFtx_ZAHu(BRp(e!sdJwB z@GQRsn=`^ZZQaOr>s#pg+6{~*h@5O!sB^0qST zPB^i*zTce~uvuZebCmXp#!o`%5QqGL`_yCVMAOlOIPz`>dAIX;7+lrehFF9Zeq|U) ze*JoRLlSC*zW&a--)_X0U#Mw%L)}P0+-)#N(B%I2Ln7THqG7T%dxLq^XoHvf)R&|W?IwHhV6s}{U*~cvs*pjRg)aX#Hs zy+x0MxJz0)m_R0<6Nb8#?iAVM>NA#Z2evc)A}`_8v&{quwJUXwJBH~J;G64ut z^%&KFK&Y%g>3!ElBlPtruz4kT-jCA!b&v_DUrY;9BtQR_0E@kR>__TD)AJp;%b5{b zwlpSyciu}_*>osLEqLgS6H8qz@tXNzm=#N{@F!Wch%~Jq3Neb6{z>O;gI|lZbVxyl=Owaza`Ms+JH4>%@mbop4akYUYs69D)12IeLR9! zGwX||_n$ddGJVvHA^)!+f#|EuxGH-0O^Y@U34>zDdqC68sBnK4FYf_sXxYA5rqfhV zb^bA_HMB@o(vpML^lRO2w`Cn!!# zcZQSe>vvZ3BgKWiwn9+euu1w*G!wXSYJQ`xoAlv|MVB+WCoP2HN&Ff7Q{2jO6p3@% zuKWXC`nmKIJuVt6)h%%5W0&^tJKJ|tPFq-8fm*bGrYH#2oLUh0d$c(W4{+XOB>XC3 zja?UYy)^fs2to zd%NNZ;x1xE+RQk&xxl36?5O(Ydc#T>AtQvI;Ekq`#y0ATVxvZs5gpyt&eP&4>vy&~ zHU18sUWtFA{yn})j$z$^g8V0)@wX44=-ev_0|eHj|3u9(4k O;@pUE5jT70$Ugv3`vG+T literal 0 HcmV?d00001 diff --git a/ember/public/avatars/020.jpg b/ember/public/avatars/020.jpg new file mode 100755 index 0000000000000000000000000000000000000000..b94645756cf2737a0e879bc78ea213ff24704628 GIT binary patch literal 2064 zcmbtT3pA8z7=CBQFrxI&&_xV}>KH~*(ypT|#w|k(L+&wdGsdNHNtZ3PsZ^Aj*t98B zF4JUXmDrrN6BXNTvMwbmm1T-OMRKdz?`O+7d+O}&+4npD_de&nJm2>|=ld1yipMZ} zt%I`z5C{ZtLJJh##M91pcERr4bq>xPdz1hm9v1Oq5|ow##Kb0wxsJB_>wW$7RXSk? z(18j@U=_koh+pIG&VkR`a4R4tfMyoX_1UsdC)C2i6ZilG3QDi!$BPpYUX8HZro?z0 zA4XV{9~BaY@EL?n#pr?v-^JshQ}`KTSFJ2aa*!upX@*c8X1Qy8B`pC2WT zK{Xmq=ez5q0&^$h?2o1fAfapU|@*aqOW6@YUG;CL>;B6OZcfT9EJfJ{KcjtvQA}^X)uCaYDrPcS2<% zS%pABNhXF95>?65=i!8yfjL>3N@F0xjoh7C`Q=X(4?vxSh%gccU%}ng> z^%bP{bj#oLco%;!a`zCf%X@Y|LnxF*Tn#SxnpQe&e=DrZH^98}<^z*K#`@l`oT(mG z#U<~*-0ssb6xYTt-fkG%`qChE!v<@zc6^7W&6wAv$C)jUg5+h%4bnD8s=(uk-=W4K zSs%+ny0Cf`qf45jHB@9d7#nOe7tRFVxO(TMg=|ALb!6i+z|qd_l~WfFo!8$&1I|&?23^0^5IGXV%|j)wj&ZBHO`C z=Js}55!WUyvn8Xm?Awz2wez2MjxCl{x`)=MQ6)|iC+74hTqh&=GH%$!YTsQJh%KYV4lfXTVsLRrBEmAW!FDX@kzoc+kxeX^c zW5BA;N6hqkUMHMb?q5M0b!}_$y0X5j&Xf8iJ$pq!w0{TdyXs@bm0AJ6DVu z*AE;g?3T@uzg=*FRaKv^yML@QsKGVbvwuSH@Tl2|u6?b~eBSstt=aXq_3-m9Yb6)v z=v$u2=|zur7`w=l+gtgv{$xq;lD)Y@qsO%OrI%&iFmx~BOUpZ49sSzm0Jr{9DtzgA zsgX&`e4p{e6B4G&jq<3LwcA==X7Wb16(seB{v>B*_h=cPbugKGinFSb=J-laly5JI z+Hl{iuUTk$Fzgb;k`~5h#|>?A(J7(Hutkk~dU-FJVjOQKjAS1vjlM1|5EgrJQm^kQ zO_;Id2kz6IvG)RGo{{loRdr)Eu0Jxe8#TMtyysu^sp(p{V!5Aj^@V}0HI^l>zSzoG zkWxt>mgZII9QA!{cVJ;wNX4N%rZm%e|M-L+$9~JD^(?B{Wr64b)jH*rN8>QtO@0-l zB{9w7dIB4DA70_~urJ=-US9B`CgdB@fF0DA&fe|%Wor3_&WgMQr9eAGWK zOB&SjBKGt^(iV|xu;b5BnyPjh@}9vIz`A7*jxWD)aq#*&frK02?C z>*^qo`N&UI+Lv^uY-+xEg_pe2A>wIGt#z$w-+-UFG4qU5O!E1HN2@Lb4UCH%e^w7q zsn0oE)yXt8uJEou;JQO-y~?{NFSm?0Zg7EW;{AZl7u(qtB${ + + + + + Flarum Tests + + + + {{BASE_TAG}} + + + + + + + +

    +
    + + + + + + + + + + diff --git a/ember/tests/test-helper.js b/ember/tests/test-helper.js new file mode 100644 index 000000000..a182571c4 --- /dev/null +++ b/ember/tests/test-helper.js @@ -0,0 +1,6 @@ +import resolver from './helpers/resolver'; +import { setResolver } from 'ember-qunit'; + +setResolver(resolver); + +document.write('
    '); diff --git a/ember/tests/unit/.gitkeep b/ember/tests/unit/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 000000000..12bee7aa9 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + ./tests/ + + + \ No newline at end of file diff --git a/src/Flarum/Api/Actions/Base.php b/src/Flarum/Api/Actions/Base.php new file mode 100644 index 000000000..de2c85654 --- /dev/null +++ b/src/Flarum/Api/Actions/Base.php @@ -0,0 +1,215 @@ +commandBus = $commandBus; + } + + abstract protected function run(); + + public function handle($request, $parameters) + { + $this->registerErrorHandlers(); + + $this->request = $request; + $this->parameters = $parameters; + + $this->document = new Document; + $this->document->addMeta('profile', '?'); + + return $this->run(); + } + + public function param($key, $default = null) + { + return array_get($this->parameters, $key, $default); + } + + public function input($key, $default = null) + { + return $this->request->input($key, $default); + } + + public function fillCommandWithInput($command, $inputKey = null) + { + $input = $inputKey ? $this->input($inputKey) : $this->request->input->all(); + + foreach ($input as $k => $v) { + $command->$k = $v; + } + } + + protected function inputRange($key, $default = null, $min = null, $max = null) + { + $value = (int) $this->input($key, $default); + + if (! is_null($min)) { + $value = max($value, $min); + } + if (! is_null($max)) { + $value = min($value, $max); + } + return $value; + } + + protected function included($available) + { + $requested = explode(',', $this->input('include')); + return array_intersect($available, $requested); + } + + protected function explodeIds($ids) + { + return array_unique(array_map('intval', array_filter(explode(',', $ids)))); + } + + protected function inputIn($key, $options) + { + $value = $this->input($key); + + if (array_key_exists($key, $options)) { + return $options[$key]; + } + if (! in_array($value, $options)) { + $value = reset($options); + } + + return $value; + } + + protected function sort($options) + { + $criteria = (string) $this->input('sort', ''); + $order = $criteria ? 'asc' : null; + + if ($criteria && $criteria[0] == '-') { + $order = 'desc'; + $criteria = substr($criteria, 1); + } + + if (! in_array($criteria, $options)) { + $criteria = reset($options); + } + + return [ + 'by' => $criteria, + 'order' => $order, + 'string' => ($order == 'desc' ? '-' : '').$criteria + ]; + } + + protected function start() + { + return $this->inputRange('start', 0, 0); + } + + protected function count($default, $max = 100) + { + return $this->inputRange('count', $default, 1, $max); + } + + protected function buildUrl($route, $params = [], $input = []) + { + $url = route('flarum.api.'.$route, $params); + $queryString = $input ? '?'.http_build_query($input) : ''; + + return $url.$queryString; + } + + protected function respondWithoutContent($statusCode = 204, $headers = []) + { + return Response::make('', $statusCode, $headers); + } + + protected function respondWithArray($array, $statusCode = 200, $headers = []) + { + // @todo remove this + $headers['Access-Control-Allow-Origin'] = 'http://0.0.0.0:4200'; + + return Response::json($array, $statusCode, $headers); + } + + protected function respondWithDocument($statusCode = 200, $headers = []) + { + // @todo remove this + $this->document->addMeta('pageload', microtime(true) - LARAVEL_START); + + Event::fire('flarum.api.willRespondWithDocument', [$this->document]); + + $headers['Content-Type'] = 'application/vnd.api+json'; + + return $this->respondWithArray($this->document->toArray(), $statusCode, $headers); + } + + // @todo fix this + protected function call($name, $params, $method, $input) + { + Input::replace($input); + + $url = URL::action('\\Flarum\\Api\\Controllers\\'.$name, $params, false); + $request = Request::create($url, $method); + $json = Route::dispatch($request)->getContent(); + + return json_decode($json, true); + } + + protected function registerErrorHandlers() + { + if (! Config::get('app.debug')) { + App::error(function ($exception, $code) { + return $this->respondWithError('ApplicationError', $code); + }); + } + + App::error(function (ModelNotFoundException $exception) { + return $this->respondWithError('ResourceNotFound', 404); + }); + + App::error(function (ValidationFailureException $exception) { + $errors = []; + foreach ($exception->getErrors()->getMessages() as $field => $messages) { + $errors[] = [ + 'code' => 'ValidationFailure', + 'detail' => implode("\n", $messages), + 'path' => $field + ]; + } + return $this->respondWithErrors($errors, 422); + }); + } + + protected function respondWithErrors($errors, $httpCode = 500) + { + return Response::json(['errors' => $errors], $httpCode); + } + + protected function respondWithError($error, $httpCode = 500, $detail = null) + { + $error = ['code' => $error]; + + if ($detail) { + $error['detail'] = $detail; + } + + return $this->respondWithErrors([$error], $httpCode); + } +} diff --git a/src/Flarum/Api/Actions/Discussions/Create.php b/src/Flarum/Api/Actions/Discussions/Create.php new file mode 100644 index 000000000..2456c10af --- /dev/null +++ b/src/Flarum/Api/Actions/Discussions/Create.php @@ -0,0 +1,35 @@ +input('discussions.title'); + $content = $this->input('discussions.content'); + $command = new StartDiscussionCommand($title, $content, User::current()); + + Event::fire('Flarum.Api.Actions.Discussions.Create.WillExecuteCommand', [$command, $this->document]); + + $discussion = $this->commandBus->execute($command); + + $serializer = new DiscussionSerializer(['posts']); + $this->document->setPrimaryElement($serializer->resource($discussion)); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/Actions/Discussions/Delete.php b/src/Flarum/Api/Actions/Discussions/Delete.php new file mode 100644 index 000000000..5119e9a10 --- /dev/null +++ b/src/Flarum/Api/Actions/Discussions/Delete.php @@ -0,0 +1,26 @@ +param('id'); + $command = new DeleteDiscussionCommand($discussionId, User::current()); + + Event::fire('Flarum.Api.Actions.Discussions.Delete.WillExecuteCommand', [$command]); + + $this->commandBus->execute($command); + + return $this->respondWithoutContent(); + } +} diff --git a/src/Flarum/Api/Actions/Discussions/Index.php b/src/Flarum/Api/Actions/Discussions/Index.php new file mode 100644 index 000000000..a953bce4e --- /dev/null +++ b/src/Flarum/Api/Actions/Discussions/Index.php @@ -0,0 +1,84 @@ +finder = $finder; + } + + /** + * Show a list of discussions. + * + * @todo custom rate limit for this function? determined by if $key was valid? + * @return Response + */ + protected function run() + { + $query = $this->input('q'); + $key = $this->input('key'); + $start = $this->start(); + $include = $this->included(['startPost', 'lastPost', 'relevantPosts']); + $count = $this->count($include ? 20 : 50, 50); + $sort = $this->sort(['', 'lastPost', 'replies', 'created']); + + $relations = array_merge(['startUser', 'lastUser'], $include); + + // Set up the discussion finder with our search criteria, and get the + // requested range of results with the necessary relations loaded. + $this->finder->setUser(User::current()); + $this->finder->setQuery($query); + $this->finder->setSort($sort['by']); + $this->finder->setOrder($sort['order']); + $this->finder->setKey($key); + + $discussions = $this->finder->results($count, $start, array_merge($relations, ['state'])); + + if (($total = $this->finder->getCount()) !== null) { + $this->document->addMeta('total', $total); + } + if (($key = $this->finder->getKey()) !== null) { + $this->document->addMeta('key', $key); + } + + // If there are more results, then we need to construct a URL to the + // next results page and add that to the metadata. We do this by + // compacting all of the valid query parameters which have been + // specified. + if ($this->finder->areMoreResults()) { + $start += $count; + $include = implode(',', $include); + $sort = $sort['string']; + $input = array_filter(compact('query', 'key', 'sort', 'start', 'count', 'include')); + $moreUrl = $this->buildUrl('discussions.index', [], $input); + } else { + $moreUrl = ''; + } + $this->document->addMeta('moreUrl', $moreUrl); + + // Finally, we can set up the discussion serializer and use it to create + // a collection of discussion results. + $serializer = new DiscussionSerializer($relations); + $this->document->setPrimaryElement($serializer->collection($discussions)); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/Actions/Discussions/Show.php b/src/Flarum/Api/Actions/Discussions/Show.php new file mode 100644 index 000000000..3b6e1238a --- /dev/null +++ b/src/Flarum/Api/Actions/Discussions/Show.php @@ -0,0 +1,29 @@ +included(['startPost', 'lastPost']); + + $discussion = Discussion::whereCanView()->findOrFail($this->param('id')); + + // Set up the discussion serializer, which we will use to create the + // document's primary resource. As well as including the requested + // relations, we will specify that we want the 'posts' relation to be + // linked so that a list of post IDs will show up in the response. + $serializer = new DiscussionSerializer($include, ['posts']); + $this->document->setPrimaryElement($serializer->resource($discussion)); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/Actions/Discussions/Update.php b/src/Flarum/Api/Actions/Discussions/Update.php new file mode 100644 index 000000000..5e6d235f2 --- /dev/null +++ b/src/Flarum/Api/Actions/Discussions/Update.php @@ -0,0 +1,51 @@ +param('id'); + $readNumber = $this->input('discussions.readNumber'); + + // First, we will run the EditDiscussionCommand. This will update the + // discussion's direct properties; by default, this is just the title. + // As usual, however, we will fire an event to allow plugins to update + // additional properties. + $command = new EditDiscussionCommand($discussionId, User::current()); + $this->fillCommandWithInput($command, 'discussions'); + + Event::fire('Flarum.Api.Actions.Discussions.Update.WillExecuteCommand', [$command]); + + $discussion = $this->commandBus->execute($command); + + // Next, if a read number was specified in the request, we will run the + // ReadDiscussionCommand. We won't bother firing an event for this one, + // because it's pretty specific. (This may need to change in the future.) + if ($readNumber) { + $command = new ReadDiscussionCommand($discussionId, User::current(), $readNumber); + $discussion = $this->commandBus->execute($command); + } + + // Presumably, the discussion was updated successfully. (One of the command + // handlers would have thrown an exception if not.) We set this + // discussion as our document's primary element. + $serializer = new DiscussionSerializer; + $this->document->setPrimaryElement($serializer->resource($discussion)); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/Actions/Groups/Index.php b/src/Flarum/Api/Actions/Groups/Index.php new file mode 100644 index 000000000..916686c4d --- /dev/null +++ b/src/Flarum/Api/Actions/Groups/Index.php @@ -0,0 +1,18 @@ +document->setPrimaryElement($serializer->collection($groups)); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/Actions/Posts/Create.php b/src/Flarum/Api/Actions/Posts/Create.php new file mode 100644 index 000000000..c6864b048 --- /dev/null +++ b/src/Flarum/Api/Actions/Posts/Create.php @@ -0,0 +1,39 @@ +input('posts.links.discussions'); + $content = $this->input('posts.content'); + $command = new PostReplyCommand($discussionId, $content, User::current()); + + Event::fire('Flarum.Api.Actions.Posts.Create.WillExecuteCommand', [$command]); + + $post = $this->commandBus->execute($command); + + // Presumably, the post was created successfully. (The command handler + // would have thrown an exception if not.) We set this post as our + // document's primary element. + $serializer = new PostSerializer; + $this->document->setPrimaryElement($serializer->resource($post)); + + return $this->respondWithDocument(201); + } +} diff --git a/src/Flarum/Api/Actions/Posts/Delete.php b/src/Flarum/Api/Actions/Posts/Delete.php new file mode 100644 index 000000000..ee8cf2e6b --- /dev/null +++ b/src/Flarum/Api/Actions/Posts/Delete.php @@ -0,0 +1,26 @@ +param('id'); + $command = new DeletePostCommand($postId, User::current()); + + Event::fire('Flarum.Api.Actions.Posts.Delete.WillExecuteCommand', [$command]); + + $this->commandBus->execute($command); + + return $this->respondWithoutContent(); + } +} diff --git a/src/Flarum/Api/Actions/Posts/Index.php b/src/Flarum/Api/Actions/Posts/Index.php new file mode 100644 index 000000000..6b53b0f23 --- /dev/null +++ b/src/Flarum/Api/Actions/Posts/Index.php @@ -0,0 +1,62 @@ +input('discussions'); + + $count = $this->count(20, 50); + + if ($near = $this->input('near')) { + // fetch the nearest post + $post = Post::orderByRaw('ABS(CAST(number AS SIGNED) - ?)', [$near])->whereNotNull('number')->where('discussion_id', $discussionId)->take(1)->first(); + + $start = max( + 0, + Post::whereCanView() + ->where('discussion_id', $discussionId) + ->where('time', '<=', $post->time) + ->count() - round($count / 2) + ); + } else { + $start = $this->start(); + } + + $include = $this->included([]); + $sort = $this->sort(['time']); + + $relations = array_merge(['user', 'user.groups', 'editUser', 'deleteUser'], $include); + + // @todo move to post repository + $posts = Post::with($relations) + ->whereCanView() + ->where('discussion_id', $discussionId) + ->skip($start) + ->take($count) + ->orderBy($sort['by'], $sort['order'] ?: 'asc') + ->get(); + + if (! count($posts)) { + throw new ModelNotFoundException; + } + + // Finally, we can set up the post serializer and use it to create + // a post resource or collection, depending on how many posts were + // requested. + $serializer = new PostSerializer($relations); + $this->document->setPrimaryElement($serializer->collection($posts)); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/Actions/Posts/Show.php b/src/Flarum/Api/Actions/Posts/Show.php new file mode 100644 index 000000000..3bd2df188 --- /dev/null +++ b/src/Flarum/Api/Actions/Posts/Show.php @@ -0,0 +1,39 @@ +explodeIds($this->param('id')); + $posts = Post::whereCanView()->whereIn('id', $ids)->get(); + + if (! count($posts)) { + throw new ModelNotFoundException; + } + + $include = $this->included(['discussion', 'replyTo']); + $relations = array_merge(['user', 'editUser', 'deleteUser'], $include); + $posts->load($relations); + + // Finally, we can set up the post serializer and use it to create + // a post resource or collection, depending on how many posts were + // requested. + $serializer = new PostSerializer($relations); + $this->document->setPrimaryElement( + count($ids) == 1 ? $serializer->resource($posts->first()) : $serializer->collection($posts) + ); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/Actions/Posts/Update.php b/src/Flarum/Api/Actions/Posts/Update.php new file mode 100644 index 000000000..cdf0a3399 --- /dev/null +++ b/src/Flarum/Api/Actions/Posts/Update.php @@ -0,0 +1,39 @@ +param('id'); + + // EditPost is a single command because we don't want to allow partial + // updates (i.e. if we were to run one command and then another, if the + // second one failed, the first one would still have succeeded.) + $command = new EditPostCommand($postId, User::current()); + $this->fillCommandWithInput($command, 'posts'); + + Event::fire('Flarum.Api.Actions.Posts.Update.WillExecuteCommand', [$command]); + + $post = $this->commandBus->execute($command); + + // Presumably, the post was updated successfully. (The command handler + // would have thrown an exception if not.) We set this post as our + // document's primary element. + $serializer = new PostSerializer; + $this->document->setPrimaryElement($serializer->resource($post)); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/Actions/Users/Create.php b/src/Flarum/Api/Actions/Users/Create.php new file mode 100644 index 000000000..7890bd844 --- /dev/null +++ b/src/Flarum/Api/Actions/Users/Create.php @@ -0,0 +1,39 @@ +input('users.username'); + $email = $this->input('users.email'); + $password = $this->input('users.password'); + $command = new RegisterUserCommand($username, $email, $password, User::current()); + + Event::fire('Flarum.Api.Actions.Users.Create.WillExecuteCommand', [$command]); + + $user = $this->commandBus->execute($command); + + // Presumably, the user was created successfully. (The command handler + // would have thrown an exception if not.) We set this post as our + // document's primary element. + $serializer = new UserSerializer; + $this->document->setPrimaryElement($serializer->resource($user)); + + return $this->respondWithDocument(201); + } +} diff --git a/src/Flarum/Api/Actions/Users/Delete.php b/src/Flarum/Api/Actions/Users/Delete.php new file mode 100644 index 000000000..2f58caa42 --- /dev/null +++ b/src/Flarum/Api/Actions/Users/Delete.php @@ -0,0 +1,26 @@ +param('id'); + $command = new DeleteUserCommand($userId, User::current()); + + Event::fire('Flarum.Api.Actions.Users.Delete.WillExecuteCommand', [$command]); + + $this->commandBus->execute($command); + + return $this->respondWithoutContent(); + } +} diff --git a/src/Flarum/Api/Actions/Users/Index.php b/src/Flarum/Api/Actions/Users/Index.php new file mode 100644 index 000000000..57fd4eea7 --- /dev/null +++ b/src/Flarum/Api/Actions/Users/Index.php @@ -0,0 +1,83 @@ +finder = $finder; + } + + /** + * Show a list of users. + * + * @todo custom rate limit for this function? determined by if $key was valid? + * @return Response + */ + protected function run() + { + $query = $this->input('q'); + $key = $this->input('key'); + $sort = $this->sort(['', 'username', 'posts', 'discussions', 'lastActive', 'created']); + $start = $this->start(); + $count = $this->count(50, 100); + $include = $this->included(['groups']); + $relations = array_merge(['groups'], $include); + + // Set up the user finder with our search criteria, and get the + // requested range of results with the necessary relations loaded. + $this->finder->setUser(User::current()); + $this->finder->setQuery($query); + $this->finder->setSort($sort['by']); + $this->finder->setOrder($sort['order']); + $this->finder->setKey($key); + + $users = $this->finder->results($count, $start); + $users->load($relations); + + if (($total = $this->finder->getCount()) !== null) { + $this->document->addMeta('total', $total); + } + if (($key = $this->finder->getKey()) !== null) { + $this->document->addMeta('key', $key); + } + + // If there are more results, then we need to construct a URL to the + // next results page and add that to the metadata. We do this by + // compacting all of the valid query parameters which have been + // specified. + if ($this->finder->areMoreResults()) { + $start += $count; + $include = implode(',', $include); + $sort = $sort['string']; + $input = array_filter(compact('query', 'key', 'sort', 'start', 'count', 'include')); + $moreUrl = $this->buildUrl('users.index', [], $input); + } else { + $moreUrl = ''; + } + $this->document->addMeta('moreUrl', $moreUrl); + + // Finally, we can set up the user serializer and use it to create + // a collection of user results. + $serializer = new UserSerializer($relations); + $this->document->setPrimaryElement($serializer->collection($users)); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/Actions/Users/Show.php b/src/Flarum/Api/Actions/Users/Show.php new file mode 100644 index 000000000..11da5c518 --- /dev/null +++ b/src/Flarum/Api/Actions/Users/Show.php @@ -0,0 +1,26 @@ +findOrFail($this->param('id')); + + // Set up the user serializer, which we will use to create the + // document's primary resource. We will specify that we want the + // 'groups' relation to be included by default. + $serializer = new UserSerializer(['groups']); + $this->document->setPrimaryElement($serializer->resource($user)); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/Actions/Users/Update.php b/src/Flarum/Api/Actions/Users/Update.php new file mode 100644 index 000000000..ed15e0a3c --- /dev/null +++ b/src/Flarum/Api/Actions/Users/Update.php @@ -0,0 +1,40 @@ +param('id'); + + // EditUser is a single command because we don't want to allow partial + // updates (i.e. if we were to run one command and then another, if the + // second one failed, the first one would still have succeeded.) + $command = new EditUserCommand($userId, User::current()); + $this->fillCommandWithInput($command, 'users'); + + Event::fire('Flarum.Api.Actions.Users.Update.WillExecuteCommand', [$command]); + + $user = $this->commandBus->execute($command); + + // Presumably, the user was updated successfully. (The command handler + // would have thrown an exception if not.) We set this user as our + // document's primary element. + $serializer = new UserSerializer; + $this->document->setPrimaryElement($serializer->resource($user)); + + return $this->respondWithDocument(); + } +} diff --git a/src/Flarum/Api/ApiServiceProvider.php b/src/Flarum/Api/ApiServiceProvider.php new file mode 100644 index 000000000..35a102acc --- /dev/null +++ b/src/Flarum/Api/ApiServiceProvider.php @@ -0,0 +1,46 @@ +package('flarum/api', 'flarum.api'); + + include __DIR__.'/../../routes.api.php'; + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return array(); + } +} diff --git a/src/Flarum/Api/Serializers/ActivitySerializer.php b/src/Flarum/Api/Serializers/ActivitySerializer.php new file mode 100644 index 000000000..65c1369f1 --- /dev/null +++ b/src/Flarum/Api/Serializers/ActivitySerializer.php @@ -0,0 +1,19 @@ + (int) $activity->id + ]; + + Event::fire('flarum.api.serialize.activity', [&$serialized]); + + return $serialized; + } + +} diff --git a/src/Flarum/Api/Serializers/BaseSerializer.php b/src/Flarum/Api/Serializers/BaseSerializer.php new file mode 100644 index 000000000..c8718fe39 --- /dev/null +++ b/src/Flarum/Api/Serializers/BaseSerializer.php @@ -0,0 +1,102 @@ + (int) $discussion->id, + 'title' => $discussion->title, + ]; + + return $this->attributesEvent($discussion, $attributes); + } + + /** + * Get the URL templates where this resource and its related resources can + * be accessed. + * + * @return array + */ + protected function href() + { + $href = [ + 'discussions' => $this->action('DiscussionsController@show', ['id' => '{discussions.id}']), + 'posts' => $this->action('PostsController@indexForDiscussion', ['id' => '{discussions.id}']) + ]; + + return $this->hrefEvent($href); + } +} diff --git a/src/Flarum/Api/Serializers/DiscussionSerializer.php b/src/Flarum/Api/Serializers/DiscussionSerializer.php new file mode 100644 index 000000000..038e26d2f --- /dev/null +++ b/src/Flarum/Api/Serializers/DiscussionSerializer.php @@ -0,0 +1,137 @@ + (int) $discussion->posts_count, + 'startTime' => $discussion->start_time->toRFC3339String(), + 'lastTime' => $discussion->last_time ? $discussion->last_time->toRFC3339String() : null, + 'lastPostNumber' => $discussion->last_post_number, + 'canEdit' => $discussion->permission('edit'), + 'canDelete' => $discussion->permission('delete'), + + // temp + 'sticky' => $discussion->sticky, + 'category' => $discussion->category + ]; + + if ($state = $discussion->state) { + $attributes += [ + 'readTime' => $state->read_time ? $state->read_time->toRFC3339String() : null, + 'readNumber' => (int) $state->read_number + ]; + } + + return $this->attributesEvent($discussion, $attributes); + } + + /** + * Get a collection containing a discussion's viewable post IDs. + * + * @param Discussion $discussion + * @return Tobscure\JsonApi\Collection + */ + public function linkPosts(Discussion $discussion) + { + return (new PostBasicSerializer)->collection($discussion->posts()->whereCanView()->ids()); + } + + /** + * Get a collection containing a discussion's viewable posts. + * + * @param Discussion $discussion + * @param array $relations + * @return Tobscure\JsonApi\Collection + */ + public function includePosts(Discussion $discussion, $relations) + { + return (new PostSerializer($relations))->collection($discussion->posts()->whereCanView()->get()); + } + + /** + * Get a collection containing a discussion's relevant posts. Assumes that + * the discussion model's relevantPosts attributes has been filled (this + * happens in the DiscussionFinder.) + * + * @param Discussion $discussion + * @param array $relations + * @return Tobscure\JsonApi\Collection + */ + public function includeRelevantPosts(Discussion $discussion, $relations) + { + return (new PostBasicSerializer($relations))->collection($discussion->relevantPosts); + } + + /** + * Get a resource containing a discussion's start user. + * + * @param Discussion $discussion + * @param array $relations + * @return Tobscure\JsonApi\Resource + */ + public function includeStartUser(Discussion $discussion, $relations) + { + return (new UserBasicSerializer($relations))->resource($discussion->startUser); + } + + /** + * Get a resource containing a discussion's starting post. + * + * @param Discussion $discussion + * @param array $relations + * @return Tobscure\JsonApi\Resource + */ + public function includeStartPost(Discussion $discussion, $relations) + { + return (new PostBasicSerializer($relations))->resource($discussion->startPost); + } + + /** + * Get a resource containing a discussion's last user. + * + * @param Discussion $discussion + * @param array $relations + * @return Tobscure\JsonApi\Resource + */ + public function includeLastUser(Discussion $discussion, $relations) + { + return (new UserBasicSerializer($relations))->resource($discussion->lastUser); + } + + /** + * Get a resource containing a discussion's last post. + * + * @param Discussion $discussion + * @param array $relations + * @return Tobscure\JsonApi\Resource + */ + public function includeLastPost(Discussion $discussion, $relations) + { + return (new PostBasicSerializer($relations))->resource($discussion->lastPost); + } +} diff --git a/src/Flarum/Api/Serializers/GroupSerializer.php b/src/Flarum/Api/Serializers/GroupSerializer.php new file mode 100644 index 000000000..d90ebc29a --- /dev/null +++ b/src/Flarum/Api/Serializers/GroupSerializer.php @@ -0,0 +1,48 @@ + (int) $group->id, + 'name' => $group->name + ]; + + return $this->attributesEvent($group, $attributes); + } + + /** + * Get the URL templates where this resource and its related resources can + * be accessed. + * + * @return array + */ + public function href() + { + return [ + 'groups' => $this->action('GroupsController@show', ['id' => '{groups.id}']), + 'users' => $this->action('UsersController@indexForGroup', ['id' => '{groups.id}']) + ]; + } +} diff --git a/src/Flarum/Api/Serializers/PostBasicSerializer.php b/src/Flarum/Api/Serializers/PostBasicSerializer.php new file mode 100644 index 000000000..a76dc7ce6 --- /dev/null +++ b/src/Flarum/Api/Serializers/PostBasicSerializer.php @@ -0,0 +1,85 @@ + (int) $post->id, + 'number' => (int) $post->number, + 'time' => $post->time->toRFC3339String(), + 'type' => $post->type, + 'content' => str_limit($post->content, 200) + ]; + + return $this->attributesEvent($post, $attributes); + } + + /** + * Get the URL templates where this resource and its related resources can + * be accessed. + * + * @return array + */ + public function href() + { + return [ + 'posts' => $this->action('PostsController@show', ['id' => '{posts.id}']) + ]; + } + + /** + * Get a resource containing a post's user. + * + * @param Post $post + * @param array $relations + * @return Tobscure\JsonApi\Resource + */ + public function includeUser(Post $post, $relations) + { + return (new UserBasicSerializer($relations))->resource($post->user); + } + + /** + * Get a resource containing a post's discussion ID. + * + * @param Post $post + * @return Tobscure\JsonApi\Resource + */ + public function linkDiscussion(Post $post) + { + return (new DiscussionBasicSerializer)->resource($post->discussion_id); + } +} diff --git a/src/Flarum/Api/Serializers/PostSerializer.php b/src/Flarum/Api/Serializers/PostSerializer.php new file mode 100644 index 000000000..fa23f901c --- /dev/null +++ b/src/Flarum/Api/Serializers/PostSerializer.php @@ -0,0 +1,109 @@ +type != 'comment') { + $attributes['content'] = $post->content; + } else { + // @todo move to a formatter class + $attributes['contentHtml'] = $post->content_html ?: '

    '.nl2br(htmlspecialchars(trim($post->content))).'

    '; + } + + if ($post->edit_time) { + $attributes['editTime'] = (string) $post->edit_time; + } + + if ($post->delete_time) { + $attributes['deleteTime'] = (string) $post->delete_time; + } + + $user = User::current(); + + $attributes += [ + 'canEdit' => $post->can($user, 'edit'), + 'canDelete' => $post->can($user, 'delete') + ]; + + return $this->attributesEvent($post, $attributes); + } + + /** + * Get a resource containing a post's user. + * + * @param Post $post + * @param array $relations + * @return Tobscure\JsonApi\Resource + */ + public function includeUser(Post $post, $relations = []) + { + return (new UserSerializer($relations))->resource($post->user); + } + + /** + * Get a resource containing a post's discussion. + * + * @param Post $post + * @param array $relations + * @return Tobscure\JsonApi\Resource + */ + public function includeDiscussion(Post $post, $relations = []) + { + return (new DiscussionBasicSerializer($relations))->resource($post->discussion); + } + + /** + * Get a resource containing a post's edit user. + * + * @param Post $post + * @param array $relations + * @return Tobscure\JsonApi\Resource + */ + public function includeEditUser(Post $post, $relations = []) + { + return (new UserBasicSerializer($relations))->resource($post->editUser); + } + + /** + * Get a resource containing a post's delete user. + * + * @param Post $post + * @param array $relations + * @return Tobscure\JsonApi\Resource + */ + public function includeDeleteUser(Post $post, $relations = []) + { + return (new UserBasicSerializer($relations))->resource($post->deleteUser); + } +} diff --git a/src/Flarum/Api/Serializers/UserAdminSerializer.php b/src/Flarum/Api/Serializers/UserAdminSerializer.php new file mode 100644 index 000000000..27817a0e5 --- /dev/null +++ b/src/Flarum/Api/Serializers/UserAdminSerializer.php @@ -0,0 +1,21 @@ + $user->email, + ]; + + Event::fire('flarum.api.serialize.user.admin', [&$serialized]); + + return $serialized; + } + +} diff --git a/src/Flarum/Api/Serializers/UserBasicSerializer.php b/src/Flarum/Api/Serializers/UserBasicSerializer.php new file mode 100644 index 000000000..f9cf4c96c --- /dev/null +++ b/src/Flarum/Api/Serializers/UserBasicSerializer.php @@ -0,0 +1,50 @@ + (int) $user->id, + 'username' => $user->username, + 'avatarUrl' => $user->avatar_url + ]; + + return $this->attributesEvent($user, $attributes); + } + + /** + * Get the URL templates where this resource and its related resources can + * be accessed. + * + * @return array + */ + protected function href() + { + $href = [ + 'users' => $this->action('UsersController@show', ['id' => '{users.id}']) + ]; + + return $this->hrefEvent($href); + } +} diff --git a/src/Flarum/Api/Serializers/UserCurrentSerializer.php b/src/Flarum/Api/Serializers/UserCurrentSerializer.php new file mode 100644 index 000000000..a2e090062 --- /dev/null +++ b/src/Flarum/Api/Serializers/UserCurrentSerializer.php @@ -0,0 +1,29 @@ +id) + { + $serialized += [ + 'time_zone' => $user->time_zone, + 'time_zone_offset' => with(new DateTimeZone($user->time_zone))->getOffset(new DateTime('now')) + // other user preferences. probably mostly from external sources (e.g. flarum/web) + ]; + } + + Event::fire('flarum.api.serialize.user.current', [&$serialized]); + + return $serialized; + } + +} diff --git a/src/Flarum/Api/Serializers/UserSerializer.php b/src/Flarum/Api/Serializers/UserSerializer.php new file mode 100644 index 000000000..cacf518c4 --- /dev/null +++ b/src/Flarum/Api/Serializers/UserSerializer.php @@ -0,0 +1,52 @@ + $user->join_time ? $user->join_time->toRFC3339String() : '', + 'lastSeenTime' => $user->last_seen_time ? $user->last_seen_time->toRFC3339String() : '', + 'discussionsCount' => (int) $user->discussions_count, + 'postsCount' => (int) $user->posts_count, + 'canEdit' => $user->permission('edit'), + 'canDelete' => $user->permission('delete'), + ]; + + return $this->attributesEvent($user, $attributes); + } + + /** + * Get a collection containing a user's groups. + * + * @param User $user + * @param array $relations + * @return Tobscure\JsonApi\Collection + */ + protected function includeGroups(User $user, $relations) + { + return (new GroupSerializer($relations))->collection($user->groups); + } +} diff --git a/src/Flarum/Core/Activity/Activity.php b/src/Flarum/Core/Activity/Activity.php new file mode 100644 index 000000000..b194b2a74 --- /dev/null +++ b/src/Flarum/Core/Activity/Activity.php @@ -0,0 +1,36 @@ +belongsTo('Flarum\Core\Users\User', 'from_user_id'); + } + + public function permission($permission) + { + return User::current()->can($permission, 'activity', $this); + } + + public function editable() + { + return $this->permission('edit'); + } + + public function deletable() + { + return $this->permission('delete'); + } + +} diff --git a/src/Flarum/Core/CoreServiceProvider.php b/src/Flarum/Core/CoreServiceProvider.php new file mode 100644 index 000000000..a0c331937 --- /dev/null +++ b/src/Flarum/Core/CoreServiceProvider.php @@ -0,0 +1,98 @@ +package('flarum/core', 'flarum'); + + Config::set('database.connections.flarum', Config::get('flarum::database')); + + $this->app->make('validator')->extend('username', 'Flarum\Core\Users\UsernameValidator@validate'); + + Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\DiscussionMetadataUpdater'); + Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\UserMetadataUpdater'); + Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\PostFormatter'); + Event::listen('Flarum.Core.*', 'Flarum\Core\Listeners\TitleChangePostCreator'); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + // Start up the Laracasts Commander package. This is used as the basis + // for the Commands & Domain Events architecture used to structure + // Flarum's domain. + $this->app->register('Laracasts\Commander\CommanderServiceProvider'); + + // Register a singleton entity that represents this forum. This entity + // will be used to check for global forum permissions (like viewing the + // forum, registering, and starting discussions.) + $this->app->singleton('flarum.forum', 'Flarum\Core\Forum'); + + // Register the extensions manager object. This manages a list of + // available extensions, and provides functionality to enable/disable + // them. + $this->app->singleton('flarum.extensions', 'Flarum\Core\Support\Extensions\Manager'); + + // Register the permissions manager object. This reads the permissions + // from the permissions repository and can determine whether or not a + // user has explicitly been granted a certain permission. + $this->app->singleton('flarum.permissions', 'Flarum\Core\Permissions\Manager'); + + + + $this->app->bind('flarum.discussionFinder', 'Flarum\Core\Discussions\DiscussionFinder'); + + + // $this->app->singleton( + // 'Flarum\Core\Repositories\Contracts\DiscussionRepository', + // function($app) + // { + // $discussion = new \Flarum\Core\Repositories\EloquentDiscussionRepository; + // return new DiscussionCacheDecorator($discussion); + // } + // ); + // $this->app->singleton( + // 'Flarum\Core\Repositories\Contracts\UserRepository', + // 'Flarum\Core\Repositories\EloquentUserRepository' + // ); + // $this->app->singleton( + // 'Flarum\Core\Repositories\Contracts\PostRepository', + // 'Flarum\Core\Repositories\EloquentPostRepository' + // ); + // $this->app->singleton( + // 'Flarum\Core\Repositories\Contracts\GroupRepository', + // 'Flarum\Core\Repositories\EloquentGroupRepository' + // ); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return array(); + } +} diff --git a/src/Flarum/Core/Discussions/Commands/DeleteDiscussionCommand.php b/src/Flarum/Core/Discussions/Commands/DeleteDiscussionCommand.php new file mode 100644 index 000000000..a788a8b3d --- /dev/null +++ b/src/Flarum/Core/Discussions/Commands/DeleteDiscussionCommand.php @@ -0,0 +1,14 @@ +discussionId = $discussionId; + $this->user = $user; + } +} diff --git a/src/Flarum/Core/Discussions/Commands/DeleteDiscussionCommandHandler.php b/src/Flarum/Core/Discussions/Commands/DeleteDiscussionCommandHandler.php new file mode 100644 index 000000000..6008c703e --- /dev/null +++ b/src/Flarum/Core/Discussions/Commands/DeleteDiscussionCommandHandler.php @@ -0,0 +1,33 @@ +discussions = $discussions; + } + + public function handle($command) + { + $user = $command->user; + $discussion = $this->discussions->findOrFail($command->discussionId, $user); + + $discussion->assertCan($user, 'delete'); + + Event::fire('Flarum.Core.Discussions.Commands.DeleteDiscussion.DiscussionWillBeDeleted', [$discussion, $command]); + + $this->discussions->delete($discussion); + $this->dispatchEventsFor($discussion); + + return $discussion; + } +} diff --git a/src/Flarum/Core/Discussions/Commands/DeleteDiscussionValidator.php b/src/Flarum/Core/Discussions/Commands/DeleteDiscussionValidator.php new file mode 100644 index 000000000..3f0ccd0fc --- /dev/null +++ b/src/Flarum/Core/Discussions/Commands/DeleteDiscussionValidator.php @@ -0,0 +1,7 @@ +discussionId = $discussionId; + $this->user = $user; + } +} diff --git a/src/Flarum/Core/Discussions/Commands/EditDiscussionCommandHandler.php b/src/Flarum/Core/Discussions/Commands/EditDiscussionCommandHandler.php new file mode 100644 index 000000000..cb92553ee --- /dev/null +++ b/src/Flarum/Core/Discussions/Commands/EditDiscussionCommandHandler.php @@ -0,0 +1,38 @@ +discussions = $discussions; + } + + public function handle($command) + { + $user = $command->user; + $discussion = $this->discussions->findOrFail($command->discussionId, $user); + + $discussion->assertCan($user, 'edit'); + + if (isset($command->title)) { + $discussion->rename($command->title, $user); + } + + Event::fire('Flarum.Core.Discussions.Commands.EditDiscussion.DiscussionWillBeSaved', [$discussion, $command]); + + $this->discussions->save($discussion); + $this->dispatchEventsFor($discussion); + + return $discussion; + } +} diff --git a/src/Flarum/Core/Discussions/Commands/EditDiscussionValidator.php b/src/Flarum/Core/Discussions/Commands/EditDiscussionValidator.php new file mode 100644 index 000000000..8fe7741a9 --- /dev/null +++ b/src/Flarum/Core/Discussions/Commands/EditDiscussionValidator.php @@ -0,0 +1,7 @@ +discussionId = $discussionId; + $this->user = $user; + $this->readNumber = $readNumber; + } +} diff --git a/src/Flarum/Core/Discussions/Commands/ReadDiscussionCommandHandler.php b/src/Flarum/Core/Discussions/Commands/ReadDiscussionCommandHandler.php new file mode 100644 index 000000000..363cfe219 --- /dev/null +++ b/src/Flarum/Core/Discussions/Commands/ReadDiscussionCommandHandler.php @@ -0,0 +1,35 @@ +discussions = $discussions; + } + + public function handle($command) + { + $user = $command->user; + $discussion = $this->discussions->findOrFail($command->discussionId, $user); + + $discussion->state = $this->discussions->getState($discussion, $user); + $discussion->state->read($command->readNumber); + + Event::fire('Flarum.Core.Discussions.Commands.ReadDiscussion.StateWillBeSaved', [$discussion, $command]); + + $this->discussions->saveState($discussion->state); + $this->dispatchEventsFor($discussion->state); + + return $discussion; + } +} diff --git a/src/Flarum/Core/Discussions/Commands/ReadDiscussionValidator.php b/src/Flarum/Core/Discussions/Commands/ReadDiscussionValidator.php new file mode 100644 index 000000000..46258ce3f --- /dev/null +++ b/src/Flarum/Core/Discussions/Commands/ReadDiscussionValidator.php @@ -0,0 +1,19 @@ +user->exists) { + throw new PermissionDeniedException; + } + + parent::validate($command); + } +} diff --git a/src/Flarum/Core/Discussions/Commands/StartDiscussionCommand.php b/src/Flarum/Core/Discussions/Commands/StartDiscussionCommand.php new file mode 100644 index 000000000..5fbfa69b0 --- /dev/null +++ b/src/Flarum/Core/Discussions/Commands/StartDiscussionCommand.php @@ -0,0 +1,17 @@ +title = $title; + $this->content = $content; + $this->user = $user; + } +} diff --git a/src/Flarum/Core/Discussions/Commands/StartDiscussionCommandHandler.php b/src/Flarum/Core/Discussions/Commands/StartDiscussionCommandHandler.php new file mode 100644 index 000000000..4cd017688 --- /dev/null +++ b/src/Flarum/Core/Discussions/Commands/StartDiscussionCommandHandler.php @@ -0,0 +1,59 @@ +forum = $forum; + $this->discussionRepo = $discussionRepo; + $this->commandBus = $commandBus; + } + + public function handle($command) + { + $this->forum->assertCan($command->user, 'startDiscussion'); + + // Create a new Discussion entity, persist it, and dispatch domain + // events. Before persistance, though, fire an event to give plugins + // an opportunity to alter the discussion entity based on data in the + // command they may have passed through in the controller. + $discussion = Discussion::start( + $command->title, + $command->user + ); + + Event::fire('Flarum.Core.Discussions.Commands.StartDiscussion.DiscussionWillBeSaved', [$discussion, $command]); + + $this->discussionRepo->save($discussion); + + // Now that the discussion has been created, we can add the first post. + // For now we will do this by running the PostReply command, but as this + // will trigger a domain event that is slightly semantically incorrect + // in this situation (ReplyWasPosted), we may need to reconsider someday. + $this->commandBus->execute( + new PostReplyCommand($discussion->id, $command->content, $command->user) + ); + + $this->dispatchEventsFor($discussion); + + return $discussion; + } +} diff --git a/src/Flarum/Core/Discussions/Commands/StartDiscussionValidator.php b/src/Flarum/Core/Discussions/Commands/StartDiscussionValidator.php new file mode 100644 index 000000000..a00c68ae6 --- /dev/null +++ b/src/Flarum/Core/Discussions/Commands/StartDiscussionValidator.php @@ -0,0 +1,7 @@ + 'required', + 'start_time' => 'required|date', + 'posts_count' => 'integer', + 'start_user_id' => 'integer', + 'start_post_id' => 'integer', + 'last_time' => 'date', + 'last_user_id' => 'integer', + 'last_post_id' => 'integer', + 'last_post_number' => 'integer' + ]; + + public static function boot() + { + parent::boot(); + + static::grant(function ($grant, $user, $permission) { + return app('flarum.permissions')->granted($user, $permission, 'discussion'); + }); + + // Grant view access to a discussion if the user can view the forum. + static::grant('view', function ($grant, $user) { + return app('flarum.forum')->can($user, 'view'); + }); + + // Allow a user to edit their own discussion. + static::grant('edit', function ($grant, $user) { + if (app('flarum.permissions')->granted($user, 'editOwn', 'discussion')) { + $grant->where('user_id', $user->id); + } + }); + + static::deleted(function ($discussion) { + $discussion->raise(new Events\DiscussionWasDeleted($discussion)); + + $discussion->posts()->delete(); + $discussion->readers()->detach(); + }); + } + + public static function start($title, $user) + { + $discussion = new static; + + $discussion->title = $title; + $discussion->start_time = time(); + $discussion->start_user_id = $user->id; + + $discussion->raise(new Events\DiscussionWasStarted($discussion)); + + return $discussion; + } + + public function setLastPost($post) + { + $this->last_time = $post->time; + $this->last_user_id = $post->user_id; + $this->last_post_id = $post->id; + $this->last_post_number = $post->number; + } + + public function refreshLastPost() + { + $lastPost = $this->dialog()->orderBy('time', 'desc')->first(); + $this->setLastPost($lastPost); + } + + public function refreshPostsCount() + { + $this->posts_count = $this->dialog()->count(); + } + + public function rename($title, $user) + { + if ($this->title === $title) { + return; + } + + $this->title = $title; + + $this->raise(new Events\DiscussionWasRenamed($this, $user)); + } + + public function getDates() + { + return ['start_time', 'last_time']; + } + + public function posts() + { + return $this->hasMany('Flarum\Core\Posts\Post')->orderBy('time', 'asc'); + } + + public function dialog() + { + return $this->posts()->where('type', 'comment')->whereNull('delete_time'); + } + + public function startPost() + { + return $this->belongsTo('Flarum\Core\Posts\Post', 'start_post_id'); + } + + public function startUser() + { + return $this->belongsTo('Flarum\Core\Users\User', 'start_user_id'); + } + + public function lastPost() + { + return $this->belongsTo('Flarum\Core\Posts\Post', 'last_post_id'); + } + + public function lastUser() + { + return $this->belongsTo('Flarum\Core\Users\User', 'last_user_id'); + } + + public function readers() + { + return $this->belongsToMany('Flarum\Core\Users\User', 'users_discussions'); + } + + public function state($userId = null) + { + if (is_null($userId)) { + $userId = User::current()->id; + } + return $this->hasOne('Flarum\Core\Discussions\DiscussionState')->where('user_id', $userId); + } + + public function stateFor($user) + { + $state = $this->state($user->id)->first(); + + if (! $state) { + $state = new DiscussionState; + $state->discussion_id = $this->id; + $state->user_id = $user->id; + } + + return $state; + } + + public function scopePermission($query, $permission, $user = null) + { + if (is_null($user)) { + $user = User::current(); + } + return $this->scopeWhereCan($query, $user, $permission); + } + + public function scopeWhereCanView($query, $user = null) + { + return $this->scopePermission($query, 'view', $user); + } + + public function permission($permission, $user = null) + { + if (is_null($user)) { + $user = User::current(); + } + return $this->can($user, $permission); + } + + public function assertCan($user, $permission) + { + if (! $this->can($user, $permission)) { + throw new PermissionDeniedException; + } + } +} diff --git a/src/Flarum/Core/Discussions/DiscussionFinder.php b/src/Flarum/Core/Discussions/DiscussionFinder.php new file mode 100644 index 000000000..0efabbf54 --- /dev/null +++ b/src/Flarum/Core/Discussions/DiscussionFinder.php @@ -0,0 +1,258 @@ + ['last_time', 'desc'], + 'replies' => ['posts_count', 'desc'], + 'created' => ['start_time', 'desc'] + ]; + + protected $order; + + protected $key; + + protected $count; + + protected $areMoreResults; + + protected $fulltext; + + public function __construct($user = null, $tokens = null, $sort = null, $order = null, $key = null) + { + $this->user = $user; + $this->tokens = $tokens; + $this->sort = $sort; + $this->order = $order; + $this->key = $key; + } + + public function getUser() + { + return $this->user; + } + + public function setUser($user) + { + $this->user = $user; + } + + public function getTokens() + { + return $this->tokens; + } + + public function setTokens($tokens) + { + $this->tokens = $tokens; + } + + public function setQuery($query) + { + $tokenizer = new Tokenizer($query); + $this->setTokens($tokenizer->tokenize()); + } + + public function getSort() + { + return $this->sort; + } + + public function setSort($sort) + { + $this->sort = $sort; + } + + public function getOrder() + { + return $this->order; + } + + public function setOrder($order) + { + $this->order = $order; + } + + public function getKey() + { + return $this->key; + } + + public function setKey($key) + { + $this->key = $key; + } + + protected function getCacheKey() + { + return 'discussions.'.$this->key; + } + + public function getCount() + { + return $this->count; + } + + public function areMoreResults() + { + return $this->areMoreResults; + } + + public function fulltext() + { + return $this->fulltext; + } + + public function results($count = null, $start = 0, $load = []) + { + $relevantPosts = false; + + if (in_array('relevantPosts', $load)) { + $load = array_diff($load, ['relevantPosts', 'relevantPosts.user']); + $relevantPosts = true; + } + + $ids = null; + $query = Discussion::whereCan($this->user, 'view'); + $query->with($load); + + if ($this->key and Cache::has($key = $this->getCacheKey())) { + $ids = Cache::get($key); + } elseif (count($this->tokens)) { + // foreach ($tokens as $type => $value) + // { + // switch ($type) + // { + // case 'flag:draft': + // case 'flag:muted': + // case 'flag:subscribed': + // case 'flag:private': + // // pre-process + // $ids = $this->discussions->getDraftIdsForUser(Auth::user()); + // $ids = $this->discussions->getMutedIdsForUser(Auth::user()); + // $ids = $this->discussions->getSubscribedIdsForUser(Auth::user()); + // $ids = $this->discussions->getPrivateIdsForUser(Auth::user()); + // // $user->permissions['discussion']['view'] = [1,2,3] + // break; + // } + // } + + // $search = $this->search->create(); + // $search->limitToIds($ids); + // $search->setQuery($query); + // $search->setSort($sort); + // $search->setSortOrder($sortOrder); + // $results = $search->results(); + + // process flag:unread here? + + // parse the tokens. + // run ID filters. + + // TESTING lol + $this->fulltext = reset($this->tokens); + $posts = Post::whereRaw('MATCH (`content`) AGAINST (? IN BOOLEAN MODE)', [$this->fulltext]) + ->orderByRaw('MATCH (`content`) AGAINST (?) DESC', [$this->fulltext]); + + $posts = $posts->select('id', 'discussion_id'); + + $posts = $posts->get(); + + $ids = []; + foreach ($posts as $post) { + if (empty($ids[$post->discussion_id])) { + $ids[$post->discussion_id] = []; + } + $ids[$post->discussion_id][] = $post->id; + } + + if ($this->fulltext and ! $this->sort) { + $this->sort = 'relevance'; + } + + if (! is_null($ids)) { + $this->key = str_random(); + } + + // run other tokens + // $discussions->where(''); + } + + if (! is_null($ids)) { + Cache::put($this->getCacheKey(), $ids, 10); // recache + $this->count = count($ids); + + if (! $ids) { + return []; + } + $query->whereIn('id', array_keys($ids)); + + // If we're sorting by relevance, assume that the IDs we've been provided + // are already sorted by relevance. Therefore, we'll get discussions in + // the order that they are in. + if ($this->sort == 'relevance') { + foreach ($ids as $id) { + $query->orderBy(DB::raw('id != '.(int) $id)); + } + } + } + + if (empty($this->sort)) { + reset($this->sortMap); + $this->sort = key($this->sortMap); + } + if (! empty($this->sortMap[$this->sort])) { + list($column, $order) = $this->sortMap[$this->sort]; + $query->orderBy($column, $this->order ?: $order); + } + + if ($start > 0) { + $query->skip($start); + } + if ($count > 0) { + $query->take($count + 1); + $results = $query->get(); + $this->areMoreResults = $results->count() > $count; + if ($this->areMoreResults) { + $results->pop(); + } + } else { + $results = $query->get(); + } + + if (!empty($relevantPosts)) { + $postIds = []; + foreach ($ids as $id => &$posts) { + $postIds = array_merge($postIds, array_slice($posts, 0, 2)); + } + $posts = Post::with('user')->whereCan($this->user, 'view')->whereIn('id', $postIds)->get(); + + foreach ($results as $discussion) { + $discussion->relevantPosts = $posts->filter(function ($post) use ($discussion) { + return $post->discussion_id == $discussion->id; + }) + ->slice(0, 2) + ->each(function ($post) { + $pos = strpos(strtolower($post->content), strtolower($this->fulltext)); + // TODO: make clipping more intelligent (full words only) + $start = max(0, $pos - 50); + $post->content = ($start > 0 ? '...' : '').str_limit(substr($post->content, $start), 300); + }); + } + } + + return $results; + } +} diff --git a/src/Flarum/Core/Discussions/DiscussionRepository.php b/src/Flarum/Core/Discussions/DiscussionRepository.php new file mode 100755 index 000000000..b73f512f2 --- /dev/null +++ b/src/Flarum/Core/Discussions/DiscussionRepository.php @@ -0,0 +1,43 @@ +whereCanView($user); + } + + return $query->findOrFail($id); + } + + public function save(Discussion $discussion) + { + $discussion->assertValid(); + $discussion->save(); + } + + public function delete(Discussion $discussion) + { + $discussion->delete(); + } + + public function getState(Discussion $discussion, User $user) + { + return $discussion->stateFor($user); + } + + public function saveState(DiscussionState $state) + { + $state->save(); + } +} diff --git a/src/Flarum/Core/Discussions/DiscussionState.php b/src/Flarum/Core/Discussions/DiscussionState.php new file mode 100644 index 000000000..8bbae7e6a --- /dev/null +++ b/src/Flarum/Core/Discussions/DiscussionState.php @@ -0,0 +1,49 @@ +belongsTo('Flarum\Core\Discussions\Discussion', 'discussion_id'); + } + + public function user() + { + return $this->belongsTo('Flarum\Core\Users\User', 'user_id'); + } + + public function read($number) + { + $this->read_number = $number; // only if it's greater than the old one + $this->read_time = time(); + + $this->raise(new Events\DiscussionWasRead($this)); + } + + /** + * Set the keys for a save update query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function setKeysForSaveQuery(\Illuminate\Database\Eloquent\Builder $query) + { + $query->where('discussion_id', $this->discussion_id) + ->where('user_id', $this->user_id); + + return $query; + } +} diff --git a/src/Flarum/Core/Discussions/Events/DiscussionWasDeleted.php b/src/Flarum/Core/Discussions/Events/DiscussionWasDeleted.php new file mode 100644 index 000000000..8efa6235b --- /dev/null +++ b/src/Flarum/Core/Discussions/Events/DiscussionWasDeleted.php @@ -0,0 +1,13 @@ +discussion = $discussion; + } +} diff --git a/src/Flarum/Core/Discussions/Events/DiscussionWasRead.php b/src/Flarum/Core/Discussions/Events/DiscussionWasRead.php new file mode 100644 index 000000000..4fcefa9d4 --- /dev/null +++ b/src/Flarum/Core/Discussions/Events/DiscussionWasRead.php @@ -0,0 +1,13 @@ +state = $state; + } +} diff --git a/src/Flarum/Core/Discussions/Events/DiscussionWasRenamed.php b/src/Flarum/Core/Discussions/Events/DiscussionWasRenamed.php new file mode 100644 index 000000000..3409c4848 --- /dev/null +++ b/src/Flarum/Core/Discussions/Events/DiscussionWasRenamed.php @@ -0,0 +1,17 @@ +discussion = $discussion; + $this->user = $user; + } +} diff --git a/src/Flarum/Core/Discussions/Events/DiscussionWasStarted.php b/src/Flarum/Core/Discussions/Events/DiscussionWasStarted.php new file mode 100644 index 000000000..5e341c775 --- /dev/null +++ b/src/Flarum/Core/Discussions/Events/DiscussionWasStarted.php @@ -0,0 +1,13 @@ +discussion = $discussion; + } +} diff --git a/src/Flarum/Core/Entity.php b/src/Flarum/Core/Entity.php new file mode 100755 index 000000000..8f5bb0b43 --- /dev/null +++ b/src/Flarum/Core/Entity.php @@ -0,0 +1,86 @@ +validator = $validator ?: \App::make('validator'); + } + + public function getConnection() + { + return static::resolveConnection('flarum'); + } + + public function valid() + { + return $this->getValidator()->passes(); + } + + public function assertValid() + { + $validation = $this->getValidator(); + + if ($validation->fails()) { + $this->throwValidationException($validation->errors(), $validation->getData()); + } + } + + protected function getValidator() + { + $rules = $this->expandUniqueRules(static::$rules); + + return $this->validator->make($this->attributes, $rules, static::$messages); + } + + protected function expandUniqueRules($rules) + { + foreach ($rules as $column => &$ruleset) { + if (is_string($ruleset)) { + $ruleset = explode('|', $ruleset); + } + foreach ($ruleset as &$rule) { + if (strpos($rule, 'unique') === 0) { + $parts = explode(':', $rule); + $key = $this->getKey() ?: 'NULL'; + $rule = 'unique:'.$this->getTable().','.$column.','.$key.','.$this->getKeyName(); + if (! empty($parts[1])) { + $wheres = explode(',', $parts[1]); + foreach ($wheres as &$where) { + $where .= ','.$this->$where; + } + $rule .= ','.implode(',', $wheres); + } + } + } + } + + return $rules; + } + + protected function throwValidationException($errors, $input) + { + $exception = new ValidationFailureException; + $exception->setErrors($errors)->setInput($input); + throw $exception; + } +} diff --git a/src/Flarum/Core/Forum.php b/src/Flarum/Core/Forum.php new file mode 100755 index 000000000..2270e254b --- /dev/null +++ b/src/Flarum/Core/Forum.php @@ -0,0 +1,26 @@ +granted($user, $permission, 'forum'); + }); + } + + public function assertCan($user, $permission) + { + if (! $this->can($user, $permission)) { + throw new PermissionDeniedException; + } + } +} diff --git a/src/Flarum/Core/Groups/Group.php b/src/Flarum/Core/Groups/Group.php new file mode 100755 index 000000000..8f0f50ae6 --- /dev/null +++ b/src/Flarum/Core/Groups/Group.php @@ -0,0 +1,18 @@ +belongsToMany('Flarum\Core\Users\User', 'users_groups'); + } + +} diff --git a/src/Flarum/Core/Groups/GroupRepository.php b/src/Flarum/Core/Groups/GroupRepository.php new file mode 100755 index 000000000..b5e4c2969 --- /dev/null +++ b/src/Flarum/Core/Groups/GroupRepository.php @@ -0,0 +1,15 @@ +save(); + } + + public function delete(Group $group) + { + $group->delete(); + } +} diff --git a/src/Flarum/Core/Listeners/DiscussionMetadataUpdater.php b/src/Flarum/Core/Listeners/DiscussionMetadataUpdater.php new file mode 100755 index 000000000..de178ed6e --- /dev/null +++ b/src/Flarum/Core/Listeners/DiscussionMetadataUpdater.php @@ -0,0 +1,63 @@ +discussionRepo = $discussionRepo; + } + + public function whenReplyWasPosted(ReplyWasPosted $event) + { + $discussion = $this->discussionRepo->find($event->post->discussion_id); + + $discussion->replies_count++; + $discussion->setLastPost($event->post); + + $this->discussionRepo->save($discussion); + } + + public function whenPostWasDeleted(PostWasDeleted $event) + { + $this->removePost($event->post); + } + + public function whenPostWasHidden(PostWasHidden $event) + { + $this->removePost($event->post); + } + + public function whenPostWasRestored(PostWasRestored $event) + { + $discussion = $this->discussionRepo->find($event->post->discussion_id); + + $discussion->replies_count++; + $discussion->refreshLastPost(); + + $this->discussionRepo->save($discussion); + } + + protected function removePost(Post $post) + { + $discussion = $this->discussionRepo->find($post->discussion_id); + + $discussion->replies_count--; + + if ($discussion->last_post_id == $post->id) { + $discussion->refreshLastPost(); + } + + $this->discussionRepo->save($discussion); + } +} diff --git a/src/Flarum/Core/Listeners/PostFormatter.php b/src/Flarum/Core/Listeners/PostFormatter.php new file mode 100755 index 000000000..c2efd358a --- /dev/null +++ b/src/Flarum/Core/Listeners/PostFormatter.php @@ -0,0 +1,48 @@ +postRepo = $postRepo; + } + + protected function formatPost($post) + { + $post = $this->postRepo->find($post->id); + + // By default, we want to convert paragraphs of text into

    tags. + // And maybe also wrap URLs in tags. + // However, we want to allow plugins to completely override this, and/or + // just do some superficial formatting afterwards. + + $html = htmlspecialchars($post->content); + + // Primary formatter + $html = '

    '.$html.'

    '; // Move this to Flarum\Core\Support\Formatters\BasicFormatter < FormatterInterface + + // Run additional formatters + + $post->content_html = $html; + $this->postRepo->save($post); + } + + public function whenReplyWasPosted(ReplyWasPosted $event) + { + $this->formatPost($event->post); + } + + public function whenPostWasRevised(PostWasRevised $event) + { + $this->formatPost($event->post); + } +} diff --git a/src/Flarum/Core/Listeners/TitleChangePostCreator.php b/src/Flarum/Core/Listeners/TitleChangePostCreator.php new file mode 100755 index 000000000..18a2c1b19 --- /dev/null +++ b/src/Flarum/Core/Listeners/TitleChangePostCreator.php @@ -0,0 +1,28 @@ +postRepo = $postRepo; + } + + public function whenDiscussionWasRenamed(DiscussionWasRenamed $event) + { + $post = TitleChangePost::reply( + $event->discussion->id, + $event->discussion->title, + $event->user->id + ); + + $this->postRepo->save($post); + } +} diff --git a/src/Flarum/Core/Listeners/UserMetadataUpdater.php b/src/Flarum/Core/Listeners/UserMetadataUpdater.php new file mode 100755 index 000000000..ee54c2916 --- /dev/null +++ b/src/Flarum/Core/Listeners/UserMetadataUpdater.php @@ -0,0 +1,70 @@ +userRepo = $userRepo; + } + + protected function updateRepliesCount($userId, $amount) + { + $user = $this->userRepo->find($userId); + + $user->posts_count += $amount; + + $this->userRepo->save($user); + } + + protected function updateDiscussionsCount($userId, $amount) + { + $user = $this->userRepo->find($userId); + + $user->discussions_count += $amount; + + $this->userRepo->save($user); + } + + public function whenReplyWasPosted(ReplyWasPosted $event) + { + $this->updateRepliesCount($event->post->user_id, 1); + } + + public function whenPostWasDeleted(PostWasDeleted $event) + { + $this->updateRepliesCount($event->post->user_id, -1); + } + + public function whenPostWasHidden(PostWasHidden $event) + { + $this->updateRepliesCount($event->post->user_id, -1); + } + + public function whenPostWasRestored(PostWasRestored $event) + { + $this->updateRepliesCount($event->post->user_id, 1); + } + + public function whenDiscussionWasStarted(DiscussionWasStarted $event) + { + $this->updateDiscussionsCount($event->discussion->start_user_id, 1); + } + + public function whenDiscussionWasDeleted(DiscussionWasDeleted $event) + { + $this->updateDiscussionsCount($event->discussion->start_user_id, -1); + } +} diff --git a/src/Flarum/Core/Permissions/Manager.php b/src/Flarum/Core/Permissions/Manager.php new file mode 100755 index 000000000..ce09184da --- /dev/null +++ b/src/Flarum/Core/Permissions/Manager.php @@ -0,0 +1,42 @@ +permissions = $permissions; + } + + public function getMap() + { + if (is_null($this->map)) { + $permissions = $this->permissions->get(); + foreach ($permissions as $permission) { + $this->map[$permission->entity.'.'.$permission->permission][] = $permission->grantee; + } + } + + return $this->map; + } + + public function granted($user, $permission, $entity) + { + $grantees = $user->getGrantees(); + + // If user has admin, then yes! + if (in_array('group.1', $grantees)) { + return true; + } + + $permission = $entity.'.'.$permission; + + $map = $this->getMap(); + $mappedGrantees = isset($map[$permission]) ? $map[$permission] : []; + + return (bool) array_intersect($grantees, $mappedGrantees); + } +} diff --git a/src/Flarum/Core/Permissions/Permission.php b/src/Flarum/Core/Permissions/Permission.php new file mode 100644 index 000000000..2c2e3b4b9 --- /dev/null +++ b/src/Flarum/Core/Permissions/Permission.php @@ -0,0 +1,7 @@ +assertValid(); + $permission->save(); + } + + public function delete(Permission $permission) + { + $permission->delete(); + } +} diff --git a/src/Flarum/Core/Posts/Commands/DeletePostCommand.php b/src/Flarum/Core/Posts/Commands/DeletePostCommand.php new file mode 100644 index 000000000..16f3d854b --- /dev/null +++ b/src/Flarum/Core/Posts/Commands/DeletePostCommand.php @@ -0,0 +1,14 @@ +postId = $postId; + $this->user = $user; + } +} diff --git a/src/Flarum/Core/Posts/Commands/DeletePostCommandHandler.php b/src/Flarum/Core/Posts/Commands/DeletePostCommandHandler.php new file mode 100644 index 000000000..0493f86ac --- /dev/null +++ b/src/Flarum/Core/Posts/Commands/DeletePostCommandHandler.php @@ -0,0 +1,33 @@ +posts = $posts; + } + + public function handle($command) + { + $user = $command->user; + $post = $this->posts->findOrFail($command->postId, $user); + + $post->assertCan($user, 'delete'); + + Event::fire('Flarum.Core.Posts.Commands.DeletePost.PostWillBeDeleted', [$post, $command]); + + $this->posts->delete($post); + $this->dispatchEventsFor($post); + + return $post; + } +} diff --git a/src/Flarum/Core/Posts/Commands/DeletePostValidator.php b/src/Flarum/Core/Posts/Commands/DeletePostValidator.php new file mode 100644 index 000000000..c3e9aa4c5 --- /dev/null +++ b/src/Flarum/Core/Posts/Commands/DeletePostValidator.php @@ -0,0 +1,7 @@ +postId = $postId; + $this->user = $user; + } +} diff --git a/src/Flarum/Core/Posts/Commands/EditPostCommandHandler.php b/src/Flarum/Core/Posts/Commands/EditPostCommandHandler.php new file mode 100644 index 000000000..75817a4f9 --- /dev/null +++ b/src/Flarum/Core/Posts/Commands/EditPostCommandHandler.php @@ -0,0 +1,44 @@ +posts = $posts; + } + + public function handle($command) + { + $user = $command->user; + $post = $this->posts->findOrFail($command->postId, $user); + + $post->assertCan($user, 'edit'); + + if (isset($command->content)) { + $post->revise($command->content, $user); + } + + if ($command->hidden === true) { + $post->hide($user); + } elseif ($command->hidden === false) { + $post->restore($user); + } + + Event::fire('Flarum.Core.Posts.Commands.EditPost.PostWillBeSaved', [$post, $command]); + + $this->posts->save($post); + $this->dispatchEventsFor($post); + + return $post; + } +} diff --git a/src/Flarum/Core/Posts/Commands/EditPostValidator.php b/src/Flarum/Core/Posts/Commands/EditPostValidator.php new file mode 100644 index 000000000..63a5608a3 --- /dev/null +++ b/src/Flarum/Core/Posts/Commands/EditPostValidator.php @@ -0,0 +1,7 @@ +discussionId = $discussionId; + $this->content = $content; + $this->user = $user; + } +} diff --git a/src/Flarum/Core/Posts/Commands/PostReplyCommandHandler.php b/src/Flarum/Core/Posts/Commands/PostReplyCommandHandler.php new file mode 100644 index 000000000..bedcb5eee --- /dev/null +++ b/src/Flarum/Core/Posts/Commands/PostReplyCommandHandler.php @@ -0,0 +1,53 @@ +discussions = $discussions; + $this->posts = $posts; + } + + public function handle($command) + { + $user = $command->user; + + // Make sure the user has permission to reply to this discussion. First, + // make sure the discussion exists and that the user has permission to + // view it; if not, fail with a ModelNotFound exception so we don't give + // away the existence of the discussion. If the user is allowed to view + // it, check if they have permission to reply. + $discussion = $this->discussions->findOrFail($command->discussionId, $user); + + $discussion->assertCan($user, 'reply'); + + // Create a new Post entity, persist it, and dispatch domain events. + // Before persistance, though, fire an event to give plugins an + // opportunity to alter the post entity based on data in the command. + $post = CommentPost::reply( + $command->discussionId, + $command->content, + $user->id + ); + + Event::fire('Flarum.Core.Posts.Commands.PostReply.PostWillBeSaved', [$post, $command]); + + $this->posts->save($post); + $this->dispatchEventsFor($post); + + return $post; + } +} diff --git a/src/Flarum/Core/Posts/Commands/PostReplyValidator.php b/src/Flarum/Core/Posts/Commands/PostReplyValidator.php new file mode 100644 index 000000000..e54377e2f --- /dev/null +++ b/src/Flarum/Core/Posts/Commands/PostReplyValidator.php @@ -0,0 +1,7 @@ +number = $post->discussion->number_index++; + $post->discussion->save(); + }); + } + + public static function reply($discussionId, $content, $userId) + { + $post = new static; + + $post->content = $content; + $post->time = time(); + $post->discussion_id = $discussionId; + $post->user_id = $userId; + $post->type = 'comment'; + + $post->raise(new Events\ReplyWasPosted($post)); + + return $post; + } + + public function revise($content, $user) + { + $this->content = $content; + + $this->edit_time = time(); + $this->edit_user_id = $user->id; + + $this->raise(new Events\PostWasRevised($this)); + } + + public function hide($user) + { + $this->delete_time = time(); + $this->delete_user_id = $user->id; + + $this->raise(new Events\PostWasHidden($this)); + } + + public function restore($user) + { + $this->delete_time = null; + $this->delete_user_id = null; + + $this->raise(new Events\PostWasRestored($this)); + } +} diff --git a/src/Flarum/Core/Posts/Events/PostWasDeleted.php b/src/Flarum/Core/Posts/Events/PostWasDeleted.php new file mode 100644 index 000000000..b57e680f1 --- /dev/null +++ b/src/Flarum/Core/Posts/Events/PostWasDeleted.php @@ -0,0 +1,13 @@ +post = $post; + } +} diff --git a/src/Flarum/Core/Posts/Events/PostWasHidden.php b/src/Flarum/Core/Posts/Events/PostWasHidden.php new file mode 100644 index 000000000..2fc8daa58 --- /dev/null +++ b/src/Flarum/Core/Posts/Events/PostWasHidden.php @@ -0,0 +1,13 @@ +post = $post; + } +} diff --git a/src/Flarum/Core/Posts/Events/PostWasRestored.php b/src/Flarum/Core/Posts/Events/PostWasRestored.php new file mode 100644 index 000000000..0b65f02bd --- /dev/null +++ b/src/Flarum/Core/Posts/Events/PostWasRestored.php @@ -0,0 +1,13 @@ +post = $post; + } +} diff --git a/src/Flarum/Core/Posts/Events/PostWasRevised.php b/src/Flarum/Core/Posts/Events/PostWasRevised.php new file mode 100644 index 000000000..8c9b019af --- /dev/null +++ b/src/Flarum/Core/Posts/Events/PostWasRevised.php @@ -0,0 +1,13 @@ +post = $post; + } +} diff --git a/src/Flarum/Core/Posts/Events/ReplyWasPosted.php b/src/Flarum/Core/Posts/Events/ReplyWasPosted.php new file mode 100644 index 000000000..9b9a9f66a --- /dev/null +++ b/src/Flarum/Core/Posts/Events/ReplyWasPosted.php @@ -0,0 +1,13 @@ +post = $post; + } +} diff --git a/src/Flarum/Core/Posts/Post.php b/src/Flarum/Core/Posts/Post.php new file mode 100755 index 000000000..7c82c9398 --- /dev/null +++ b/src/Flarum/Core/Posts/Post.php @@ -0,0 +1,113 @@ + 'required|integer', + 'time' => 'required|date', + 'content' => 'required', + 'number' => 'integer', + 'user_id' => 'integer', + 'edit_time' => 'date', + 'edit_user_id' => 'integer', + 'delete_time' => 'date', + 'delete_user_id' => 'integer', + ]; + + public static function boot() + { + parent::boot(); + + static::grant(function ($grant, $user, $permission) { + return app('flarum.permissions')->granted($user, $permission, 'post'); + }); + + // Grant view access to a post only if the user can also view the + // discussion which the post is in. Also, the if the post is hidden, + // the user must have edit permissions too. + static::grant('view', function ($grant, $user) { + $grant->whereCan('view', 'discussion'); + }); + + static::check('view', function ($check, $user) { + $check->whereNull('delete_user_id') + ->orWhereCan('edit'); + }); + + // Allow a user to edit their own post, unless it has been hidden by + // someone else. + static::grant('edit', function ($grant, $user) { + $grant->whereCan('editOwn') + ->where('user_id', $user->id); + }); + + static::check('editOwn', function ($check, $user) { + $check->whereNull('delete_user_id') + ->orWhere('delete_user_id', $user->id); + }); + + static::deleted(function ($post) { + $post->raise(new Events\PostWasDeleted($post)); + }); + } + + public function discussion() + { + return $this->belongsTo('Flarum\Core\Discussions\Discussion', 'discussion_id'); + } + + public function user() + { + return $this->belongsTo('Flarum\Core\Users\User', 'user_id'); + } + + public function editUser() + { + return $this->belongsTo('Flarum\Core\Users\User', 'edit_user_id'); + } + + public function deleteUser() + { + return $this->belongsTo('Flarum\Core\Users\User', 'delete_user_id'); + } + + public function getDates() + { + return ['time', 'edit_time', 'delete_time']; + } + + // Terminates the query and returns an array of matching IDs. + // Example usage: $discussion->posts()->ids(); + public function scopeIds($query) + { + return array_map('intval', $query->get(['id'])->fetch('id')->all()); + } + + public function scopeWhereCanView($query, $user = null) + { + if (is_null($user)) { + $user = User::current(); + } + return $this->scopeWhereCan($query, $user, 'view'); + } + + public function assertCan($user, $permission) + { + if (! $this->can($user, $permission)) { + throw new PermissionDeniedException; + } + } +} diff --git a/src/Flarum/Core/Posts/PostRepository.php b/src/Flarum/Core/Posts/PostRepository.php new file mode 100755 index 000000000..4fa42333a --- /dev/null +++ b/src/Flarum/Core/Posts/PostRepository.php @@ -0,0 +1,31 @@ +whereCanView($user); + } + + return $query->findOrFail($id); + } + + public function save(Post $post) + { + $post->assertValid(); + $post->save(); + } + + public function delete(Post $post) + { + $post->delete(); + } +} diff --git a/src/Flarum/Core/Posts/TitleChangePost.php b/src/Flarum/Core/Posts/TitleChangePost.php new file mode 100755 index 000000000..a9eaa6550 --- /dev/null +++ b/src/Flarum/Core/Posts/TitleChangePost.php @@ -0,0 +1,25 @@ +content = $content; + $post->time = time(); + $post->discussion_id = $discussionId; + $post->user_id = $userId; + $post->type = 'titleChange'; + + return $post; + } +} diff --git a/src/Flarum/Core/Search/FulltextSearchDriver.php b/src/Flarum/Core/Search/FulltextSearchDriver.php new file mode 100644 index 000000000..b85055bc4 --- /dev/null +++ b/src/Flarum/Core/Search/FulltextSearchDriver.php @@ -0,0 +1,48 @@ +table = $table; + // inject db connection? + // pass primary key name? + } + + public function results(SearchCriteria $criteria) + { + $query = DB::table($this->table); + + $this->parseConditions($criteria->conditions, $query); + + return $query->get('id'); + } + + protected function parseConditions(ConditionCollection $conditions, Query $query) + { + foreach ($conditions as $condition) + { + if ($condition instanceof ConditionOr) + { + $query->orWhere(function($query) + { + $this->parseConditions($condition->conditions, $query); + }) + } + elseif ($condition instanceof ConditionComparison) + { + // etc + } + } + } +} diff --git a/src/Flarum/Core/Search/SearchDriverInterface.php b/src/Flarum/Core/Search/SearchDriverInterface.php new file mode 100644 index 000000000..f257675c9 --- /dev/null +++ b/src/Flarum/Core/Search/SearchDriverInterface.php @@ -0,0 +1,8 @@ +client = $client; + $this->index = $index; + } + + public function results(SearchCriteria $criteria) + { + foreach ($query->conditions as $condition) + { + if ($condition instanceof ConditionOr) + { + // $search->setSelect("*, IF(code = 1 OR productid = 2, 1,0) AS filter"); + // $->setFilter('filter',array(1)); + } + } + + // etc + } +} diff --git a/src/Flarum/Core/Search/Tokenizer.php b/src/Flarum/Core/Search/Tokenizer.php new file mode 100644 index 000000000..e495e1d04 --- /dev/null +++ b/src/Flarum/Core/Search/Tokenizer.php @@ -0,0 +1,17 @@ +query = $query; + } + + public function tokenize() + { + return $this->query ? [$this->query] : []; + } + +} diff --git a/src/Flarum/Core/Search/Tokens/AuthorToken.php b/src/Flarum/Core/Search/Tokens/AuthorToken.php new file mode 100644 index 000000000..90494f2d1 --- /dev/null +++ b/src/Flarum/Core/Search/Tokens/AuthorToken.php @@ -0,0 +1,25 @@ +validator = $validator; + } + + public function validate($command) + { + if (! $command->user) { + throw new InvalidArgumentException('Empty argument [user] in command ['.get_class($command).']'); + } + + $validator = $this->validator->make(get_object_vars($command), $this->rules); + + $this->fireValidationEvent([$validator, $command]); + + if ($validator->fails()) { + $this->throwValidationException($validator->errors(), $validator->getData()); + } + } + + protected function fireValidationEvent(array $arguments) + { + Event::fire(str_replace('\\', '.', get_class($this)), $arguments); + } + + protected function throwValidationException($errors, $input) + { + $exception = new ValidationFailureException; + $exception->setErrors($errors)->setInput($input); + throw $exception; + } +} diff --git a/src/Flarum/Core/Support/Exceptions/PermissionDeniedException.php b/src/Flarum/Core/Support/Exceptions/PermissionDeniedException.php new file mode 100644 index 000000000..3b3278a4d --- /dev/null +++ b/src/Flarum/Core/Support/Exceptions/PermissionDeniedException.php @@ -0,0 +1,7 @@ +errors = new MessageBag; + } + + public function setErrors(MessageBag $errors) + { + $this->errors = $errors; + + return $this; + } + + public function getErrors() + { + return $this->errors; + } + + public function setInput(array $input) + { + $this->input = $input; + + return $this; + } + + public function getInput() + { + return $this->input; + } +} diff --git a/src/Flarum/Core/Support/Extensions/Extension.php b/src/Flarum/Core/Support/Extensions/Extension.php new file mode 100755 index 000000000..a30627850 --- /dev/null +++ b/src/Flarum/Core/Support/Extensions/Extension.php @@ -0,0 +1,6 @@ +userId = $userId; + $this->user = $user; + } +} diff --git a/src/Flarum/Core/Users/Commands/DeleteUserCommandHandler.php b/src/Flarum/Core/Users/Commands/DeleteUserCommandHandler.php new file mode 100644 index 000000000..8ac3a2cbc --- /dev/null +++ b/src/Flarum/Core/Users/Commands/DeleteUserCommandHandler.php @@ -0,0 +1,33 @@ +userRepo = $userRepo; + } + + public function handle($command) + { + $user = $command->user; + $userToDelete = $this->userRepo->findOrFail($command->userId, $user); + + $userToDelete->assertCan($user, 'delete'); + + Event::fire('Flarum.Core.Users.Commands.DeleteUser.UserWillBeDeleted', [$userToDelete, $command]); + + $this->userRepo->delete($userToDelete); + $this->dispatchEventsFor($userToDelete); + + return $userToDelete; + } +} diff --git a/src/Flarum/Core/Users/Commands/DeleteUserValidator.php b/src/Flarum/Core/Users/Commands/DeleteUserValidator.php new file mode 100644 index 000000000..7719a5468 --- /dev/null +++ b/src/Flarum/Core/Users/Commands/DeleteUserValidator.php @@ -0,0 +1,7 @@ +userId = $userId; + $this->user = $user; + } +} diff --git a/src/Flarum/Core/Users/Commands/EditUserCommandHandler.php b/src/Flarum/Core/Users/Commands/EditUserCommandHandler.php new file mode 100644 index 000000000..17dc3d74c --- /dev/null +++ b/src/Flarum/Core/Users/Commands/EditUserCommandHandler.php @@ -0,0 +1,46 @@ +userRepo = $userRepo; + } + + public function handle($command) + { + $user = $command->user; + $userToEdit = $this->userRepo->findOrFail($command->userId, $user); + + $userToEdit->assertCan($user, 'edit'); + + if (isset($command->username)) { + $userToEdit->username = $command->username; + } + + if (isset($command->email)) { + $userToEdit->email = $command->email; + } + + if (isset($command->password)) { + $userToEdit->password = $command->password; + } + + Event::fire('Flarum.Core.Users.Commands.EditUser.UserWillBeSaved', [$userToEdit, $command]); + + $this->userRepo->save($userToEdit); + $this->dispatchEventsFor($userToEdit); + + return $userToEdit; + } +} diff --git a/src/Flarum/Core/Users/Commands/EditUserValidator.php b/src/Flarum/Core/Users/Commands/EditUserValidator.php new file mode 100644 index 000000000..358c98f90 --- /dev/null +++ b/src/Flarum/Core/Users/Commands/EditUserValidator.php @@ -0,0 +1,7 @@ +username = $username; + $this->email = $email; + $this->password = $password; + $this->user = $user; + } +} diff --git a/src/Flarum/Core/Users/Commands/RegisterUserCommandHandler.php b/src/Flarum/Core/Users/Commands/RegisterUserCommandHandler.php new file mode 100644 index 000000000..35016bc87 --- /dev/null +++ b/src/Flarum/Core/Users/Commands/RegisterUserCommandHandler.php @@ -0,0 +1,49 @@ +forum = $forum; + $this->userRepo = $userRepo; + } + + public function handle($command) + { + // Assert the the current user has permission to create a user. In the + // case of a guest trying to register an account, this will depend on + // whether or not registration is open. If the user is an admin, though, + // it will be allowed. + $this->forum->assertCan($command->user, 'register'); + + // Create a new User entity, persist it, and dispatch domain events. + // Before persistance, though, fire an event to give plugins an + // opportunity to alter the post entity based on data in the command. + $user = User::register( + $command->username, + $command->email, + $command->password + ); + + Event::fire('Flarum.Core.Users.Commands.RegisterUser.UserWillBeSaved', [$user, $command]); + + $this->userRepo->save($user); + $this->userRepo->syncGroups($user, [3]); // default groups + $this->dispatchEventsFor($user); + + return $user; + } +} diff --git a/src/Flarum/Core/Users/Commands/RegisterUserValidator.php b/src/Flarum/Core/Users/Commands/RegisterUserValidator.php new file mode 100644 index 000000000..add4a627a --- /dev/null +++ b/src/Flarum/Core/Users/Commands/RegisterUserValidator.php @@ -0,0 +1,7 @@ +user = $user; + } +} diff --git a/src/Flarum/Core/Users/Events/PasswordWasChanged.php b/src/Flarum/Core/Users/Events/PasswordWasChanged.php new file mode 100644 index 000000000..9844e602e --- /dev/null +++ b/src/Flarum/Core/Users/Events/PasswordWasChanged.php @@ -0,0 +1,13 @@ +user = $user; + } +} diff --git a/src/Flarum/Core/Users/Events/UserWasDeleted.php b/src/Flarum/Core/Users/Events/UserWasDeleted.php new file mode 100644 index 000000000..6683080ff --- /dev/null +++ b/src/Flarum/Core/Users/Events/UserWasDeleted.php @@ -0,0 +1,13 @@ +user = $user; + } +} diff --git a/src/Flarum/Core/Users/Events/UserWasRegistered.php b/src/Flarum/Core/Users/Events/UserWasRegistered.php new file mode 100644 index 000000000..0ecd8299c --- /dev/null +++ b/src/Flarum/Core/Users/Events/UserWasRegistered.php @@ -0,0 +1,13 @@ +user = $user; + } +} diff --git a/src/Flarum/Core/Users/Events/UserWasRenamed.php b/src/Flarum/Core/Users/Events/UserWasRenamed.php new file mode 100644 index 000000000..68a8568fe --- /dev/null +++ b/src/Flarum/Core/Users/Events/UserWasRenamed.php @@ -0,0 +1,13 @@ +user = $user; + } +} diff --git a/src/Flarum/Core/Users/Guest.php b/src/Flarum/Core/Users/Guest.php new file mode 100755 index 000000000..4a4316421 --- /dev/null +++ b/src/Flarum/Core/Users/Guest.php @@ -0,0 +1,34 @@ +setAttribute($this->getKeyName(), 0); + + return parent::__construct($attributes); + } + + public function getGroupsAttribute() + { + if ( ! isset($this->attributes['groups'])) + { + $this->attributes['groups'] = $this->relations['groups'] = Group::where('id', Group::GUEST_ID)->get(); + } + + return $this->attributes['groups']; + } + + public function guest() + { + return true; + } + +} diff --git a/src/Flarum/Core/Users/User.php b/src/Flarum/Core/Users/User.php new file mode 100755 index 000000000..9790dcd50 --- /dev/null +++ b/src/Flarum/Core/Users/User.php @@ -0,0 +1,240 @@ + 'required|username|unique', + 'email' => 'required|email|unique', + 'password' => 'required', + 'join_time' => 'date', + 'last_seen_time' => 'date', + 'discussions_count' => 'integer', + 'posts_count' => 'integer', + ]; + + protected $table = 'users'; + + protected $hidden = ['password']; + + public static function boot() + { + parent::boot(); + + static::grant(function ($grant, $user, $permission) { + return app('flarum.permissions')->granted($user, $permission, 'forum'); + }); + + // Grant view access to a user if the user can view the forum. + static::grant('view', function ($grant, $user) { + return app('forum')->can($user, 'view'); + }); + + // Allow a user to edit their own account. + static::grant('edit', function ($grant, $user) { + $grant->where('id', $user->id); + }); + + static::deleted(function ($user) { + $user->raise(new Events\UserWasDeleted($user)); + }); + } + + public function setUsernameAttribute($username) + { + $this->attributes['username'] = $username; + $this->raise(new Events\UserWasRenamed($this)); + } + + public function setEmailAttribute($email) + { + $this->attributes['email'] = $email; + $this->raise(new Events\EmailWasChanged($this)); + } + + public function setPasswordAttribute($password) + { + $this->attributes['password'] = Hash::make($password); + $this->raise(new Events\PasswordWasChanged($this)); + } + + public static function register($username, $email, $password) + { + $user = new static; + + $user->username = $username; + $user->email = $email; + $user->password = $password; + $user->join_time = time(); + + $user->raise(new Events\UserWasRegistered($user)); + + return $user; + } + + public function getDates() + { + return ['join_time', 'last_seen_time']; + } + + public function getAvatarUrlAttribute() + { + return ''; + } + + public static function current() + { + static $current = null; + + if (Auth::guest()) { + if (! isset($current)) { + // $current = new Guest; + $current = User::find(1); + } + return $current; + } + + return Auth::user(); + } + + public function getGrantees() + { + $grantees = ['group.2']; // guests + if ($this->id) { + $grantees[] = 'user.'.$this->id; + } + foreach ($this->groups as $group) { + $grantees[] = 'group.'.$group->id; + } + + /* + TODO: maybe we should rethink how groups and permissions work a bit. + + Permissions table could be like: + GRANTEE ENTITY PERMISSION + all forum view + all discussion view + all post view + all user view + user discussion create + user discussion reply + group.1 forum administrate + group.1 post delete + etc + + sit on it. what about for suspended users? we could hook in and remove the 'user' grantee? + */ + + return $grantees; + } + + public function permission($permission, $user = null) + { + if (is_null($user)) { + $user = User::current(); + } + return $this->can($user, $permission); + } + + public function scopePermission($query, $permission, $user = null) + { + if (is_null($user)) { + $user = User::current(); + } + return $this->scopeWhereCan($query, $user, $permission); + } + + public function scopeWhereCanView($query, $user = null) + { + return $this->scopePermission($query, 'view', $user); + } + + public function assertCan($user, $permission) + { + if (! $this->can($user, $permission)) { + throw new PermissionDeniedException; + } + } + + // public function granted($permission, $scope) + // { + // return isset($this->permissions[$scope]) && in_array($permission, $this->permissions[$scope]); + // } + + // public function mustBeAbleTo($permission, $scope = 'forum', $entity = null) + // { + // if (! $this->can($permission, $scope, $entity)) { + // throw new PermissionDeniedException; + // } + // } + + public function admin() + { + return $this->can('administrate'); + } + + public function isAdmin() + { + return $this->groups->contains(1); + } + + public function guest() + { + return false; + } + + public function groups() + { + return $this->belongsToMany('Flarum\Core\Groups\Group', 'users_groups'); + } + + public function activity() + { + return $this->hasMany('Flarum\Core\Activity\Activity'); + } + + /** + * Get the unique identifier for the user. + * + * @return mixed + */ + public function getAuthIdentifier() + { + return $this->getKey(); + } + + /** + * Get the password for the user. + * + * @return string + */ + public function getAuthPassword() + { + return $this->password; + } + + /** + * Get the e-mail address where password reminders are sent. + * + * @return string + */ + public function getReminderEmail() + { + return $this->email; + } +} diff --git a/src/Flarum/Core/Users/UserFinder.php b/src/Flarum/Core/Users/UserFinder.php new file mode 100644 index 000000000..3672ea579 --- /dev/null +++ b/src/Flarum/Core/Users/UserFinder.php @@ -0,0 +1,167 @@ + ['username', 'asc'], + 'posts' => ['count_posts', 'desc'], + 'discussions' => ['count_discussions', 'desc'], + 'last_active' => ['last_action_time', 'desc'], + 'created' => ['join_time', 'asc'] + ]; + + protected $order; + + protected $key; + + protected $count; + + protected $areMoreResults; + + public function __construct($user = null, $tokens = null, $sort = null, $order = '', $key = null) + { + $this->user = $user; + $this->tokens = $tokens; + $this->sort = $sort; + $this->order = $order; + $this->key = $key; + } + + public function getUser() + { + return $this->user; + } + + public function setUser($user) + { + $this->user = $user; + } + + public function getTokens() + { + return $this->tokens; + } + + public function setTokens($tokens) + { + $this->tokens = $tokens; + } + + public function setQuery($query) + { + $tokenizer = new Tokenizer($query); + $this->setTokens($tokenizer->tokenize()); + } + + public function getSort() + { + return $this->sort; + } + + public function setSort($sort) + { + $this->sort = $sort; + } + + public function getOrder() + { + return $this->order; + } + + public function setOrder($order) + { + $this->order = $order; + } + + public function getKey() + { + return $this->key; + } + + public function setKey($key) + { + $this->key = $key; + } + + protected function getCacheKey() + { + return 'users.'.$this->key; + } + + public function getCount() + { + return $this->count; + } + + public function areMoreResults() + { + return $this->areMoreResults; + } + + public function results($count = null, $start = 0) + { + $ids = null; + $query = User::whereCan($this->user, 'view'); + + // not sure if we need any of this stuff - especially ID filters? + + // if ($this->key and Cache::has($key = $this->getCacheKey())) + // { + // $ids = Cache::get($key); + // } + // elseif (count($this->tokens)) + // { + // // parse the tokens. + // // run ID filters. + // /* + // for fulltext token: + // if ( ! $this->sort) $this->sort = 'relevance'; + // */ + // if ( ! is_null($ids)) + // { + // $this->key = str_random(); + // } + + // // run other tokens + // // $discussions->where(''); + // } + + // if ( ! is_null($ids)) + // { + // Cache::put($this->getCacheKey(), $ids, 10); // recache + // $this->count = count($ids); + + // if ( ! $ids) return false; + // $query->whereIn('id', $ids); + // } + + $this->count = (int) $query->count(); + + if (empty($this->sort)) { + reset($this->sortMap); + $this->sort = key($this->sortMap); + } + if (! empty($this->sortMap[$this->sort])) { + list($column, $order) = $this->sortMap[$this->sort]; + $query->orderBy($column, $this->order ?: $order); + } + + if ($start > 0) { + $query->skip($count); + } + if ($count > 0) { + $query->take($count); + } + return $query->get(); + } +} diff --git a/src/Flarum/Core/Users/UserRepository.php b/src/Flarum/Core/Users/UserRepository.php new file mode 100755 index 000000000..111fe9bd8 --- /dev/null +++ b/src/Flarum/Core/Users/UserRepository.php @@ -0,0 +1,48 @@ +whereCanView($user); + } + + return $query->findOrFail($id); + } + + public function save(User $user) + { + $user->assertValid(); + $user->save(); + } + + public function delete(User $user) + { + $user->delete(); + + // do something with their posts/discussions? + } + + public function attachGroup(User $user, $groupId) + { + $user->groups()->attach($groupId); + } + + public function detachGroup(User $user, $groupId) + { + $user->groups()->detach($groupId); + } + + public function syncGroups(User $user, $groupIds) + { + $user->groups()->sync($groupIds); + } +} diff --git a/src/Flarum/Core/Users/UsernameValidator.php b/src/Flarum/Core/Users/UsernameValidator.php new file mode 100644 index 000000000..3681dec43 --- /dev/null +++ b/src/Flarum/Core/Users/UsernameValidator.php @@ -0,0 +1,10 @@ +files = $files; + $this->publishPath = $publishPath; + } + + protected function getPackageDir($package) + { + // TODO: First search vendor, then search workbench. + // TODO: inject path.base + return app('path.base').'/workbench/'.$package.'/dist/'; + } + + public function add($package, $files) + { + $packageDir = $this->getPackageDir($package); + + foreach ((array) $files as $file) + { + $ext = pathinfo($file, PATHINFO_EXTENSION); + switch ($ext) + { + case 'css': + $this->css[] = 'packages/'.$package.'/'.$file; + break; + + case 'js': + $this->js[] = 'packages/'.$package.'/'.$file; + break; + } + } + } + + public function getCSSFiles() + { + // TODO: in a production environment, we would concat+minify all the CSS files together + // (would probably need to check filemtimes etc.) + + // But in a development environment, we just copy all the css files to the public directory. + // foreach ($this->css as $file) + // { + + // } + + return $this->css; + } + + public function getJSFiles() + { + return $this->js; + } + + public function styles() + { + $output = ''; + + foreach ($this->getCSSFiles() as $file) + { + $output .= ''.PHP_EOL; + } + + return $output; + } + + public function scripts() + { + $output = ''; + + foreach ($this->getJSFiles() as $file) + { + $output .= ''.PHP_EOL; + } + + return $output; + } + +} diff --git a/src/Flarum/Web/WebServiceProvider.php b/src/Flarum/Web/WebServiceProvider.php new file mode 100644 index 000000000..404b8a9b1 --- /dev/null +++ b/src/Flarum/Web/WebServiceProvider.php @@ -0,0 +1,66 @@ +package('flarum/web', 'flarum.web'); + + + // Shouldn't do all this asset stuff in boot, because then it gets called on API requests + $assetManager = $this->app['flarum.web.assetManager']; + + $assetManager->add('flarum/core', [ + 'assets/vendor.css', + 'assets/flarum.css', + 'assets/vendor.js', + 'assets/flarum.js' + ]); + + // publish assets in dev environment + $publisher = new AssetPublisher($this->app['files'], $this->app['path.public']); + $publisher->publishPackage('flarum/core', $this->app['path.base'].'/workbench'); + + include __DIR__.'/../../routes.php'; + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + $this->app['flarum.web.assetManager'] = $this->app->share(function($app) + { + return new AssetManager($app['files'], $app['path.public']); + }); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides() + { + return array(); + } + +} diff --git a/src/config/.gitkeep b/src/config/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/config/config.php b/src/config/config.php new file mode 100755 index 000000000..560bfaa16 --- /dev/null +++ b/src/config/config.php @@ -0,0 +1,23 @@ + true, + 'title' => 'Flarum Prototype Forum', + 'database' => array( + 'driver' => 'mysql', + 'host' => 'localhost', + 'database' => 'flarum', + 'username' => '', + 'password' => '', + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + ), + + 'route_rules' => array( + // 'prefix' => 'blog', + // 'domain' => 'blog.site.com' + ) + +); diff --git a/src/lang/.gitkeep b/src/lang/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/lang/en/reminders.php b/src/lang/en/reminders.php new file mode 100755 index 000000000..4a9f17661 --- /dev/null +++ b/src/lang/en/reminders.php @@ -0,0 +1,22 @@ + "Passwords must be six characters and match the confirmation.", + + "user" => "We can't find a user with that e-mail address.", + + "token" => "This password reset token is invalid.", + +); \ No newline at end of file diff --git a/src/lang/en/validation.php b/src/lang/en/validation.php new file mode 100755 index 000000000..85a62aa50 --- /dev/null +++ b/src/lang/en/validation.php @@ -0,0 +1,93 @@ + "The :attribute must be accepted.", + "active_url" => "The :attribute is not a valid URL.", + "after" => "The :attribute must be a date after :date.", + "alpha" => "The :attribute may only contain letters.", + "alpha_dash" => "The :attribute may only contain letters, numbers, and dashes.", + "alpha_num" => "The :attribute may only contain letters and numbers.", + "before" => "The :attribute must be a date before :date.", + "between" => array( + "numeric" => "The :attribute must be between :min - :max.", + "file" => "The :attribute must be between :min - :max kilobytes.", + "string" => "The :attribute must be between :min - :max characters.", + ), + "confirmed" => "The :attribute confirmation does not match.", + "date" => "The :attribute is not a valid date.", + "date_format" => "The :attribute does not match the format :format.", + "different" => "The :attribute and :other must be different.", + "digits" => "The :attribute must be :digits digits.", + "digits_between" => "The :attribute must be between :min and :max digits.", + "email" => "The :attribute format is invalid.", + "exists" => "The selected :attribute is invalid.", + "image" => "The :attribute must be an image.", + "in" => "The selected :attribute is invalid.", + "integer" => "The :attribute must be an integer.", + "ip" => "The :attribute must be a valid IP address.", + "max" => array( + "numeric" => "The :attribute may not be greater than :max.", + "file" => "The :attribute may not be greater than :max kilobytes.", + "string" => "The :attribute may not be greater than :max characters.", + ), + "mimes" => "The :attribute must be a file of type: :values.", + "min" => array( + "numeric" => "The :attribute must be at least :min.", + "file" => "The :attribute must be at least :min kilobytes.", + "string" => "The :attribute must be at least :min characters.", + ), + "not_in" => "The selected :attribute is invalid.", + "numeric" => "The :attribute must be a number.", + "regex" => "The :attribute format is invalid.", + "required" => "The :attribute field is required.", + "required_if" => "The :attribute field is required when :other is :value.", + "required_with" => "The :attribute field is required when :values is present.", + "required_without" => "The :attribute field is required when :values is not present.", + "same" => "The :attribute and :other must match.", + "size" => array( + "numeric" => "The :attribute must be :size.", + "file" => "The :attribute must be :size kilobytes.", + "string" => "The :attribute must be :size characters.", + ), + "unique" => "The :attribute has already been taken.", + "url" => "The :attribute format is invalid.", + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => array(), + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap attribute place-holders + | with something more reader friendly such as E-Mail Address instead + | of "email". This simply helps us make messages a little cleaner. + | + */ + + 'attributes' => array(), + +); diff --git a/src/migrations/.gitkeep b/src/migrations/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/migrations/2014_01_14_231259_create_config_table.php b/src/migrations/2014_01_14_231259_create_config_table.php new file mode 100644 index 000000000..b74c9f1c4 --- /dev/null +++ b/src/migrations/2014_01_14_231259_create_config_table.php @@ -0,0 +1,32 @@ +string('key')->primary(); + $table->binary('value')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('config'); + } + +} diff --git a/src/migrations/2014_01_14_231321_create_discussions_table.php b/src/migrations/2014_01_14_231321_create_discussions_table.php new file mode 100644 index 000000000..e842e98c3 --- /dev/null +++ b/src/migrations/2014_01_14_231321_create_discussions_table.php @@ -0,0 +1,45 @@ +engine = 'MyISAM'; + + $table->increments('id'); + $table->string('title'); + $table->integer('posts_count')->unsigned()->default(0); + $table->integer('number_index')->unsigned()->default(0); + + $table->dateTime('start_time'); + $table->integer('start_user_id')->unsigned()->nullable(); + $table->integer('start_post_id')->unsigned()->nullable(); + + $table->dateTime('last_time')->nullable(); + $table->integer('last_user_id')->unsigned()->nullable(); + $table->integer('last_post_id')->unsigned()->nullable(); + $table->integer('last_post_number')->unsigned()->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('discussions'); + } + +} diff --git a/src/migrations/2014_01_14_231334_create_groups_table.php b/src/migrations/2014_01_14_231334_create_groups_table.php new file mode 100644 index 000000000..34e5b354f --- /dev/null +++ b/src/migrations/2014_01_14_231334_create_groups_table.php @@ -0,0 +1,32 @@ +increments('id'); + $table->string('name'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('groups'); + } + +} diff --git a/src/migrations/2014_01_14_231343_create_permissions_table.php b/src/migrations/2014_01_14_231343_create_permissions_table.php new file mode 100644 index 000000000..8bdef6ab2 --- /dev/null +++ b/src/migrations/2014_01_14_231343_create_permissions_table.php @@ -0,0 +1,34 @@ +string('grantee'); + $table->string('entity'); + $table->string('permission'); + $table->primary(['grantee', 'entity', 'permission']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('permissions'); + } + +} diff --git a/src/migrations/2014_01_14_231350_create_posts_table.php b/src/migrations/2014_01_14_231350_create_posts_table.php new file mode 100644 index 000000000..3e0ebbb18 --- /dev/null +++ b/src/migrations/2014_01_14_231350_create_posts_table.php @@ -0,0 +1,49 @@ +engine = 'MyISAM'; + + $table->increments('id'); + $table->integer('discussion_id')->unsigned(); + $table->integer('number')->unsigned()->nullable(); + + $table->dateTime('time'); + $table->integer('user_id')->unsigned()->nullable(); + $table->string('type')->nullable(); + $table->text('content'); + $table->text('html_content'); + + $table->dateTime('edit_time')->nullable(); + $table->integer('edit_user_id')->unsigned()->nullable(); + $table->dateTime('delete_time')->nullable(); + $table->integer('delete_user_id')->unsigned()->nullable(); + }); + + // add fulltext index to content (and title?) + // add unique index on [discussion_id, number] !!! + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('posts'); + } + +} diff --git a/src/migrations/2014_01_14_231357_create_sessions_table.php b/src/migrations/2014_01_14_231357_create_sessions_table.php new file mode 100644 index 000000000..e383784ad --- /dev/null +++ b/src/migrations/2014_01_14_231357_create_sessions_table.php @@ -0,0 +1,33 @@ +string('id')->unique(); + $table->binary('payload'); + $table->integer('last_activity'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('sessions'); + } + +} diff --git a/src/migrations/2014_01_14_231404_create_users_table.php b/src/migrations/2014_01_14_231404_create_users_table.php new file mode 100644 index 000000000..8ce3cb25e --- /dev/null +++ b/src/migrations/2014_01_14_231404_create_users_table.php @@ -0,0 +1,39 @@ +increments('id'); + $table->string('username'); + $table->string('email'); + $table->string('password'); + $table->dateTime('join_time'); + $table->string('time_zone'); + $table->dateTime('last_seen_time')->nullable(); + $table->integer('discussions_count')->unsigned()->default(0); + $table->integer('posts_count')->unsigned()->default(0); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('users'); + } + +} diff --git a/src/migrations/2014_01_14_231455_create_users_discussions_table.php b/src/migrations/2014_01_14_231455_create_users_discussions_table.php new file mode 100644 index 000000000..6b0108aec --- /dev/null +++ b/src/migrations/2014_01_14_231455_create_users_discussions_table.php @@ -0,0 +1,35 @@ +integer('user_id')->unsigned(); + $table->integer('discussion_id')->unsigned(); + $table->dateTime('read_time')->nullable(); + $table->integer('read_number')->unsigned()->nullable(); + $table->primary(['user_id', 'discussion_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('users_discussions'); + } + +} diff --git a/src/migrations/2014_01_14_231503_create_users_groups_table.php b/src/migrations/2014_01_14_231503_create_users_groups_table.php new file mode 100644 index 000000000..386036c7e --- /dev/null +++ b/src/migrations/2014_01_14_231503_create_users_groups_table.php @@ -0,0 +1,33 @@ +integer('user_id')->unsigned(); + $table->integer('group_id')->unsigned(); + $table->primary(['user_id', 'group_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('users_groups'); + } + +} diff --git a/src/migrations/2014_01_19_232631_create_activity_table.php b/src/migrations/2014_01_19_232631_create_activity_table.php new file mode 100644 index 000000000..43169a606 --- /dev/null +++ b/src/migrations/2014_01_19_232631_create_activity_table.php @@ -0,0 +1,38 @@ +increments('id'); + $table->integer('user_id')->unsigned(); + $table->integer('from_user_id')->unsigned()->nullable(); + $table->string('subject'); + $table->integer('subject_id')->unsigned()->nullable(); + $table->binary('data')->nullable(); + $table->dateTime('time'); + $table->boolean('is_read')->default(0); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('activity'); + } + +} diff --git a/src/routes.api.php b/src/routes.api.php new file mode 100644 index 000000000..5fbd5d8fb --- /dev/null +++ b/src/routes.api.php @@ -0,0 +1,178 @@ +parameters(); + return $action->handle($request, $parameters); + }; +} + +Route::group(['prefix' => 'api'], function () { + + /* + |-------------------------------------------------------------------------- + | Users + |-------------------------------------------------------------------------- + */ + + // List users + Route::get('users', [ + 'as' => 'flarum.api.users.index', + 'uses' => action_handler('Flarum\Api\Actions\Users\Index') + ]); + + // Register a user + Route::post('users', [ + 'as' => 'flarum.api.users.create', + 'uses' => action_handler('Flarum\Api\Actions\Users\Create') + ]); + + // Get a single user + Route::get('users/{id}', [ + 'as' => 'flarum.api.users.show', + 'uses' => action_handler('Flarum\Api\Actions\Users\Show') + ]); + + // Edit a user + Route::put('users/{id}', [ + 'as' => 'flarum.api.users.update', + 'uses' => action_handler('Flarum\Api\Actions\Users\Update') + ]); + + // Delete a user + Route::delete('users/{id}', [ + 'as' => 'flarum.api.users.delete', + 'uses' => action_handler('Flarum\Api\Actions\Users\Delete') + ]); + + /* + |-------------------------------------------------------------------------- + | Activity + |-------------------------------------------------------------------------- + */ + + // List activity + Route::get('activity', [ + 'as' => 'flarum.api.activity.index', + 'uses' => action_handler('Flarum\Api\Actions\Activity\Index') + ]); + + // List notifications for the current user + Route::get('notifications', [ + 'as' => 'flarum.api.notifications.index', + 'uses' => action_handler('Flarum\Api\Actions\Notifications\Index') + ]); + + /* + |-------------------------------------------------------------------------- + | Discussions + |-------------------------------------------------------------------------- + */ + + // List discussions + Route::get('discussions', [ + 'as' => 'flarum.api.discussions.index', + 'uses' => action_handler('Flarum\Api\Actions\Discussions\Index') + ]); + + // Create a discussion + Route::post('discussions', [ + 'as' => 'flarum.api.discussions.create', + 'uses' => action_handler('Flarum\Api\Actions\Discussions\Create') + ]); + + // Show a single discussion + Route::get('discussions/{id}', [ + 'as' => 'flarum.api.discussions.show', + 'uses' => action_handler('Flarum\Api\Actions\Discussions\Show') + ]); + + // Edit a discussion + Route::put('discussions/{id}', [ + 'as' => 'flarum.api.discussions.update', + 'uses' => action_handler('Flarum\Api\Actions\Discussions\Update') + ]); + + // Delete a discussion + Route::delete('discussions/{id}', [ + 'as' => 'flarum.api.discussions.delete', + 'uses' => action_handler('Flarum\Api\Actions\Discussions\Delete') + ]); + + /* + |-------------------------------------------------------------------------- + | Posts + |-------------------------------------------------------------------------- + */ + + // List posts + Route::get('posts', [ + 'as' => 'flarum.api.posts.index', + 'uses' => action_handler('Flarum\Api\Actions\Posts\Index') + ]); + + // Create a post + // @todo consider 'discussions/{id}/links/posts'? + Route::post('posts', [ + 'as' => 'flarum.api.posts.create', + 'uses' => action_handler('Flarum\Api\Actions\Posts\Create') + ]); + + // Show a single post + Route::get('posts/{id}', [ + 'as' => 'flarum.api.posts.show', + 'uses' => action_handler('Flarum\Api\Actions\Posts\Show') + ]); + + // Edit a post + Route::put('posts/{id}', [ + 'as' => 'flarum.api.posts.update', + 'uses' => action_handler('Flarum\Api\Actions\Posts\Update') + ]); + + // Delete a post + Route::delete('posts/{id}', [ + 'as' => 'flarum.api.posts.delete', + 'uses' => action_handler('Flarum\Api\Actions\Posts\Delete') + ]); + + /* + |-------------------------------------------------------------------------- + | Groups + |-------------------------------------------------------------------------- + */ + + // List groups + Route::get('groups', [ + 'as' => 'flarum.api.groups.index', + 'uses' => action_handler('Flarum\Api\Actions\Groups\Index') + ]); + + // Create a group + Route::post('groups', [ + 'as' => 'flarum.api.groups.create', + 'uses' => action_handler('Flarum\Api\Actions\Groups\Create') + ]); + + // Show a single group + Route::get('groups/{id}', [ + 'as' => 'flarum.api.groups.show', + 'uses' => action_handler('Flarum\Api\Actions\Groups\Show') + ]); + + // Edit a group + Route::put('groups/{id}', [ + 'as' => 'flarum.api.groups.update', + 'uses' => action_handler('Flarum\Api\Actions\Groups\Update') + ]); + + // Delete a group + Route::delete('groups/{id}', [ + 'as' => 'flarum.api.groups.delete', + 'uses' => action_handler('Flarum\Api\Actions\Groups\Delete') + ]); + +}); diff --git a/src/routes.php b/src/routes.php new file mode 100755 index 000000000..427d8e00f --- /dev/null +++ b/src/routes.php @@ -0,0 +1,7 @@ +with('title', Config::get('flarum::forum_title', 'Flarum Support Forum')); +}); \ No newline at end of file diff --git a/src/views/.gitkeep b/src/views/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/views/index.blade.php b/src/views/index.blade.php new file mode 100644 index 000000000..c5dd97df7 --- /dev/null +++ b/src/views/index.blade.php @@ -0,0 +1,28 @@ + + + + + + {{ $title }} + + + + + + + + {{ app('flarum.web.assetManager')->styles() }} + + + + + {{ app('flarum.web.assetManager')->scripts() }} + + + diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 000000000..e69de29bb