From 5919d16edfcbbc8a473ad70d1b4e93a6bd9d3d62 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 5 May 2015 08:39:24 +0930 Subject: [PATCH 001/554] Initial commit --- extensions/tags/.gitignore | 4 + extensions/tags/bootstrap.php | 5 + extensions/tags/composer.json | 11 + extensions/tags/extension.json | 15 + extensions/tags/js/.gitignore | 3 + extensions/tags/js/Gulpfile.js | 45 +++ extensions/tags/js/bootstrap.js | 274 ++++++++++++++++++ extensions/tags/js/package.json | 18 ++ extensions/tags/js/src/category.js | 12 + .../tags/js/src/components/categories-page.js | 38 +++ .../src/components/post-discussion-moved.js | 59 ++++ extensions/tags/less/categories.less | 206 +++++++++++++ ..._24_000000_add_category_to_discussions.php | 31 ++ ...5_02_24_000000_create_categories_table.php | 32 ++ .../tags/src/CategoriesServiceProvider.php | 36 +++ extensions/tags/src/Category.php | 8 + extensions/tags/src/CategoryGambit.php | 25 ++ extensions/tags/src/CategorySerializer.php | 26 ++ extensions/tags/src/DiscussionMovedPost.php | 49 ++++ extensions/tags/src/Handlers/Handler.php | 100 +++++++ 20 files changed, 997 insertions(+) create mode 100644 extensions/tags/.gitignore create mode 100644 extensions/tags/bootstrap.php create mode 100644 extensions/tags/composer.json create mode 100644 extensions/tags/extension.json create mode 100644 extensions/tags/js/.gitignore create mode 100644 extensions/tags/js/Gulpfile.js create mode 100644 extensions/tags/js/bootstrap.js create mode 100644 extensions/tags/js/package.json create mode 100644 extensions/tags/js/src/category.js create mode 100644 extensions/tags/js/src/components/categories-page.js create mode 100644 extensions/tags/js/src/components/post-discussion-moved.js create mode 100644 extensions/tags/less/categories.less create mode 100644 extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php create mode 100644 extensions/tags/migrations/2015_02_24_000000_create_categories_table.php create mode 100644 extensions/tags/src/CategoriesServiceProvider.php create mode 100644 extensions/tags/src/Category.php create mode 100644 extensions/tags/src/CategoryGambit.php create mode 100644 extensions/tags/src/CategorySerializer.php create mode 100755 extensions/tags/src/DiscussionMovedPost.php create mode 100755 extensions/tags/src/Handlers/Handler.php diff --git a/extensions/tags/.gitignore b/extensions/tags/.gitignore new file mode 100644 index 000000000..a4f3b125e --- /dev/null +++ b/extensions/tags/.gitignore @@ -0,0 +1,4 @@ +/vendor +composer.phar +.DS_Store +Thumbs.db diff --git a/extensions/tags/bootstrap.php b/extensions/tags/bootstrap.php new file mode 100644 index 000000000..bcde602ce --- /dev/null +++ b/extensions/tags/bootstrap.php @@ -0,0 +1,5 @@ +app->register('Flarum\Categories\CategoriesServiceProvider'); diff --git a/extensions/tags/composer.json b/extensions/tags/composer.json new file mode 100644 index 000000000..f95f058d8 --- /dev/null +++ b/extensions/tags/composer.json @@ -0,0 +1,11 @@ +{ + "require": { + "php": ">=5.4.0" + }, + "autoload": { + "psr-4": { + "Flarum\\Categories\\": "src/" + } + }, + "minimum-stability": "dev" +} diff --git a/extensions/tags/extension.json b/extensions/tags/extension.json new file mode 100644 index 000000000..be33153e4 --- /dev/null +++ b/extensions/tags/extension.json @@ -0,0 +1,15 @@ +{ + "name": "categories", + "description": "Organise discussions into a heirarchy of categories.", + "version": "0.1.0", + "author": { + "name": "Toby Zerner", + "email": "toby@flarum.org", + "website": "http://tobyzerner.com" + }, + "license": "MIT", + "require": { + "php": ">=5.4.0", + "flarum": ">1.0.0" + } +} diff --git a/extensions/tags/js/.gitignore b/extensions/tags/js/.gitignore new file mode 100644 index 000000000..372e20a51 --- /dev/null +++ b/extensions/tags/js/.gitignore @@ -0,0 +1,3 @@ +bower_components +node_modules +dist diff --git a/extensions/tags/js/Gulpfile.js b/extensions/tags/js/Gulpfile.js new file mode 100644 index 000000000..baa47a7ac --- /dev/null +++ b/extensions/tags/js/Gulpfile.js @@ -0,0 +1,45 @@ +var gulp = require('gulp'); +var livereload = require('gulp-livereload'); +var concat = require('gulp-concat'); +var argv = require('yargs').argv; +var uglify = require('gulp-uglify'); +var gulpif = require('gulp-if'); +var babel = require('gulp-babel'); +var cached = require('gulp-cached'); +var remember = require('gulp-remember'); +var merge = require('merge-stream'); +var streamqueue = require('streamqueue'); + +var staticFiles = [ + 'bootstrap.js' +]; +var moduleFiles = [ + 'src/**/*.js' +]; +var modulePrefix = 'categories'; + +gulp.task('default', function() { + return streamqueue({objectMode: true}, + gulp.src(moduleFiles) + .pipe(cached('scripts')) + .pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix })) + .pipe(remember('scripts')), + gulp.src(staticFiles) + .pipe(babel()) + ) + .pipe(concat('extension.js')) + .pipe(gulpif(argv.production, uglify())) + .pipe(gulp.dest('dist')) + .pipe(livereload()); +}); + +gulp.task('watch', ['default'], function () { + livereload.listen(); + var watcher = gulp.watch(moduleFiles.concat(staticFiles), ['default']); + watcher.on('change', function (event) { + if (event.type === 'deleted') { + delete cached.caches.scripts[event.path]; + remember.forget('scripts', event.path); + } + }); +}); diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js new file mode 100644 index 000000000..00de45748 --- /dev/null +++ b/extensions/tags/js/bootstrap.js @@ -0,0 +1,274 @@ +import { extend, override } from 'flarum/extension-utils'; +import Model from 'flarum/model'; +import Component from 'flarum/component'; +import Discussion from 'flarum/models/discussion'; +import IndexPage from 'flarum/components/index-page'; +import DiscussionPage from 'flarum/components/discussion-page'; +import DiscussionList from 'flarum/components/discussion-list'; +import DiscussionHero from 'flarum/components/discussion-hero'; +import Separator from 'flarum/components/separator'; +import NavItem from 'flarum/components/nav-item'; +import ActionButton from 'flarum/components/action-button'; +import ComposerDiscussion from 'flarum/components/composer-discussion'; +import ActivityPost from 'flarum/components/activity-post'; +import icon from 'flarum/helpers/icon'; + +import CategoriesPage from 'categories/components/categories-page'; +import Category from 'categories/category'; +import PostDiscussionMoved from 'categories/components/post-discussion-moved'; + +import app from 'flarum/app'; + +Discussion.prototype.category = Model.one('category'); + +app.initializers.add('categories', function() { + app.routes['categories'] = ['/categories', CategoriesPage.component()]; + + app.routes['category'] = ['/c/:categories', IndexPage.component({category: true})]; + + + // @todo support combination with filters + // app.routes['category.filter'] = ['/c/:slug/:filter', IndexPage.component({category: true})]; + + app.postComponentRegistry['discussionMoved'] = PostDiscussionMoved; + app.store.model('categories', Category); + + extend(DiscussionList.prototype, 'infoItems', function(items, discussion) { + var category = discussion.category(); + if (category && category.slug() !== this.props.params.categories) { + items.add('category', m('span.category', {style: 'color: '+category.color()}, category.title()), {first: true}); + } + + return items; + }); + + class CategoryNavItem extends NavItem { + view() { + var category = this.props.category; + var active = this.constructor.active(this.props); + return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route, onclick: () => {app.cache.discussionList = null; m.redraw.strategy('none')}, style: active ? 'color: '+category.color() : ''}, [ + m('span.icon.category-icon', {style: 'background-color: '+category.color()}), + category.title() + ])); + } + + static props(props) { + var category = props.category; + props.params.categories = category.slug(); + props.href = app.route('category', props.params); + props.label = category.title(); + + return props; + } + } + + extend(IndexPage.prototype, 'navItems', function(items) { + items.add('categories', NavItem.component({ + icon: 'reorder', + label: 'Categories', + href: app.route('categories'), + config: m.route + }), {last: true}); + + items.add('separator', Separator.component(), {last: true}); + + app.store.all('categories').forEach(category => { + items.add('category'+category.id(), CategoryNavItem.component({category, params: this.stickyParams()}), {last: true}); + }); + + return items; + }); + + extend(IndexPage.prototype, 'params', function(params) { + params.categories = this.props.category ? m.route.param('categories') : undefined; + return params; + }); + + class CategoryHero extends Component { + view() { + var category = this.props.category; + + return m('header.hero.category-hero', {style: 'background-color: '+category.color()}, [ + m('div.container', [ + m('div.container-narrow', [ + m('h2', category.title()), + m('div.subtitle', category.description()) + ]) + ]) + ]); + } + } + + extend(IndexPage.prototype, 'view', function(view) { + if (this.props.category) { + var slug = this.params().categories; + var category = app.store.all('categories').filter(category => category.slug() == slug)[0]; + view.children[0] = CategoryHero.component({category}); + } + return view; + }); + + extend(IndexPage.prototype, 'sidebarItems', function(items) { + var slug = this.params().categories; + var category = app.store.all('categories').filter(category => category.slug() == slug)[0]; + if (category) { + items.newDiscussion.content.props.style = 'background-color: '+category.color(); + } + return items; + }); + + extend(DiscussionList.prototype, 'params', function(params) { + if (params.categories) { + params.q = (params.q || '')+' category:'+params.categories; + delete params.categories; + } + return params; + }); + + extend(DiscussionPage.prototype, 'params', function(params) { + params.include += ',category'; + return params; + }); + + extend(DiscussionHero.prototype, 'view', function(view) { + var category = this.props.discussion.category(); + if (category) { + view.attrs.style = 'background-color: '+category.color(); + } + return view; + }); + + extend(DiscussionHero.prototype, 'items', function(items) { + var category = this.props.discussion.category(); + if (category) { + items.add('category', m('span.category', category.title()), {before: 'title'}); + items.title.content.wrapperClass = 'block-item'; + } + return items; + }); + + class MoveDiscussionModal extends Component { + constructor(props) { + super(props); + + this.categories = m.prop(app.store.all('categories')); + } + + view() { + var discussion = this.props.discussion; + + return m('div.modal-dialog.modal-move-discussion', [ + m('div.modal-content', [ + m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')), + m('div.modal-header', m('h3.title-control', discussion + ? ['Move ', m('em', discussion.title()), ' from ', m('span.category', {style: 'color: '+discussion.category().color()}, discussion.category().title()), ' to...'] + : ['Start a Discussion In...'])), + m('div', [ + m('ul.category-list', [ + this.categories().map(category => + (discussion && category.id() === discussion.category().id()) ? '' : m('li.category-tile', {style: 'background-color: '+category.color()}, [ + m('a[href=javascript:;]', {onclick: this.save.bind(this, category)}, [ + m('h3.title', category.title()), + m('p.description', category.description()), + m('span.count', category.discussionsCount()+' discussions'), + ]) + ]) + ) + ]) + ]) + ]) + ]); + } + + save(category) { + var discussion = this.props.discussion; + + if (discussion) { + discussion.save({links: {category}}).then(discussion => { + if (app.current instanceof DiscussionPage) { + app.current.stream().sync(); + } + m.redraw(); + }); + } + + this.props.onchange && this.props.onchange(category); + + app.modal.close(); + + m.redraw.strategy('none'); + } + } + + function move() { + app.modal.show(new MoveDiscussionModal({discussion: this})); + } + + extend(Discussion.prototype, 'controls', function(items) { + if (this.canEdit()) { + items.add('move', ActionButton.component({ + label: 'Move', + icon: 'arrow-right', + onclick: move.bind(this) + }), {after: 'rename'}); + } + + return items; + }); + + override(IndexPage.prototype, 'newDiscussion', function(parent) { + var categories = app.store.all('categories'); + var slug = this.params().categories; + var category; + if (slug || !app.session.user()) { + parent(); + if (app.composer.component) { + category = categories.filter(category => category.slug() == slug)[0]; + app.composer.component.category(category); + } + } else { + var modal = new MoveDiscussionModal({onchange: category => { + parent(); + app.composer.component.category(category); + }}); + app.modal.show(modal); + } + }); + + ComposerDiscussion.prototype.chooseCategory = function() { + var modal = new MoveDiscussionModal({onchange: category => { + this.category(category); + this.$('textarea').focus(); + }}); + app.modal.show(modal); + }; + + ComposerDiscussion.prototype.category = m.prop(); + extend(ComposerDiscussion.prototype, 'headerItems', function(items) { + var category = this.category(); + + items.add('category', m('a[href=javascript:;][tabindex=-1].btn.btn-link.control-change-category', { + onclick: this.chooseCategory.bind(this) + }, [ + category ? m('span.category-icon', {style: 'background-color: '+category.color()}) : '', ' ', + m('span.label', category ? category.title() : 'Uncategorized'), + icon('sort') + ])); + + return items; + }); + + extend(ComposerDiscussion.prototype, 'data', function(data) { + data.links = data.links || {}; + data.links.category = this.category(); + return data; + }) + + extend(ActivityPost.prototype, 'headerItems', function(items) { + var category = this.props.activity.post().discussion().category(); + if (category) { + items.add('category', m('span.category', {style: {color: category.color()}}, category.title())); + } + return items; + }) +}); diff --git a/extensions/tags/js/package.json b/extensions/tags/js/package.json new file mode 100644 index 000000000..b83b01cc5 --- /dev/null +++ b/extensions/tags/js/package.json @@ -0,0 +1,18 @@ +{ + "name": "flarum-sticky", + "devDependencies": { + "gulp": "^3.8.11", + "gulp-babel": "^5.1.0", + "gulp-cached": "^1.0.4", + "gulp-concat": "^2.5.2", + "gulp-if": "^1.2.5", + "gulp-livereload": "^3.8.0", + "gulp-remember": "^0.3.0", + "gulp-uglify": "^1.2.0", + "merge-stream": "^0.1.7", + "yargs": "^3.7.2" + }, + "dependencies": { + "streamqueue": "^0.1.3" + } +} diff --git a/extensions/tags/js/src/category.js b/extensions/tags/js/src/category.js new file mode 100644 index 000000000..4de836019 --- /dev/null +++ b/extensions/tags/js/src/category.js @@ -0,0 +1,12 @@ +import Model from 'flarum/model'; + +class Category extends Model {} + +Category.prototype.id = Model.prop('id'); +Category.prototype.title = Model.prop('title'); +Category.prototype.slug = Model.prop('slug'); +Category.prototype.description = Model.prop('description'); +Category.prototype.color = Model.prop('color'); +Category.prototype.discussionsCount = Model.prop('discussionsCount'); + +export default Category; diff --git a/extensions/tags/js/src/components/categories-page.js b/extensions/tags/js/src/components/categories-page.js new file mode 100644 index 000000000..4bf407de6 --- /dev/null +++ b/extensions/tags/js/src/components/categories-page.js @@ -0,0 +1,38 @@ +import Component from 'flarum/component'; +import WelcomeHero from 'flarum/components/welcome-hero'; +import icon from 'flarum/helpers/icon'; + +export default class CategoriesPage extends Component { + constructor(props) { + super(props); + + this.categories = m.prop(app.store.all('categories')); + } + + view() { + return m('div.categories-area', [ + m('div.title-control.categories-forum-title', app.config.forum_title), + WelcomeHero.component(), + m('div.container', [ + m('ul.category-list.category-list-tiles', [ + m('li.filter-tile', [ + m('a', {href: app.route('index'), config: m.route}, 'All Discussions'), + // m('ul.filter-list', [ + // m('li', m('a', {href: app.route('index'), config: m.route}, m('span', [icon('star'), ' Following']))), + // m('li', m('a', {href: app.route('index'), config: m.route}, m('span', [icon('envelope-o'), ' Inbox']))) + // ]) + ]), + this.categories().map(category => + m('li.category-tile', {style: 'background-color: '+category.color()}, [ + m('a', {href: app.route('category', {categories: category.slug()}), config: m.route}, [ + m('h3.title', category.title()), + m('p.description', category.description()), + m('span.count', category.discussionsCount()+' discussions'), + ]) + ]) + ) + ]) + ]) + ]); + } +} diff --git a/extensions/tags/js/src/components/post-discussion-moved.js b/extensions/tags/js/src/components/post-discussion-moved.js new file mode 100644 index 000000000..1168b42a8 --- /dev/null +++ b/extensions/tags/js/src/components/post-discussion-moved.js @@ -0,0 +1,59 @@ +import Component from 'flarum/component'; +import icon from 'flarum/helpers/icon'; +import username from 'flarum/helpers/username'; +import humanTime from 'flarum/utils/human-time'; +import SubtreeRetainer from 'flarum/utils/subtree-retainer'; +import ItemList from 'flarum/utils/item-list'; +import ActionButton from 'flarum/components/action-button'; +import DropdownButton from 'flarum/components/dropdown-button'; + +export default class PostDiscussionMoved extends Component { + constructor(props) { + super(props); + + this.subtree = new SubtreeRetainer( + () => this.props.post.freshness, + () => this.props.post.user().freshness + ); + } + + view(ctrl) { + var controls = this.controlItems().toArray(); + + var post = this.props.post; + var oldCategory = app.store.getById('categories', post.content()[0]); + var newCategory = app.store.getById('categories', post.content()[1]); + + return m('article.post.post-activity.post-discussion-moved', this.subtree.retain() || m('div', [ + controls.length ? DropdownButton.component({ + items: controls, + className: 'contextual-controls', + buttonClass: 'btn btn-default btn-icon btn-sm btn-naked', + menuClass: 'pull-right' + }) : '', + icon('arrow-right post-icon'), + m('div.post-activity-info', [ + m('a.post-user', {href: app.route('user', {username: post.user().username()}), config: m.route}, username(post.user())), + ' moved the discussion from ', m('span.category', {style: {color: oldCategory.color()}}, oldCategory.title()), ' to ', m('span.category', {style: {color: newCategory.color()}}, newCategory.title()), '.' + ]), + m('div.post-activity-time', humanTime(post.time())) + ])); + } + + controlItems() { + var items = new ItemList(); + var post = this.props.post; + + if (post.canDelete()) { + items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.delete.bind(this) })); + } + + return items; + } + + delete() { + var post = this.props.post; + post.delete(); + this.props.ondelete && this.props.ondelete(post); + } +} diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less new file mode 100644 index 000000000..5b1b9aeee --- /dev/null +++ b/extensions/tags/less/categories.less @@ -0,0 +1,206 @@ +.category { + text-transform: uppercase; + font-size: 80%; + font-weight: bold; + display: inline-block; + + .discussion-summary & { + margin-right: 10px; + font-size: 11px; + } + + .discussion-hero & { + font-size: 14px; + } + + .post-discussion-moved & { + margin: 0 2px; + } +} +.discussion-hero { + & .block-item { + margin-top: 10px; + } +} + +.category-icon { + border-radius: @border-radius-base; + width: 16px; + height: 16px; + display: inline-block; + vertical-align: -3px; + margin-left: 1px; +} + +.categories-area .container { + width: 100%; + max-width: none; + padding: 0; +} + +.control-change-category { + vertical-align: 1px; + margin: -10px 0; + + & .label { + margin: 0 2px 0 5px; + } + + .minimized & { + display: none; + } +} + +.modal-move-discussion { + & .modal-header { + padding: 20px; + text-align: left; + + & h3 { + font-size: 16px; + } + } + & .modal-content { + overflow: hidden; + } + & .category-tile .title { + margin-bottom: 10px; + font-size: 18px; + } + & .category-tile .description { + margin-bottom: 0; + font-size: 13px; + } + & .count { + display: none; + } + & .category-tile > a { + padding: 20px; + } +} + + +.category-list { + margin: 0; + padding: 0; + list-style: none; + background: @fl-body-control-bg; + color: @fl-body-control-color; + overflow: hidden; +} + +@media @tablet { + .category-list-tiles { + & > li { + float: left; + width: 50%; + height: 175px; + + & > a { + height: 100%; + } + } + } +} +@media @tablet, @desktop, @desktop-hd { + .categories-forum-title { + display: none; + } +} +@media @desktop, @desktop-hd { + .category-list-tiles { + & > li { + float: left; + width: 33.333%; + height: 175px; + + & > a { + height: 100%; + } + } + } +} +.category-tile { + position: relative; + + &, & > a { + color: #fff; + } + & > a { + display: block; + padding: 25px; + transition: background 0.1s; + text-decoration: none; + } + & > a:hover { + background: rgba(0, 0, 0, 0.1); + } + & > a:active { + background: rgba(0, 0, 0, 0.2); + } + & .title { + font-size: 20px; + margin: 0 0 15px; + } + & .description { + font-size: 14px; + line-height: 1.5em; + color: rgba(255, 255, 255, 0.6); + } + & .count { + text-transform: uppercase; + font-weight: bold; + font-size: 11px; + color: rgba(255, 255, 255, 0.6); + } +} +.filter-tile a { + display: block; + padding: 15px 25px; + font-size: 18px; + color: @fl-body-control-color; +} +@media @tablet, @desktop, @desktop-hd { + .filter-tile { + & > a { + float: left; + width: 50%; + border-right: 1px solid #fff; + + &:first-child:last-child { + width: 100%; + border-right: 0; + } + } + & a { + display: block; + height: 100%; + padding: 20px; + font-size: 18px; + color: @fl-body-control-color; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + } + } + .filter-list { + float: left; + margin: 0; + padding: 0; + list-style: none; + height: 100%; + display: table; + width: 50%; + table-layout: fixed; + + & > li { + display: table-row; + height: 1%; + + &:not(:last-child) a { + border-bottom: 1px solid #fff; + } + } + } +} diff --git a/extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php b/extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php new file mode 100644 index 000000000..68c1b285a --- /dev/null +++ b/extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php @@ -0,0 +1,31 @@ +integer('category_id')->unsigned(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('discussions', function (Blueprint $table) { + $table->dropColumn('category_id'); + }); + } +} diff --git a/extensions/tags/migrations/2015_02_24_000000_create_categories_table.php b/extensions/tags/migrations/2015_02_24_000000_create_categories_table.php new file mode 100644 index 000000000..dac755568 --- /dev/null +++ b/extensions/tags/migrations/2015_02_24_000000_create_categories_table.php @@ -0,0 +1,32 @@ +increments('id'); + $table->string('title'); + $table->text('description'); + $table->string('color'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('categories'); + } +} diff --git a/extensions/tags/src/CategoriesServiceProvider.php b/extensions/tags/src/CategoriesServiceProvider.php new file mode 100644 index 000000000..bd99b994a --- /dev/null +++ b/extensions/tags/src/CategoriesServiceProvider.php @@ -0,0 +1,36 @@ +subscribe('Flarum\Categories\Handlers\Handler'); + + IndexAction::$include['category'] = true; + + ShowAction::$include['category'] = true; + + Post::addType('discussionMoved', 'Flarum\Categories\DiscussionMovedPost'); + + Discussion::addRelationship('category', function ($model) { + return $model->belongsTo('Flarum\Categories\Category', null, null, 'category'); + }); + } + + public function register() + { + // + } +} diff --git a/extensions/tags/src/Category.php b/extensions/tags/src/Category.php new file mode 100644 index 000000000..65bb38817 --- /dev/null +++ b/extensions/tags/src/Category.php @@ -0,0 +1,8 @@ +categories->getIdForSlug($slug); + $id = Category::whereSlug($slug)->pluck('id'); + + $searcher->query()->where('category_id', $id); + } +} diff --git a/extensions/tags/src/CategorySerializer.php b/extensions/tags/src/CategorySerializer.php new file mode 100644 index 000000000..48ee398e4 --- /dev/null +++ b/extensions/tags/src/CategorySerializer.php @@ -0,0 +1,26 @@ + $category->title, + 'description' => $category->description, + 'slug' => $category->slug, + 'color' => $category->color, + 'discussionsCount' => (int) $category->discussions_count + ]; + + return $this->extendAttributes($category, $attributes); + } +} diff --git a/extensions/tags/src/DiscussionMovedPost.php b/extensions/tags/src/DiscussionMovedPost.php new file mode 100755 index 000000000..a5cd59639 --- /dev/null +++ b/extensions/tags/src/DiscussionMovedPost.php @@ -0,0 +1,49 @@ +content = [$oldCategoryId, $newCategoryId]; + $post->time = time(); + $post->discussion_id = $discussionId; + $post->user_id = $userId; + $post->type = 'discussionMoved'; + + return $post; + } + + /** + * Unserialize the content attribute. + * + * @param string $value + * @return string + */ + public function getContentAttribute($value) + { + return json_decode($value); + } + + /** + * Serialize the content attribute. + * + * @param string $value + */ + public function setContentAttribute($value) + { + $this->attributes['content'] = json_encode($value); + } +} diff --git a/extensions/tags/src/Handlers/Handler.php b/extensions/tags/src/Handlers/Handler.php new file mode 100755 index 000000000..fae68dd60 --- /dev/null +++ b/extensions/tags/src/Handlers/Handler.php @@ -0,0 +1,100 @@ +actor = $actor; + } + + public function subscribe($events) + { + $events->listen('Flarum\Forum\Events\RenderView', __CLASS__.'@renderForum'); + $events->listen('Flarum\Api\Events\SerializeRelationship', __CLASS__.'@serializeRelationship'); + $events->listen('Flarum\Core\Events\RegisterDiscussionGambits', __CLASS__.'@registerGambits'); + $events->listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@saveCategoryToDiscussion'); + } + + public function renderForum(RenderView $event) + { + $root = __DIR__.'/../..'; + + $event->assets->addFile([ + $root.'/js/dist/extension.js', + $root.'/less/categories.less' + ]); + + $serializer = new CategorySerializer($event->action->actor); + $event->view->data = array_merge($event->view->data, $serializer->collection(Category::orderBy('position')->get())->toArray()); + } + + public function saveCategoryToDiscussion(DiscussionWillBeSaved $event) + { + if (isset($event->command->data['links']['category']['linkage'])) { + $linkage = $event->command->data['links']['category']['linkage']; + + $categoryId = (int) $linkage['id']; + $discussion = $event->discussion; + $user = $event->command->user; + + $oldCategoryId = (int) $discussion->category_id; + + if ($oldCategoryId === $categoryId) { + return; + } + + $discussion->category_id = $categoryId; + + if ($discussion->exists) { + $lastPost = $discussion->posts()->orderBy('time', 'desc')->first(); + if ($lastPost instanceof DiscussionMovedPost) { + if ($lastPost->content[0] == $categoryId) { + $lastPost->delete(); + $discussion->postWasRemoved($lastPost); + } else { + $newContent = $lastPost->content; + $newContent[1] = $categoryId; + $lastPost->content = $newContent; + $lastPost->save(); + $discussion->postWasAdded($lastPost); + } + } else { + $post = DiscussionMovedPost::reply( + $discussion->id, + $user->id, + $oldCategoryId, + $categoryId + ); + + $post->save(); + + $discussion->postWasAdded($post); + } + } + } + } + + public function registerGambits(RegisterDiscussionGambits $event) + { + $event->gambits->add('Flarum\Categories\CategoryGambit'); + } + + public function serializeRelationship(SerializeRelationship $event) + { + if ($event->serializer instanceof DiscussionSerializer && $event->name === 'category') { + return $event->serializer->hasOne('Flarum\Categories\CategorySerializer', 'category'); + } + } +} From 360402c5b164a3a6cc8f2a39678f7b6537d486f4 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 5 May 2015 11:45:31 +0930 Subject: [PATCH 002/554] Cleanup, add discussion moved notification --- extensions/tags/bootstrap.php | 6 +- extensions/tags/js/bootstrap.js | 2 + .../notification-discussion-moved.js | 27 +++++++++ .../Handler.php => CategoriesHandler.php} | 60 ++++--------------- .../tags/src/CategoriesServiceProvider.php | 31 ++++++---- extensions/tags/src/CategoryGambit.php | 28 ++++++++- .../tags/src/CategoryRepositoryInterface.php | 24 ++++++++ extensions/tags/src/CategorySerializer.php | 6 ++ .../tags/src/DiscussionMovedNotification.php | 39 ++++++++++++ .../tags/src/DiscussionMovedNotifier.php | 58 ++++++++++++++++++ extensions/tags/src/DiscussionMovedPost.php | 60 ++++++++++++------- .../tags/src/EloquentCategoryRepository.php | 52 ++++++++++++++++ .../tags/src/Events/DiscussionWasMoved.php | 34 +++++++++++ 13 files changed, 342 insertions(+), 85 deletions(-) create mode 100644 extensions/tags/js/src/components/notification-discussion-moved.js rename extensions/tags/src/{Handlers/Handler.php => CategoriesHandler.php} (54%) create mode 100644 extensions/tags/src/CategoryRepositoryInterface.php create mode 100644 extensions/tags/src/DiscussionMovedNotification.php create mode 100755 extensions/tags/src/DiscussionMovedNotifier.php create mode 100644 extensions/tags/src/EloquentCategoryRepository.php create mode 100644 extensions/tags/src/Events/DiscussionWasMoved.php diff --git a/extensions/tags/bootstrap.php b/extensions/tags/bootstrap.php index bcde602ce..19039db79 100644 --- a/extensions/tags/bootstrap.php +++ b/extensions/tags/bootstrap.php @@ -1,5 +1,9 @@ app->register('Flarum\Categories\CategoriesServiceProvider'); +// Register our service provider with the Flarum application. In here we can +// register bindings and execute code when the application boots. +$app->register('Flarum\Categories\CategoriesServiceProvider'); diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 00de45748..7eeeb273e 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -16,6 +16,7 @@ import icon from 'flarum/helpers/icon'; import CategoriesPage from 'categories/components/categories-page'; import Category from 'categories/category'; import PostDiscussionMoved from 'categories/components/post-discussion-moved'; +import NotificationDiscussionMoved from 'categories/components/notification-discussion-moved'; import app from 'flarum/app'; @@ -31,6 +32,7 @@ app.initializers.add('categories', function() { // app.routes['category.filter'] = ['/c/:slug/:filter', IndexPage.component({category: true})]; app.postComponentRegistry['discussionMoved'] = PostDiscussionMoved; + app.notificationComponentRegistry['discussionMoved'] = NotificationDiscussionMoved; app.store.model('categories', Category); extend(DiscussionList.prototype, 'infoItems', function(items, discussion) { diff --git a/extensions/tags/js/src/components/notification-discussion-moved.js b/extensions/tags/js/src/components/notification-discussion-moved.js new file mode 100644 index 000000000..f3ef00481 --- /dev/null +++ b/extensions/tags/js/src/components/notification-discussion-moved.js @@ -0,0 +1,27 @@ +import Notification from 'flarum/components/notification'; +import avatar from 'flarum/helpers/avatar'; +import icon from 'flarum/helpers/icon'; +import username from 'flarum/helpers/username'; +import humanTime from 'flarum/helpers/human-time'; + +export default class NotificationDiscussionMoved extends Notification { + content() { + var notification = this.props.notification; + var discussion = notification.subject(); + var category = discussion.category(); + + return m('a', {href: app.route('discussion.near', { + id: discussion.id(), + slug: discussion.slug(), + near: notification.content().postNumber + }), config: m.route}, [ + avatar(notification.sender()), + m('h3.notification-title', discussion.title()), + m('div.notification-info', [ + icon('arrow-right'), + ' Moved to ', m('span.category', {style: 'color: '+category.color()}, category.title()), ' by ', username(notification.sender()), + ' ', humanTime(notification.time()) + ]) + ]); + } +} diff --git a/extensions/tags/src/Handlers/Handler.php b/extensions/tags/src/CategoriesHandler.php similarity index 54% rename from extensions/tags/src/Handlers/Handler.php rename to extensions/tags/src/CategoriesHandler.php index fae68dd60..c21b49753 100755 --- a/extensions/tags/src/Handlers/Handler.php +++ b/extensions/tags/src/CategoriesHandler.php @@ -1,35 +1,27 @@ -actor = $actor; - } - public function subscribe($events) { $events->listen('Flarum\Forum\Events\RenderView', __CLASS__.'@renderForum'); $events->listen('Flarum\Api\Events\SerializeRelationship', __CLASS__.'@serializeRelationship'); - $events->listen('Flarum\Core\Events\RegisterDiscussionGambits', __CLASS__.'@registerGambits'); - $events->listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@saveCategoryToDiscussion'); + $events->listen('Flarum\Core\Events\RegisterDiscussionGambits', __CLASS__.'@registerDiscussionGambits'); + $events->listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved'); } public function renderForum(RenderView $event) { - $root = __DIR__.'/../..'; + $root = __DIR__.'/..'; $event->assets->addFile([ $root.'/js/dist/extension.js', @@ -40,7 +32,7 @@ class Handler $event->view->data = array_merge($event->view->data, $serializer->collection(Category::orderBy('position')->get())->toArray()); } - public function saveCategoryToDiscussion(DiscussionWillBeSaved $event) + public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event) { if (isset($event->command->data['links']['category']['linkage'])) { $linkage = $event->command->data['links']['category']['linkage']; @@ -56,37 +48,11 @@ class Handler } $discussion->category_id = $categoryId; - - if ($discussion->exists) { - $lastPost = $discussion->posts()->orderBy('time', 'desc')->first(); - if ($lastPost instanceof DiscussionMovedPost) { - if ($lastPost->content[0] == $categoryId) { - $lastPost->delete(); - $discussion->postWasRemoved($lastPost); - } else { - $newContent = $lastPost->content; - $newContent[1] = $categoryId; - $lastPost->content = $newContent; - $lastPost->save(); - $discussion->postWasAdded($lastPost); - } - } else { - $post = DiscussionMovedPost::reply( - $discussion->id, - $user->id, - $oldCategoryId, - $categoryId - ); - - $post->save(); - - $discussion->postWasAdded($post); - } - } + $discussion->raise(new DiscussionWasMoved($discussion, $user, $oldCategoryId)); } } - public function registerGambits(RegisterDiscussionGambits $event) + public function registerDiscussionGambits(RegisterDiscussionGambits $event) { $event->gambits->add('Flarum\Categories\CategoryGambit'); } diff --git a/extensions/tags/src/CategoriesServiceProvider.php b/extensions/tags/src/CategoriesServiceProvider.php index bd99b994a..6ce82ce4a 100644 --- a/extensions/tags/src/CategoriesServiceProvider.php +++ b/extensions/tags/src/CategoriesServiceProvider.php @@ -2,10 +2,11 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Contracts\Events\Dispatcher; -use Flarum\Api\Actions\Discussions\IndexAction; -use Flarum\Api\Actions\Discussions\ShowAction; use Flarum\Core\Models\Post; use Flarum\Core\Models\Discussion; +use Flarum\Core\Notifications\Notifier; +use Flarum\Api\Actions\Discussions\IndexAction as DiscussionsIndexAction; +use Flarum\Api\Actions\Discussions\ShowAction as DiscussionsShowAction; class CategoriesServiceProvider extends ServiceProvider { @@ -14,23 +15,31 @@ class CategoriesServiceProvider extends ServiceProvider * * @return void */ - public function boot(Dispatcher $events) + public function boot(Dispatcher $events, Notifier $notifier) { - $events->subscribe('Flarum\Categories\Handlers\Handler'); - - IndexAction::$include['category'] = true; - - ShowAction::$include['category'] = true; - - Post::addType('discussionMoved', 'Flarum\Categories\DiscussionMovedPost'); + $events->subscribe('Flarum\Categories\CategoriesHandler'); + $events->subscribe('Flarum\Categories\DiscussionMovedNotifier'); + // Add the category relationship to the Discussion model, and include + // it in discussion-related API actions by default. Discussion::addRelationship('category', function ($model) { return $model->belongsTo('Flarum\Categories\Category', null, null, 'category'); }); + DiscussionsIndexAction::$include['category'] = true; + DiscussionsShowAction::$include['category'] = true; + + // Add a new post and notification type to represent a discussion + // being moved from one category to another. + Post::addType('Flarum\Categories\DiscussionMovedPost'); + + $notifier->registerType('Flarum\Categories\DiscussionMovedNotification', ['alert' => true]); } public function register() { - // + $this->app->bind( + 'Flarum\Categories\CategoryRepositoryInterface', + 'Flarum\Categories\EloquentCategoryRepository' + ); } } diff --git a/extensions/tags/src/CategoryGambit.php b/extensions/tags/src/CategoryGambit.php index 928a8bb9d..38b75ddcf 100644 --- a/extensions/tags/src/CategoryGambit.php +++ b/extensions/tags/src/CategoryGambit.php @@ -8,17 +8,39 @@ class CategoryGambit extends GambitAbstract { /** * The gambit's regex pattern. + * * @var string */ protected $pattern = 'category:(.+)'; + /** + * @var \Flarum\Categories\CategoryRepositoryInterface + */ + protected $categories; + + /** + * Instantiate the gambit. + * + * @param \Flarum\Categories\CategoryRepositoryInterface $categories + */ + public function __construct(CategoryRepositoryInterface $categories) + { + $this->categories = $categories; + } + + /** + * Apply conditions to the searcher, given matches from the gambit's + * regex. + * + * @param array $matches The matches from the gambit's regex. + * @param \Flarum\Core\Search\SearcherInterface $searcher + * @return void + */ public function conditions($matches, SearcherInterface $searcher) { $slug = trim($matches[1], '"'); - // @todo implement categories repository - // $id = $this->categories->getIdForSlug($slug); - $id = Category::whereSlug($slug)->pluck('id'); + $id = $this->categories->getIdForSlug($slug); $searcher->query()->where('category_id', $id); } diff --git a/extensions/tags/src/CategoryRepositoryInterface.php b/extensions/tags/src/CategoryRepositoryInterface.php new file mode 100644 index 000000000..01c0875a9 --- /dev/null +++ b/extensions/tags/src/CategoryRepositoryInterface.php @@ -0,0 +1,24 @@ +post = $post; + + parent::__construct($recipient, $sender); + } + + public function getSubject() + { + return $this->post->discussion; + } + + public function getAlertData() + { + return [ + 'postNumber' => $this->post->number + ]; + } + + public static function getType() + { + return 'discussionMoved'; + } + + public static function getSubjectModel() + { + return 'Flarum\Core\Models\Discussion'; + } +} diff --git a/extensions/tags/src/DiscussionMovedNotifier.php b/extensions/tags/src/DiscussionMovedNotifier.php new file mode 100755 index 000000000..71adacb55 --- /dev/null +++ b/extensions/tags/src/DiscussionMovedNotifier.php @@ -0,0 +1,58 @@ +notifier = $notifier; + } + + /** + * Register the listeners for the subscriber. + * + * @param \Illuminate\Contracts\Events\Dispatcher $events + */ + public function subscribe(Dispatcher $events) + { + $events->listen('Flarum\Categories\Events\DiscussionWasMoved', __CLASS__.'@whenDiscussionWasMoved'); + } + + public function whenDiscussionWasMoved(DiscussionWasMoved $event) + { + $post = $this->createPost($event); + + $post = $event->discussion->addPost($post); + + if ($event->discussion->start_user_id !== $event->user->id) { + $this->sendNotification($event, $post); + } + } + + protected function createPost(DiscussionWasMoved $event) + { + return DiscussionMovedPost::reply( + $event->discussion->id, + $event->user->id, + $event->oldCategoryId, + $event->discussion->category_id + ); + } + + protected function sendNotification(DiscussionWasMoved $event, DiscussionMovedPost $post) + { + $notification = new DiscussionMovedNotification( + $event->discussion->startUser, + $event->user, + $post, + $event->discussion->category_id + ); + + $this->notifier->send($notification); + } +} diff --git a/extensions/tags/src/DiscussionMovedPost.php b/extensions/tags/src/DiscussionMovedPost.php index a5cd59639..a1287f3c0 100755 --- a/extensions/tags/src/DiscussionMovedPost.php +++ b/extensions/tags/src/DiscussionMovedPost.php @@ -1,49 +1,63 @@ content[0] == $this->content[1]) { + return false; + } + + $previous->content = static::buildContent($previous->content[0], $this->content[1]); + return true; + } + /** * Create a new instance in reply to a discussion. * - * @param int $discussionId - * @param int $userId - * @param string $oldTitle - * @param string $newTitle + * @param integer $discussionId + * @param integer $userId + * @param integer $oldCategoryId + * @param integer $newCategoryId * @return static */ public static function reply($discussionId, $userId, $oldCategoryId, $newCategoryId) { $post = new static; - $post->content = [$oldCategoryId, $newCategoryId]; + $post->content = static::buildContent($oldCategoryId, $newCategoryId); $post->time = time(); $post->discussion_id = $discussionId; $post->user_id = $userId; - $post->type = 'discussionMoved'; return $post; } /** - * Unserialize the content attribute. + * Build the content attribute. * - * @param string $value - * @return string + * @param boolean $oldCategoryId The old category ID. + * @param boolean $newCategoryId The new category ID. + * @return array */ - public function getContentAttribute($value) + public static function buildContent($oldCategoryId, $newCategoryId) { - return json_decode($value); - } - - /** - * Serialize the content attribute. - * - * @param string $value - */ - public function setContentAttribute($value) - { - $this->attributes['content'] = json_encode($value); + return [$oldCategoryId, $newCategoryId]; } } diff --git a/extensions/tags/src/EloquentCategoryRepository.php b/extensions/tags/src/EloquentCategoryRepository.php new file mode 100644 index 000000000..a04a2408e --- /dev/null +++ b/extensions/tags/src/EloquentCategoryRepository.php @@ -0,0 +1,52 @@ +scopeVisibleForUser($query, $user)->get(); + } + + /** + * Get the ID of a category with the given slug. + * + * @param string $slug + * @param \Flarum\Core\Models\User|null $user + * @return integer + */ + public function getIdForSlug($slug, User $user = null) + { + $query = Category::where('slug', 'like', $slug); + + return $this->scopeVisibleForUser($query, $user)->pluck('id'); + } + + /** + * Scope a query to only include records that are visible to a user. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Flarum\Core\Models\User $user + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function scopeVisibleForUser(Builder $query, User $user = null) + { + if ($user !== null) { + $query->whereCan($user, 'view'); + } + + return $query; + } +} diff --git a/extensions/tags/src/Events/DiscussionWasMoved.php b/extensions/tags/src/Events/DiscussionWasMoved.php new file mode 100644 index 000000000..6393accbe --- /dev/null +++ b/extensions/tags/src/Events/DiscussionWasMoved.php @@ -0,0 +1,34 @@ +discussion = $discussion; + $this->user = $user; + $this->oldCategoryId = $oldCategoryId; + } +} From 840cc84724b561fece42fb2a6c2ca2865e8962e1 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 5 May 2015 14:31:05 +0930 Subject: [PATCH 003/554] Refactor using new base ServiceProvider --- extensions/tags/src/CategoriesHandler.php | 66 ------------------- .../tags/src/CategoriesServiceProvider.php | 45 +++++++------ .../tags/src/Handlers/CategoryPreloader.php | 19 ++++++ .../tags/src/Handlers/CategorySaver.php | 32 +++++++++ .../DiscussionMovedNotifier.php | 4 +- 5 files changed, 80 insertions(+), 86 deletions(-) delete mode 100755 extensions/tags/src/CategoriesHandler.php create mode 100755 extensions/tags/src/Handlers/CategoryPreloader.php create mode 100755 extensions/tags/src/Handlers/CategorySaver.php rename extensions/tags/src/{ => Handlers}/DiscussionMovedNotifier.php (91%) diff --git a/extensions/tags/src/CategoriesHandler.php b/extensions/tags/src/CategoriesHandler.php deleted file mode 100755 index c21b49753..000000000 --- a/extensions/tags/src/CategoriesHandler.php +++ /dev/null @@ -1,66 +0,0 @@ -listen('Flarum\Forum\Events\RenderView', __CLASS__.'@renderForum'); - $events->listen('Flarum\Api\Events\SerializeRelationship', __CLASS__.'@serializeRelationship'); - $events->listen('Flarum\Core\Events\RegisterDiscussionGambits', __CLASS__.'@registerDiscussionGambits'); - $events->listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved'); - } - - public function renderForum(RenderView $event) - { - $root = __DIR__.'/..'; - - $event->assets->addFile([ - $root.'/js/dist/extension.js', - $root.'/less/categories.less' - ]); - - $serializer = new CategorySerializer($event->action->actor); - $event->view->data = array_merge($event->view->data, $serializer->collection(Category::orderBy('position')->get())->toArray()); - } - - public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event) - { - if (isset($event->command->data['links']['category']['linkage'])) { - $linkage = $event->command->data['links']['category']['linkage']; - - $categoryId = (int) $linkage['id']; - $discussion = $event->discussion; - $user = $event->command->user; - - $oldCategoryId = (int) $discussion->category_id; - - if ($oldCategoryId === $categoryId) { - return; - } - - $discussion->category_id = $categoryId; - $discussion->raise(new DiscussionWasMoved($discussion, $user, $oldCategoryId)); - } - } - - public function registerDiscussionGambits(RegisterDiscussionGambits $event) - { - $event->gambits->add('Flarum\Categories\CategoryGambit'); - } - - public function serializeRelationship(SerializeRelationship $event) - { - if ($event->serializer instanceof DiscussionSerializer && $event->name === 'category') { - return $event->serializer->hasOne('Flarum\Categories\CategorySerializer', 'category'); - } - } -} diff --git a/extensions/tags/src/CategoriesServiceProvider.php b/extensions/tags/src/CategoriesServiceProvider.php index 6ce82ce4a..79804e807 100644 --- a/extensions/tags/src/CategoriesServiceProvider.php +++ b/extensions/tags/src/CategoriesServiceProvider.php @@ -1,12 +1,9 @@ subscribe('Flarum\Categories\CategoriesHandler'); - $events->subscribe('Flarum\Categories\DiscussionMovedNotifier'); + $events->subscribe('Flarum\Categories\Handlers\DiscussionMovedNotifier'); + $events->subscribe('Flarum\Categories\Handlers\CategoryPreloader'); + $events->subscribe('Flarum\Categories\Handlers\CategorySaver'); + + $this->forumAssets([ + __DIR__.'/../js/dist/extension.js', + __DIR__.'/../less/categories.less' + ]); + + $this->postType('Flarum\Categories\DiscussionMovedPost'); + + $this->discussionGambit('Flarum\Categories\CategoryGambit'); + + $this->notificationType('Flarum\Categories\DiscussionMovedNotification', ['alert' => true]); + + $this->relationship('Flarum\Core\Models\Discussion', 'belongsTo', 'category', 'Flarum\Categories\Category'); + + $this->serializeRelationship('Flarum\Api\Serializers\DiscussionSerializer', 'hasOne', 'category', 'Flarum\Categories\CategorySerializer'); - // Add the category relationship to the Discussion model, and include - // it in discussion-related API actions by default. - Discussion::addRelationship('category', function ($model) { - return $model->belongsTo('Flarum\Categories\Category', null, null, 'category'); - }); DiscussionsIndexAction::$include['category'] = true; DiscussionsShowAction::$include['category'] = true; - - // Add a new post and notification type to represent a discussion - // being moved from one category to another. - Post::addType('Flarum\Categories\DiscussionMovedPost'); - - $notifier->registerType('Flarum\Categories\DiscussionMovedNotification', ['alert' => true]); } + /** + * Register the service provider. + * + * @return void + */ public function register() { $this->app->bind( diff --git a/extensions/tags/src/Handlers/CategoryPreloader.php b/extensions/tags/src/Handlers/CategoryPreloader.php new file mode 100755 index 000000000..a6ac6e9f8 --- /dev/null +++ b/extensions/tags/src/Handlers/CategoryPreloader.php @@ -0,0 +1,19 @@ +listen('Flarum\Forum\Events\RenderView', __CLASS__.'@renderForum'); + } + + public function renderForum(RenderView $event) + { + $serializer = new CategorySerializer($event->action->actor); + $event->view->data = array_merge($event->view->data, $serializer->collection(Category::orderBy('position')->get())->toArray()); + } +} diff --git a/extensions/tags/src/Handlers/CategorySaver.php b/extensions/tags/src/Handlers/CategorySaver.php new file mode 100755 index 000000000..33870cdc1 --- /dev/null +++ b/extensions/tags/src/Handlers/CategorySaver.php @@ -0,0 +1,32 @@ +listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved'); + } + + public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event) + { + if (isset($event->command->data['links']['category']['linkage'])) { + $linkage = $event->command->data['links']['category']['linkage']; + + $categoryId = (int) $linkage['id']; + $discussion = $event->discussion; + $user = $event->command->user; + + $oldCategoryId = (int) $discussion->category_id; + + if ($oldCategoryId === $categoryId) { + return; + } + + $discussion->category_id = $categoryId; + $discussion->raise(new DiscussionWasMoved($discussion, $user, $oldCategoryId)); + } + } +} diff --git a/extensions/tags/src/DiscussionMovedNotifier.php b/extensions/tags/src/Handlers/DiscussionMovedNotifier.php similarity index 91% rename from extensions/tags/src/DiscussionMovedNotifier.php rename to extensions/tags/src/Handlers/DiscussionMovedNotifier.php index 71adacb55..c50ba89fa 100755 --- a/extensions/tags/src/DiscussionMovedNotifier.php +++ b/extensions/tags/src/Handlers/DiscussionMovedNotifier.php @@ -1,5 +1,7 @@ - Date: Tue, 5 May 2015 17:07:36 +0930 Subject: [PATCH 004/554] Return the provider so core can store a reference to it --- extensions/tags/bootstrap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/tags/bootstrap.php b/extensions/tags/bootstrap.php index 19039db79..72ad85c3d 100644 --- a/extensions/tags/bootstrap.php +++ b/extensions/tags/bootstrap.php @@ -6,4 +6,4 @@ require 'vendor/autoload.php'; // Register our service provider with the Flarum application. In here we can // register bindings and execute code when the application boots. -$app->register('Flarum\Categories\CategoriesServiceProvider'); +return $app->register('Flarum\Categories\CategoriesServiceProvider'); From 14dbd5381ce23d5dbf48c69fec5ca48d8aec3f20 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 5 May 2015 17:07:58 +0930 Subject: [PATCH 005/554] JS cleanup/refactor --- extensions/tags/js/bootstrap.js | 254 +++++++----------- .../tags/js/src/components/category-hero.js | 16 ++ .../js/src/components/category-nav-item.js | 22 ++ .../src/components/move-discussion-modal.js | 56 ++++ .../notification-discussion-moved.js | 3 +- .../src/components/post-discussion-moved.js | 60 +---- .../tags/js/src/helpers/category-icon.js | 12 + .../tags/js/src/helpers/category-label.js | 3 + .../tags/js/src/{ => models}/category.js | 0 extensions/tags/less/categories.less | 2 +- 10 files changed, 220 insertions(+), 208 deletions(-) create mode 100644 extensions/tags/js/src/components/category-hero.js create mode 100644 extensions/tags/js/src/components/category-nav-item.js create mode 100644 extensions/tags/js/src/components/move-discussion-modal.js create mode 100644 extensions/tags/js/src/helpers/category-icon.js create mode 100644 extensions/tags/js/src/helpers/category-label.js rename extensions/tags/js/src/{ => models}/category.js (100%) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 7eeeb273e..69c47a426 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -1,69 +1,59 @@ import { extend, override } from 'flarum/extension-utils'; import Model from 'flarum/model'; -import Component from 'flarum/component'; import Discussion from 'flarum/models/discussion'; import IndexPage from 'flarum/components/index-page'; import DiscussionPage from 'flarum/components/discussion-page'; import DiscussionList from 'flarum/components/discussion-list'; import DiscussionHero from 'flarum/components/discussion-hero'; import Separator from 'flarum/components/separator'; -import NavItem from 'flarum/components/nav-item'; import ActionButton from 'flarum/components/action-button'; +import NavItem from 'flarum/components/nav-item'; import ComposerDiscussion from 'flarum/components/composer-discussion'; import ActivityPost from 'flarum/components/activity-post'; import icon from 'flarum/helpers/icon'; - -import CategoriesPage from 'categories/components/categories-page'; -import Category from 'categories/category'; -import PostDiscussionMoved from 'categories/components/post-discussion-moved'; -import NotificationDiscussionMoved from 'categories/components/notification-discussion-moved'; - import app from 'flarum/app'; -Discussion.prototype.category = Model.one('category'); +import Category from 'categories/models/category'; +import CategoriesPage from 'categories/components/categories-page'; +import CategoryHero from 'categories/components/category-hero'; +import CategoryNavItem from 'categories/components/category-nav-item'; +import MoveDiscussionModal from 'categories/components/move-discussion-modal'; +import NotificationDiscussionMoved from 'categories/components/notification-discussion-moved'; +import PostDiscussionMoved from 'categories/components/post-discussion-moved'; +import categoryLabel from 'categories/helpers/category-label'; +import categoryIcon from 'categories/helpers/category-icon'; app.initializers.add('categories', function() { + + // Register routes. app.routes['categories'] = ['/categories', CategoriesPage.component()]; - - app.routes['category'] = ['/c/:categories', IndexPage.component({category: true})]; - + app.routes['category'] = ['/c/:categories', IndexPage.component()]; // @todo support combination with filters // app.routes['category.filter'] = ['/c/:slug/:filter', IndexPage.component({category: true})]; + // Register models. + app.store.models['categories'] = Category; + Discussion.prototype.category = Model.one('category'); + + // Register components. app.postComponentRegistry['discussionMoved'] = PostDiscussionMoved; app.notificationComponentRegistry['discussionMoved'] = NotificationDiscussionMoved; - app.store.model('categories', Category); + // --------------------------------------------------------------------------- + // INDEX PAGE + // --------------------------------------------------------------------------- + + // Add a category label to each discussion in the discussion list. extend(DiscussionList.prototype, 'infoItems', function(items, discussion) { var category = discussion.category(); if (category && category.slug() !== this.props.params.categories) { - items.add('category', m('span.category', {style: 'color: '+category.color()}, category.title()), {first: true}); + items.add('category', categoryLabel(category), {first: true}); } - - return items; }); - class CategoryNavItem extends NavItem { - view() { - var category = this.props.category; - var active = this.constructor.active(this.props); - return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route, onclick: () => {app.cache.discussionList = null; m.redraw.strategy('none')}, style: active ? 'color: '+category.color() : ''}, [ - m('span.icon.category-icon', {style: 'background-color: '+category.color()}), - category.title() - ])); - } - - static props(props) { - var category = props.category; - props.params.categories = category.slug(); - props.href = app.route('category', props.params); - props.label = category.title(); - - return props; - } - } - + // Add a link to the categories page, as well as a list of all the categories, + // to the index page's sidebar. extend(IndexPage.prototype, 'navItems', function(items) { items.add('categories', NavItem.component({ icon: 'reorder', @@ -77,200 +67,156 @@ app.initializers.add('categories', function() { app.store.all('categories').forEach(category => { items.add('category'+category.id(), CategoryNavItem.component({category, params: this.stickyParams()}), {last: true}); }); - - return items; }); - extend(IndexPage.prototype, 'params', function(params) { - params.categories = this.props.category ? m.route.param('categories') : undefined; - return params; - }); - - class CategoryHero extends Component { - view() { - var category = this.props.category; - - return m('header.hero.category-hero', {style: 'background-color: '+category.color()}, [ - m('div.container', [ - m('div.container-narrow', [ - m('h2', category.title()), - m('div.subtitle', category.description()) - ]) - ]) - ]); + IndexPage.prototype.currentCategory = function() { + var slug = this.params().categories; + if (slug) { + return app.store.getBy('categories', 'slug', slug); } - } + }; + // If currently viewing a category, insert a category hero at the top of the + // view. extend(IndexPage.prototype, 'view', function(view) { - if (this.props.category) { - var slug = this.params().categories; - var category = app.store.all('categories').filter(category => category.slug() == slug)[0]; + var category = this.currentCategory(); + if (category) { view.children[0] = CategoryHero.component({category}); } - return view; }); + // If currently viewing a category, restyle the 'new discussion' button to use + // the category's color. extend(IndexPage.prototype, 'sidebarItems', function(items) { - var slug = this.params().categories; - var category = app.store.all('categories').filter(category => category.slug() == slug)[0]; + var category = this.currentCategory(); if (category) { items.newDiscussion.content.props.style = 'background-color: '+category.color(); } - return items; }); + // Add a parameter for the IndexPage to pass on to the DiscussionList that + // will let us filter discussions by category. + extend(IndexPage.prototype, 'params', function(params) { + params.categories = m.route.param('categories'); + }); + + // Translate that parameter into a gambit appended to the search query. extend(DiscussionList.prototype, 'params', function(params) { if (params.categories) { params.q = (params.q || '')+' category:'+params.categories; delete params.categories; } - return params; }); + // --------------------------------------------------------------------------- + // DISCUSSION PAGE + // --------------------------------------------------------------------------- + + // Include a discussion's category when fetching it. extend(DiscussionPage.prototype, 'params', function(params) { params.include += ',category'; - return params; }); + // Restyle a discussion's hero to use its category color. extend(DiscussionHero.prototype, 'view', function(view) { var category = this.props.discussion.category(); if (category) { view.attrs.style = 'background-color: '+category.color(); } - return view; }); + // Add the name of a discussion's category to the discussion hero, displayed + // before the title. Put the title on its own line. extend(DiscussionHero.prototype, 'items', function(items) { var category = this.props.discussion.category(); if (category) { - items.add('category', m('span.category', category.title()), {before: 'title'}); + items.add('category', m('span.category-label', category.title()), {before: 'title'}); items.title.content.wrapperClass = 'block-item'; } - return items; }); - class MoveDiscussionModal extends Component { - constructor(props) { - super(props); - - this.categories = m.prop(app.store.all('categories')); - } - - view() { - var discussion = this.props.discussion; - - return m('div.modal-dialog.modal-move-discussion', [ - m('div.modal-content', [ - m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')), - m('div.modal-header', m('h3.title-control', discussion - ? ['Move ', m('em', discussion.title()), ' from ', m('span.category', {style: 'color: '+discussion.category().color()}, discussion.category().title()), ' to...'] - : ['Start a Discussion In...'])), - m('div', [ - m('ul.category-list', [ - this.categories().map(category => - (discussion && category.id() === discussion.category().id()) ? '' : m('li.category-tile', {style: 'background-color: '+category.color()}, [ - m('a[href=javascript:;]', {onclick: this.save.bind(this, category)}, [ - m('h3.title', category.title()), - m('p.description', category.description()), - m('span.count', category.discussionsCount()+' discussions'), - ]) - ]) - ) - ]) - ]) - ]) - ]); - } - - save(category) { - var discussion = this.props.discussion; - - if (discussion) { - discussion.save({links: {category}}).then(discussion => { - if (app.current instanceof DiscussionPage) { - app.current.stream().sync(); - } - m.redraw(); - }); - } - - this.props.onchange && this.props.onchange(category); - - app.modal.close(); - - m.redraw.strategy('none'); - } - } - - function move() { - app.modal.show(new MoveDiscussionModal({discussion: this})); - } - + // Add a control allowing the discussion to be moved to another category. extend(Discussion.prototype, 'controls', function(items) { if (this.canEdit()) { items.add('move', ActionButton.component({ label: 'Move', icon: 'arrow-right', - onclick: move.bind(this) + onclick: () => app.modal.show(new MoveDiscussionModal({discussion: this})) }), {after: 'rename'}); } - - return items; }); - override(IndexPage.prototype, 'newDiscussion', function(parent) { - var categories = app.store.all('categories'); + // --------------------------------------------------------------------------- + // COMPOSER + // --------------------------------------------------------------------------- + + // When the 'new discussion' button is clicked... + override(IndexPage.prototype, 'newDiscussion', function(original) { var slug = this.params().categories; - var category; + + // If we're currently viewing a specific category, or if the user isn't + // logged in, then we'll let the core code proceed. If that results in the + // composer appearing, we'll set the composer's current category to the one + // we're viewing. if (slug || !app.session.user()) { - parent(); - if (app.composer.component) { - category = categories.filter(category => category.slug() == slug)[0]; + if (original()) { + var category = app.store.getBy('categories', 'slug', slug); app.composer.component.category(category); } } else { - var modal = new MoveDiscussionModal({onchange: category => { - parent(); - app.composer.component.category(category); - }}); + // If we're logged in and we're viewing All Discussions, we'll present the + // user with a category selection dialog before proceeding to show the + // composer. + var modal = new MoveDiscussionModal({ + onchange: category => { + original(); + app.composer.component.category(category); + } + }); app.modal.show(modal); } }); + // Add category-selection abilities to the discussion composer. + ComposerDiscussion.prototype.category = m.prop(); ComposerDiscussion.prototype.chooseCategory = function() { - var modal = new MoveDiscussionModal({onchange: category => { - this.category(category); - this.$('textarea').focus(); - }}); + var modal = new MoveDiscussionModal({ + onchange: category => { + this.category(category); + this.$('textarea').focus(); + } + }); app.modal.show(modal); }; - ComposerDiscussion.prototype.category = m.prop(); + // Add a category-selection menu to the discussion composer's header, after + // the title. extend(ComposerDiscussion.prototype, 'headerItems', function(items) { var category = this.category(); - items.add('category', m('a[href=javascript:;][tabindex=-1].btn.btn-link.control-change-category', { - onclick: this.chooseCategory.bind(this) - }, [ - category ? m('span.category-icon', {style: 'background-color: '+category.color()}) : '', ' ', + items.add('category', m('a[href=javascript:;][tabindex=-1].btn.btn-link.control-change-category', {onclick: this.chooseCategory.bind(this)}, [ + categoryIcon(category), ' ', m('span.label', category ? category.title() : 'Uncategorized'), icon('sort') ])); - - return items; }); + // Add the selected category as data to submit to the server. extend(ComposerDiscussion.prototype, 'data', function(data) { data.links = data.links || {}; data.links.category = this.category(); - return data; - }) + }); + // --------------------------------------------------------------------------- + // ACTIVITY PAGE + // --------------------------------------------------------------------------- + + // Add a category label next to the discussion title in post activity items. extend(ActivityPost.prototype, 'headerItems', function(items) { var category = this.props.activity.post().discussion().category(); if (category) { - items.add('category', m('span.category', {style: {color: category.color()}}, category.title())); + items.add('category', categoryLabel(category)); } - return items; - }) + }); + }); diff --git a/extensions/tags/js/src/components/category-hero.js b/extensions/tags/js/src/components/category-hero.js new file mode 100644 index 000000000..babe90069 --- /dev/null +++ b/extensions/tags/js/src/components/category-hero.js @@ -0,0 +1,16 @@ +import Component from 'flarum/component'; + +export default class CategoryHero extends Component { + view() { + var category = this.props.category; + + return m('header.hero.category-hero', {style: 'background-color: '+category.color()}, [ + m('div.container', [ + m('div.container-narrow', [ + m('h2', category.title()), + m('div.subtitle', category.description()) + ]) + ]) + ]); + } +} diff --git a/extensions/tags/js/src/components/category-nav-item.js b/extensions/tags/js/src/components/category-nav-item.js new file mode 100644 index 000000000..a0806fdad --- /dev/null +++ b/extensions/tags/js/src/components/category-nav-item.js @@ -0,0 +1,22 @@ +import NavItem from 'flarum/components/nav-item'; +import categoryIcon from 'categories/helpers/category-icon'; + +export default class CategoryNavItem extends NavItem { + view() { + var category = this.props.category; + var active = this.constructor.active(this.props); + return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route, onclick: () => {app.cache.discussionList = null; m.redraw.strategy('none')}, style: active ? 'color: '+category.color() : ''}, [ + categoryIcon(category, {className: 'icon'}), + category.title() + ])); + } + + static props(props) { + var category = props.category; + props.params.categories = category.slug(); + props.href = app.route('category', props.params); + props.label = category.title(); + + return props; + } +} diff --git a/extensions/tags/js/src/components/move-discussion-modal.js b/extensions/tags/js/src/components/move-discussion-modal.js new file mode 100644 index 000000000..54d4456ee --- /dev/null +++ b/extensions/tags/js/src/components/move-discussion-modal.js @@ -0,0 +1,56 @@ +import Component from 'flarum/component'; +import DiscussionPage from 'flarum/components/discussion-page'; +import icon from 'flarum/helpers/icon'; + +export default class MoveDiscussionModal extends Component { + constructor(props) { + super(props); + + this.categories = m.prop(app.store.all('categories')); + } + + view() { + var discussion = this.props.discussion; + + return m('div.modal-dialog.modal-move-discussion', [ + m('div.modal-content', [ + m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')), + m('div.modal-header', m('h3.title-control', discussion + ? ['Move ', m('em', discussion.title()), ' from ', m('span.category', {style: 'color: '+discussion.category().color()}, discussion.category().title()), ' to...'] + : ['Start a Discussion In...'])), + m('div', [ + m('ul.category-list', [ + this.categories().map(category => + (discussion && category.id() === discussion.category().id()) ? '' : m('li.category-tile', {style: 'background-color: '+category.color()}, [ + m('a[href=javascript:;]', {onclick: this.save.bind(this, category)}, [ + m('h3.title', category.title()), + m('p.description', category.description()), + m('span.count', category.discussionsCount()+' discussions'), + ]) + ]) + ) + ]) + ]) + ]) + ]); + } + + save(category) { + var discussion = this.props.discussion; + + if (discussion) { + discussion.save({links: {category}}).then(discussion => { + if (app.current instanceof DiscussionPage) { + app.current.stream().sync(); + } + m.redraw(); + }); + } + + this.props.onchange && this.props.onchange(category); + + app.modal.close(); + + m.redraw.strategy('none'); + } +} diff --git a/extensions/tags/js/src/components/notification-discussion-moved.js b/extensions/tags/js/src/components/notification-discussion-moved.js index f3ef00481..9e870f17c 100644 --- a/extensions/tags/js/src/components/notification-discussion-moved.js +++ b/extensions/tags/js/src/components/notification-discussion-moved.js @@ -3,6 +3,7 @@ import avatar from 'flarum/helpers/avatar'; import icon from 'flarum/helpers/icon'; import username from 'flarum/helpers/username'; import humanTime from 'flarum/helpers/human-time'; +import categoryLabel from 'categories/helpers/category-label'; export default class NotificationDiscussionMoved extends Notification { content() { @@ -19,7 +20,7 @@ export default class NotificationDiscussionMoved extends Notification { m('h3.notification-title', discussion.title()), m('div.notification-info', [ icon('arrow-right'), - ' Moved to ', m('span.category', {style: 'color: '+category.color()}, category.title()), ' by ', username(notification.sender()), + ' Moved to ', categoryLabel(category), ' by ', username(notification.sender()), ' ', humanTime(notification.time()) ]) ]); diff --git a/extensions/tags/js/src/components/post-discussion-moved.js b/extensions/tags/js/src/components/post-discussion-moved.js index 1168b42a8..3dd465829 100644 --- a/extensions/tags/js/src/components/post-discussion-moved.js +++ b/extensions/tags/js/src/components/post-discussion-moved.js @@ -1,59 +1,15 @@ -import Component from 'flarum/component'; -import icon from 'flarum/helpers/icon'; -import username from 'flarum/helpers/username'; -import humanTime from 'flarum/utils/human-time'; -import SubtreeRetainer from 'flarum/utils/subtree-retainer'; -import ItemList from 'flarum/utils/item-list'; -import ActionButton from 'flarum/components/action-button'; -import DropdownButton from 'flarum/components/dropdown-button'; - -export default class PostDiscussionMoved extends Component { - constructor(props) { - super(props); - - this.subtree = new SubtreeRetainer( - () => this.props.post.freshness, - () => this.props.post.user().freshness - ); - } - - view(ctrl) { - var controls = this.controlItems().toArray(); +import PostActivity from 'flarum/components/post-activity'; +import categoryLabel from 'categories/helpers/category-label'; +export default class PostDiscussionMoved extends PostActivity { + view() { var post = this.props.post; var oldCategory = app.store.getById('categories', post.content()[0]); var newCategory = app.store.getById('categories', post.content()[1]); - return m('article.post.post-activity.post-discussion-moved', this.subtree.retain() || m('div', [ - controls.length ? DropdownButton.component({ - items: controls, - className: 'contextual-controls', - buttonClass: 'btn btn-default btn-icon btn-sm btn-naked', - menuClass: 'pull-right' - }) : '', - icon('arrow-right post-icon'), - m('div.post-activity-info', [ - m('a.post-user', {href: app.route('user', {username: post.user().username()}), config: m.route}, username(post.user())), - ' moved the discussion from ', m('span.category', {style: {color: oldCategory.color()}}, oldCategory.title()), ' to ', m('span.category', {style: {color: newCategory.color()}}, newCategory.title()), '.' - ]), - m('div.post-activity-time', humanTime(post.time())) - ])); - } - - controlItems() { - var items = new ItemList(); - var post = this.props.post; - - if (post.canDelete()) { - items.add('delete', ActionButton.component({ icon: 'times', label: 'Delete', onclick: this.delete.bind(this) })); - } - - return items; - } - - delete() { - var post = this.props.post; - post.delete(); - this.props.ondelete && this.props.ondelete(post); + return super.view(['moved the discussion from ', categoryLabel(oldCategory), ' to ', categoryLabel(newCategory), '.'], { + className: 'post-discussion-moved', + icon: 'arrow-right' + }); } } diff --git a/extensions/tags/js/src/helpers/category-icon.js b/extensions/tags/js/src/helpers/category-icon.js new file mode 100644 index 000000000..2cefb14fc --- /dev/null +++ b/extensions/tags/js/src/helpers/category-icon.js @@ -0,0 +1,12 @@ +export default function categoryIcon(category, attrs) { + attrs = attrs || {}; + + if (category) { + attrs.style = attrs.style || {}; + attrs.style.backgroundColor = category.color(); + } else { + attrs.className = (attrs.className || '')+' uncategorized'; + } + + return m('span.icon.category-icon', attrs); +} diff --git a/extensions/tags/js/src/helpers/category-label.js b/extensions/tags/js/src/helpers/category-label.js new file mode 100644 index 000000000..8a39188d2 --- /dev/null +++ b/extensions/tags/js/src/helpers/category-label.js @@ -0,0 +1,3 @@ +export default function categoryLabel(category) { + return m('span.category-label', {style: {color: category.color()}}, category.title()); +} diff --git a/extensions/tags/js/src/category.js b/extensions/tags/js/src/models/category.js similarity index 100% rename from extensions/tags/js/src/category.js rename to extensions/tags/js/src/models/category.js diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less index 5b1b9aeee..70331163b 100644 --- a/extensions/tags/less/categories.less +++ b/extensions/tags/less/categories.less @@ -1,4 +1,4 @@ -.category { +.category-label { text-transform: uppercase; font-size: 80%; font-weight: bold; From 11804b3306eee5dd5363801161aee9ad154ef233 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 5 May 2015 17:08:11 +0930 Subject: [PATCH 006/554] Only say a discussion was moved if it already exists --- extensions/tags/src/Handlers/CategorySaver.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/tags/src/Handlers/CategorySaver.php b/extensions/tags/src/Handlers/CategorySaver.php index 33870cdc1..8074da9ed 100755 --- a/extensions/tags/src/Handlers/CategorySaver.php +++ b/extensions/tags/src/Handlers/CategorySaver.php @@ -26,7 +26,10 @@ class CategorySaver } $discussion->category_id = $categoryId; - $discussion->raise(new DiscussionWasMoved($discussion, $user, $oldCategoryId)); + + if ($discussion->exists) { + $discussion->raise(new DiscussionWasMoved($discussion, $user, $oldCategoryId)); + } } } } From 7ec714af82f2bcaa310073547ec992a934b53bcc Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 5 May 2015 17:31:42 +0930 Subject: [PATCH 007/554] Update post/notification components --- .../notification-discussion-moved.js | 29 ++++++++----------- .../src/components/post-discussion-moved.js | 5 +--- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/extensions/tags/js/src/components/notification-discussion-moved.js b/extensions/tags/js/src/components/notification-discussion-moved.js index 9e870f17c..de61cea58 100644 --- a/extensions/tags/js/src/components/notification-discussion-moved.js +++ b/extensions/tags/js/src/components/notification-discussion-moved.js @@ -1,28 +1,23 @@ import Notification from 'flarum/components/notification'; -import avatar from 'flarum/helpers/avatar'; -import icon from 'flarum/helpers/icon'; import username from 'flarum/helpers/username'; -import humanTime from 'flarum/helpers/human-time'; import categoryLabel from 'categories/helpers/category-label'; export default class NotificationDiscussionMoved extends Notification { - content() { + view() { var notification = this.props.notification; var discussion = notification.subject(); var category = discussion.category(); - return m('a', {href: app.route('discussion.near', { - id: discussion.id(), - slug: discussion.slug(), - near: notification.content().postNumber - }), config: m.route}, [ - avatar(notification.sender()), - m('h3.notification-title', discussion.title()), - m('div.notification-info', [ - icon('arrow-right'), - ' Moved to ', categoryLabel(category), ' by ', username(notification.sender()), - ' ', humanTime(notification.time()) - ]) - ]); + return super.view({ + href: app.route('discussion.near', { + id: discussion.id(), + slug: discussion.slug(), + near: notification.content().postNumber + }), + config: m.route, + title: discussion.title(), + icon: 'arrow-right', + content: ['Moved to ', categoryLabel(category), ' by ', username(notification.sender())] + }); } } diff --git a/extensions/tags/js/src/components/post-discussion-moved.js b/extensions/tags/js/src/components/post-discussion-moved.js index 3dd465829..de0b3380b 100644 --- a/extensions/tags/js/src/components/post-discussion-moved.js +++ b/extensions/tags/js/src/components/post-discussion-moved.js @@ -7,9 +7,6 @@ export default class PostDiscussionMoved extends PostActivity { var oldCategory = app.store.getById('categories', post.content()[0]); var newCategory = app.store.getById('categories', post.content()[1]); - return super.view(['moved the discussion from ', categoryLabel(oldCategory), ' to ', categoryLabel(newCategory), '.'], { - className: 'post-discussion-moved', - icon: 'arrow-right' - }); + return super.view('arrow-right', ['moved the discussion from ', categoryLabel(oldCategory), ' to ', categoryLabel(newCategory), '.']); } } From bf8766251164b4619b862e21e58fac6e215c9584 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 6 May 2015 08:29:49 +0930 Subject: [PATCH 008/554] Add a notification preference --- extensions/tags/js/bootstrap.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 69c47a426..5b1c70d66 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -9,6 +9,7 @@ import Separator from 'flarum/components/separator'; import ActionButton from 'flarum/components/action-button'; import NavItem from 'flarum/components/nav-item'; import ComposerDiscussion from 'flarum/components/composer-discussion'; +import SettingsPage from 'flarum/components/settings-page'; import ActivityPost from 'flarum/components/activity-post'; import icon from 'flarum/helpers/icon'; import app from 'flarum/app'; @@ -208,7 +209,7 @@ app.initializers.add('categories', function() { }); // --------------------------------------------------------------------------- - // ACTIVITY PAGE + // USER PROFILE // --------------------------------------------------------------------------- // Add a category label next to the discussion title in post activity items. @@ -219,4 +220,10 @@ app.initializers.add('categories', function() { } }); + extend(SettingsPage.prototype, 'notificationTypes', function(items) { + items.add('discussionMoved', { + name: 'discussionMoved', + label: [icon('arrow-right'), ' Someone moves a discussion I started'] + }); + }); }); From 97dd5bb7955835c195e8cf8981ff8601386e5d1d Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 6 May 2015 11:21:23 +0930 Subject: [PATCH 009/554] Add uncategorized filter, enable gambit to parse multiple categories --- extensions/tags/js/bootstrap.js | 2 ++ .../tags/js/src/components/category-nav-item.js | 8 ++++---- extensions/tags/less/categories.less | 4 ++++ ...5_02_24_000000_add_category_to_discussions.php | 2 +- extensions/tags/src/CategoryGambit.php | 15 +++++++++++---- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 5b1c70d66..403fca38d 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -65,6 +65,8 @@ app.initializers.add('categories', function() { items.add('separator', Separator.component(), {last: true}); + items.add('uncategorized', CategoryNavItem.component({params: this.stickyParams()}), {last: true}); + app.store.all('categories').forEach(category => { items.add('category'+category.id(), CategoryNavItem.component({category, params: this.stickyParams()}), {last: true}); }); diff --git a/extensions/tags/js/src/components/category-nav-item.js b/extensions/tags/js/src/components/category-nav-item.js index a0806fdad..ce1911468 100644 --- a/extensions/tags/js/src/components/category-nav-item.js +++ b/extensions/tags/js/src/components/category-nav-item.js @@ -5,17 +5,17 @@ export default class CategoryNavItem extends NavItem { view() { var category = this.props.category; var active = this.constructor.active(this.props); - return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route, onclick: () => {app.cache.discussionList = null; m.redraw.strategy('none')}, style: active ? 'color: '+category.color() : ''}, [ + return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route, onclick: () => {app.cache.discussionList = null; m.redraw.strategy('none')}, style: (active && category) ? 'color: '+category.color() : '', title: category ? category.description() : ''}, [ categoryIcon(category, {className: 'icon'}), - category.title() + this.props.label ])); } static props(props) { var category = props.category; - props.params.categories = category.slug(); + props.params.categories = category ? category.slug() : 'uncategorized'; props.href = app.route('category', props.params); - props.label = category.title(); + props.label = category ? category.title() : 'Uncategorized'; return props; } diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less index 70331163b..8b5fe5dde 100644 --- a/extensions/tags/less/categories.less +++ b/extensions/tags/less/categories.less @@ -30,6 +30,10 @@ display: inline-block; vertical-align: -3px; margin-left: 1px; + + &.uncategorized { + border: 1px dotted @fl-body-muted-color; + } } .categories-area .container { diff --git a/extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php b/extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php index 68c1b285a..9d4753334 100644 --- a/extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php +++ b/extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php @@ -13,7 +13,7 @@ class AddCategoryToDiscussions extends Migration public function up() { Schema::table('discussions', function (Blueprint $table) { - $table->integer('category_id')->unsigned(); + $table->integer('category_id')->unsigned()->nullable(); }); } diff --git a/extensions/tags/src/CategoryGambit.php b/extensions/tags/src/CategoryGambit.php index 38b75ddcf..eb5e2bfc3 100644 --- a/extensions/tags/src/CategoryGambit.php +++ b/extensions/tags/src/CategoryGambit.php @@ -38,10 +38,17 @@ class CategoryGambit extends GambitAbstract */ public function conditions($matches, SearcherInterface $searcher) { - $slug = trim($matches[1], '"'); + $slugs = explode(',', trim($matches[1], '"')); - $id = $this->categories->getIdForSlug($slug); - - $searcher->query()->where('category_id', $id); + $searcher->query()->where(function ($query) use ($slugs) { + foreach ($slugs as $slug) { + if ($slug === 'uncategorized') { + $query->orWhereNull('category_id'); + } else { + $id = $this->categories->getIdForSlug($slug); + $query->orWhere('category_id', $id); + } + } + }); } } From d1b406812196e648a274fb2b5580e360b4fee58b Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 6 May 2015 11:21:26 +0930 Subject: [PATCH 010/554] Cleanup --- extensions/tags/js/bootstrap.js | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 403fca38d..2a6c7cd3b 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -222,6 +222,7 @@ app.initializers.add('categories', function() { } }); + // Add a notification preference. extend(SettingsPage.prototype, 'notificationTypes', function(items) { items.add('discussionMoved', { name: 'discussionMoved', From a38efe6e9053fd36e39101506213f65b09ef6f91 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 6 May 2015 12:13:40 +0930 Subject: [PATCH 011/554] Use absolute autoload path --- extensions/tags/bootstrap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/tags/bootstrap.php b/extensions/tags/bootstrap.php index 72ad85c3d..51ea032cf 100644 --- a/extensions/tags/bootstrap.php +++ b/extensions/tags/bootstrap.php @@ -2,7 +2,7 @@ // Require the extension's composer autoload file. This will enable all of our // classes in the src directory to be autoloaded. -require 'vendor/autoload.php'; +require __DIR__.'/vendor/autoload.php'; // Register our service provider with the Flarum application. In here we can // register bindings and execute code when the application boots. From 4bf5f91c0633ee3b197bae03913cf27d9c420b4f Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 7 May 2015 06:39:05 +0930 Subject: [PATCH 012/554] Change category label appearance --- extensions/tags/js/src/components/move-discussion-modal.js | 3 ++- extensions/tags/js/src/helpers/category-label.js | 2 +- extensions/tags/less/categories.less | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/extensions/tags/js/src/components/move-discussion-modal.js b/extensions/tags/js/src/components/move-discussion-modal.js index 54d4456ee..f83ff5336 100644 --- a/extensions/tags/js/src/components/move-discussion-modal.js +++ b/extensions/tags/js/src/components/move-discussion-modal.js @@ -1,6 +1,7 @@ import Component from 'flarum/component'; import DiscussionPage from 'flarum/components/discussion-page'; import icon from 'flarum/helpers/icon'; +import categoryLabel from 'categories/helpers/category-label'; export default class MoveDiscussionModal extends Component { constructor(props) { @@ -16,7 +17,7 @@ export default class MoveDiscussionModal extends Component { m('div.modal-content', [ m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')), m('div.modal-header', m('h3.title-control', discussion - ? ['Move ', m('em', discussion.title()), ' from ', m('span.category', {style: 'color: '+discussion.category().color()}, discussion.category().title()), ' to...'] + ? ['Move ', m('em', discussion.title()), ' from ', categoryLabel(discussion.category()), ' to...'] : ['Start a Discussion In...'])), m('div', [ m('ul.category-list', [ diff --git a/extensions/tags/js/src/helpers/category-label.js b/extensions/tags/js/src/helpers/category-label.js index 8a39188d2..73f8ad398 100644 --- a/extensions/tags/js/src/helpers/category-label.js +++ b/extensions/tags/js/src/helpers/category-label.js @@ -1,3 +1,3 @@ export default function categoryLabel(category) { - return m('span.category-label', {style: {color: category.color()}}, category.title()); + return m('span.category-label', {style: {backgroundColor: category.color()}}, category.title()); } diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less index 8b5fe5dde..9a40a5d02 100644 --- a/extensions/tags/less/categories.less +++ b/extensions/tags/less/categories.less @@ -1,11 +1,14 @@ .category-label { text-transform: uppercase; font-size: 80%; - font-weight: bold; + font-weight: 600; display: inline-block; + color: #fff; + padding: 1px 5px; + border-radius: 4px; .discussion-summary & { - margin-right: 10px; + margin-right: 5px; font-size: 11px; } From 2fa694fe3df8093123c5e4c816d805a98ab1ccd3 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 7 May 2015 06:39:20 +0930 Subject: [PATCH 013/554] Only merge posts if same user --- extensions/tags/src/DiscussionMovedPost.php | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/extensions/tags/src/DiscussionMovedPost.php b/extensions/tags/src/DiscussionMovedPost.php index a1287f3c0..e57a4914c 100755 --- a/extensions/tags/src/DiscussionMovedPost.php +++ b/extensions/tags/src/DiscussionMovedPost.php @@ -16,16 +16,21 @@ class DiscussionMovedPost extends ActivityPost * Merge the post into another post of the same type. * * @param \Flarum\Core\Models\Model $previous - * @return boolean true if the post was merged, false if it was deleted. + * @return \Flarum\Core\Models\Model|null The final model, or null if the + * previous post was deleted. */ protected function mergeInto(Model $previous) { - if ($previous->content[0] == $this->content[1]) { - return false; + if ($this->user_id === $previous->user_id) { + if ($previous->content[0] == $this->content[1]) { + return; + } + + $previous->content = static::buildContent($previous->content[0], $this->content[1]); + return $previous; } - $previous->content = static::buildContent($previous->content[0], $this->content[1]); - return true; + return $this; } /** From 9661aacdee83069dc3e24ca692eac31ea0c012c4 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 7 May 2015 09:20:19 +0930 Subject: [PATCH 014/554] Fix moving discussions from uncategorized --- .../tags/js/src/components/move-discussion-modal.js | 5 +++-- extensions/tags/js/src/helpers/category-label.js | 13 +++++++++++-- extensions/tags/less/categories.less | 5 +++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/extensions/tags/js/src/components/move-discussion-modal.js b/extensions/tags/js/src/components/move-discussion-modal.js index f83ff5336..96f35ef95 100644 --- a/extensions/tags/js/src/components/move-discussion-modal.js +++ b/extensions/tags/js/src/components/move-discussion-modal.js @@ -12,17 +12,18 @@ export default class MoveDiscussionModal extends Component { view() { var discussion = this.props.discussion; + var discussionCategory = discussion && discussion.category(); return m('div.modal-dialog.modal-move-discussion', [ m('div.modal-content', [ m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')), m('div.modal-header', m('h3.title-control', discussion - ? ['Move ', m('em', discussion.title()), ' from ', categoryLabel(discussion.category()), ' to...'] + ? ['Move ', m('em', discussion.title()), ' from ', categoryLabel(discussionCategory), ' to...'] : ['Start a Discussion In...'])), m('div', [ m('ul.category-list', [ this.categories().map(category => - (discussion && category.id() === discussion.category().id()) ? '' : m('li.category-tile', {style: 'background-color: '+category.color()}, [ + (discussion && discussionCategory && category.id() === discussionCategory.id()) ? '' : m('li.category-tile', {style: 'background-color: '+category.color()}, [ m('a[href=javascript:;]', {onclick: this.save.bind(this, category)}, [ m('h3.title', category.title()), m('p.description', category.description()), diff --git a/extensions/tags/js/src/helpers/category-label.js b/extensions/tags/js/src/helpers/category-label.js index 73f8ad398..3128136be 100644 --- a/extensions/tags/js/src/helpers/category-label.js +++ b/extensions/tags/js/src/helpers/category-label.js @@ -1,3 +1,12 @@ -export default function categoryLabel(category) { - return m('span.category-label', {style: {backgroundColor: category.color()}}, category.title()); +export default function categoryLabel(category, attrs) { + attrs = attrs || {}; + + if (category) { + attrs.style = attrs.style || {}; + attrs.style.backgroundColor = category.color(); + } else { + attrs.className = (attrs.className || '')+' uncategorized'; + } + + return m('span.category-label', attrs, category ? category.title() : 'Uncategorized'); } diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less index 9a40a5d02..fcc1359ab 100644 --- a/extensions/tags/less/categories.less +++ b/extensions/tags/less/categories.less @@ -7,6 +7,11 @@ padding: 1px 5px; border-radius: 4px; + &.uncategorized { + border: 1px dotted @fl-body-muted-color; + color: @fl-body-muted-color; + } + .discussion-summary & { margin-right: 5px; font-size: 11px; From 1d450d6695db0d0f3fbefbca728f8d80bc9a4571 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 7 May 2015 14:11:00 +0930 Subject: [PATCH 015/554] Show categories in columns in modal --- extensions/tags/less/categories.less | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less index fcc1359ab..b6cd78c60 100644 --- a/extensions/tags/less/categories.less +++ b/extensions/tags/less/categories.less @@ -66,7 +66,6 @@ .modal-move-discussion { & .modal-header { padding: 20px; - text-align: left; & h3 { font-size: 16px; @@ -75,8 +74,11 @@ & .modal-content { overflow: hidden; } + & .category-list { + background: @fl-body-secondary-color; + } & .category-tile .title { - margin-bottom: 10px; + margin-bottom: 5px; font-size: 18px; } & .category-tile .description { @@ -86,8 +88,15 @@ & .count { display: none; } - & .category-tile > a { - padding: 20px; + & .category-tile { + float: left; + width: 50%; + height: 125px; + + & > a { + padding: 20px; + height: 100%; + } } } From 9705497801056ab67839c79111bb5770bdcb4d0b Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 7 May 2015 22:26:07 +0930 Subject: [PATCH 016/554] Add license --- extensions/tags/LICENSE.txt | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 extensions/tags/LICENSE.txt diff --git a/extensions/tags/LICENSE.txt b/extensions/tags/LICENSE.txt new file mode 100644 index 000000000..aa1e5fb86 --- /dev/null +++ b/extensions/tags/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2015 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. From 9d6c5b9caa86e182f989a00f8e1f13a1fc73e751 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 11 May 2015 10:35:01 +0930 Subject: [PATCH 017/554] Change appearance of category label in discussion hero --- extensions/tags/js/bootstrap.js | 2 +- extensions/tags/js/src/helpers/category-label.js | 2 +- extensions/tags/less/categories.less | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 2a6c7cd3b..b9f12782e 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -133,7 +133,7 @@ app.initializers.add('categories', function() { extend(DiscussionHero.prototype, 'items', function(items) { var category = this.props.discussion.category(); if (category) { - items.add('category', m('span.category-label', category.title()), {before: 'title'}); + items.add('category', categoryLabel(category, {inverted: true}), {before: 'title'}); items.title.content.wrapperClass = 'block-item'; } }); diff --git a/extensions/tags/js/src/helpers/category-label.js b/extensions/tags/js/src/helpers/category-label.js index 3128136be..8b97723b0 100644 --- a/extensions/tags/js/src/helpers/category-label.js +++ b/extensions/tags/js/src/helpers/category-label.js @@ -3,7 +3,7 @@ export default function categoryLabel(category, attrs) { if (category) { attrs.style = attrs.style || {}; - attrs.style.backgroundColor = category.color(); + attrs.style[attrs.inverted ? 'color' : 'backgroundColor'] = category.color(); } else { attrs.className = (attrs.className || '')+' uncategorized'; } diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less index b6cd78c60..7a4950097 100644 --- a/extensions/tags/less/categories.less +++ b/extensions/tags/less/categories.less @@ -19,6 +19,8 @@ .discussion-hero & { font-size: 14px; + background: #fff; + padding: 2px 6px; } .post-discussion-moved & { From ce2e90ecf9728e2b14e347390b30ae52a5a32cdd Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 11 May 2015 10:37:02 +0930 Subject: [PATCH 018/554] Add missing fields to migration. Closes #1 --- .../migrations/2015_02_24_000000_create_categories_table.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extensions/tags/migrations/2015_02_24_000000_create_categories_table.php b/extensions/tags/migrations/2015_02_24_000000_create_categories_table.php index dac755568..7d4af43ed 100644 --- a/extensions/tags/migrations/2015_02_24_000000_create_categories_table.php +++ b/extensions/tags/migrations/2015_02_24_000000_create_categories_table.php @@ -15,8 +15,11 @@ class CreateCategoriesTable extends Migration Schema::create('categories', function (Blueprint $table) { $table->increments('id'); $table->string('title'); + $table->string('slug'); $table->text('description'); $table->string('color'); + $table->integer('discussions_count')->unsigned()->default(0); + $table->integer('position')->nullable(); }); } From bbe863d4015f62c84f008e0147167fb52b2222dd Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 11 May 2015 10:42:20 +0930 Subject: [PATCH 019/554] Assume a Flarum\Support\ServiceProvider context in bootstrap.php --- extensions/tags/bootstrap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/tags/bootstrap.php b/extensions/tags/bootstrap.php index 51ea032cf..2f78404cb 100644 --- a/extensions/tags/bootstrap.php +++ b/extensions/tags/bootstrap.php @@ -6,4 +6,4 @@ require __DIR__.'/vendor/autoload.php'; // Register our service provider with the Flarum application. In here we can // register bindings and execute code when the application boots. -return $app->register('Flarum\Categories\CategoriesServiceProvider'); +return $this->app->register('Flarum\Categories\CategoriesServiceProvider'); From 32efe1198615e2fe7963869203e5e556a1a90633 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 14 May 2015 22:41:37 +0930 Subject: [PATCH 020/554] Use new discussion request include API --- extensions/tags/js/bootstrap.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index b9f12782e..c930a6528 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -25,7 +25,6 @@ import categoryLabel from 'categories/helpers/category-label'; import categoryIcon from 'categories/helpers/category-icon'; app.initializers.add('categories', function() { - // Register routes. app.routes['categories'] = ['/categories', CategoriesPage.component()]; app.routes['category'] = ['/c/:categories', IndexPage.component()]; @@ -117,7 +116,7 @@ app.initializers.add('categories', function() { // Include a discussion's category when fetching it. extend(DiscussionPage.prototype, 'params', function(params) { - params.include += ',category'; + params.include.push('category'); }); // Restyle a discussion's hero to use its category color. From 11fcfbba5836dae3e9d786eedaab1c2eab9d9a92 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 14 May 2015 22:41:51 +0930 Subject: [PATCH 021/554] Update notification architecture --- .../notification-discussion-moved.js | 11 ++------ .../tags/src/DiscussionMovedNotification.php | 25 +++++++++++++------ .../src/Handlers/DiscussionMovedNotifier.php | 20 ++++++--------- 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/extensions/tags/js/src/components/notification-discussion-moved.js b/extensions/tags/js/src/components/notification-discussion-moved.js index de61cea58..90ac47ca4 100644 --- a/extensions/tags/js/src/components/notification-discussion-moved.js +++ b/extensions/tags/js/src/components/notification-discussion-moved.js @@ -6,18 +6,11 @@ export default class NotificationDiscussionMoved extends Notification { view() { var notification = this.props.notification; var discussion = notification.subject(); - var category = discussion.category(); return super.view({ - href: app.route('discussion.near', { - id: discussion.id(), - slug: discussion.slug(), - near: notification.content().postNumber - }), - config: m.route, - title: discussion.title(), + href: app.route.discussion(discussion, notification.content().postNumber), icon: 'arrow-right', - content: ['Moved to ', categoryLabel(category), ' by ', username(notification.sender())] + content: [username(notification.sender()), ' moved to ', categoryLabel(discussion.category())] }); } } diff --git a/extensions/tags/src/DiscussionMovedNotification.php b/extensions/tags/src/DiscussionMovedNotification.php index 5c11cff6f..d10eade16 100644 --- a/extensions/tags/src/DiscussionMovedNotification.php +++ b/extensions/tags/src/DiscussionMovedNotification.php @@ -1,30 +1,39 @@ discussion = $discussion; + $this->sender = $sender; $this->post = $post; - - parent::__construct($recipient, $sender); } public function getSubject() { - return $this->post->discussion; + return $this->discussion; + } + + public function getSender() + { + return $this->sender; } public function getAlertData() { - return [ - 'postNumber' => $this->post->number - ]; + return ['postNumber' => $this->post->number]; } public static function getType() diff --git a/extensions/tags/src/Handlers/DiscussionMovedNotifier.php b/extensions/tags/src/Handlers/DiscussionMovedNotifier.php index c50ba89fa..43a25173f 100755 --- a/extensions/tags/src/Handlers/DiscussionMovedNotifier.php +++ b/extensions/tags/src/Handlers/DiscussionMovedNotifier.php @@ -32,7 +32,13 @@ class DiscussionMovedNotifier $post = $event->discussion->addPost($post); if ($event->discussion->start_user_id !== $event->user->id) { - $this->sendNotification($event, $post); + $notification = new DiscussionMovedNotification($event->discussion, $post->user, $post); + + if ($post->exists) { + $this->notifier->send($notification, [$post->discussion->startUser]); + } else { + $this->notifier->retract($notification); + } } } @@ -45,16 +51,4 @@ class DiscussionMovedNotifier $event->discussion->category_id ); } - - protected function sendNotification(DiscussionWasMoved $event, DiscussionMovedPost $post) - { - $notification = new DiscussionMovedNotification( - $event->discussion->startUser, - $event->user, - $post, - $event->discussion->category_id - ); - - $this->notifier->send($notification); - } } From fccb0ad60805fc2eb5a017b28c9387c1e488df7c Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 14 May 2015 22:42:07 +0930 Subject: [PATCH 022/554] Make category label padding adapt to its size --- extensions/tags/less/categories.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less index 7a4950097..6b0a97edd 100644 --- a/extensions/tags/less/categories.less +++ b/extensions/tags/less/categories.less @@ -4,7 +4,7 @@ font-weight: 600; display: inline-block; color: #fff; - padding: 1px 5px; + padding: 0.1em 0.45em; border-radius: 4px; &.uncategorized { From df7552765b7d41a760dfc90f8c89881526f190da Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 15 May 2015 17:07:12 +0930 Subject: [PATCH 023/554] Include category in the discussion list request --- extensions/tags/js/bootstrap.js | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index c930a6528..10794dec8 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -104,6 +104,7 @@ app.initializers.add('categories', function() { // Translate that parameter into a gambit appended to the search query. extend(DiscussionList.prototype, 'params', function(params) { + params.include.push('category'); if (params.categories) { params.q = (params.q || '')+' category:'+params.categories; delete params.categories; From 33ba3409d1c4c8239c4d185ed84a52cf8eefc2b5 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sun, 17 May 2015 10:20:02 +0930 Subject: [PATCH 024/554] Update for new extension API --- .../tags/src/CategoriesServiceProvider.php | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/extensions/tags/src/CategoriesServiceProvider.php b/extensions/tags/src/CategoriesServiceProvider.php index 79804e807..24a571290 100644 --- a/extensions/tags/src/CategoriesServiceProvider.php +++ b/extensions/tags/src/CategoriesServiceProvider.php @@ -1,9 +1,14 @@ subscribe('Flarum\Categories\Handlers\DiscussionMovedNotifier'); - $events->subscribe('Flarum\Categories\Handlers\CategoryPreloader'); - $events->subscribe('Flarum\Categories\Handlers\CategorySaver'); + $this->extend( + new EventSubscribers([ + 'Flarum\Categories\Handlers\DiscussionMovedNotifier', + 'Flarum\Categories\Handlers\CategoryPreloader', + 'Flarum\Categories\Handlers\CategorySaver' + ]), - $this->forumAssets([ - __DIR__.'/../js/dist/extension.js', - __DIR__.'/../less/categories.less' - ]); + new ForumAssets([ + __DIR__.'/../js/dist/extension.js', + __DIR__.'/../less/categories.less' + ]), - $this->postType('Flarum\Categories\DiscussionMovedPost'); + new PostType('Flarum\Categories\DiscussionMovedPost'), - $this->discussionGambit('Flarum\Categories\CategoryGambit'); + new DiscussionGambit('Flarum\Categories\CategoryGambit'), - $this->notificationType('Flarum\Categories\DiscussionMovedNotification', ['alert' => true]); + (new NotificationType('Flarum\Categories\DiscussionMovedNotification'))->enableByDefault('alert'), - $this->relationship('Flarum\Core\Models\Discussion', 'belongsTo', 'category', 'Flarum\Categories\Category'); + new Relationship('Flarum\Core\Models\Discussion', 'belongsTo', 'category', 'Flarum\Categories\Category'), - $this->serializeRelationship('Flarum\Api\Serializers\DiscussionSerializer', 'hasOne', 'category', 'Flarum\Categories\CategorySerializer'); + new SerializeRelationship('Flarum\Api\Serializers\DiscussionSerializer', 'hasOne', 'category', 'Flarum\Categories\CategorySerializer'), - DiscussionsIndexAction::$include['category'] = true; - DiscussionsShowAction::$include['category'] = true; + new ApiInclude(['discussions.index', 'discussions.show'], 'category', true) + ); } /** From 6794d8794fedc2579db662993703915721395e1b Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 18 May 2015 10:31:40 +0930 Subject: [PATCH 025/554] Remove unneeded dependency --- extensions/tags/js/Gulpfile.js | 1 - extensions/tags/js/package.json | 7 ++----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/extensions/tags/js/Gulpfile.js b/extensions/tags/js/Gulpfile.js index baa47a7ac..cf805541a 100644 --- a/extensions/tags/js/Gulpfile.js +++ b/extensions/tags/js/Gulpfile.js @@ -7,7 +7,6 @@ var gulpif = require('gulp-if'); var babel = require('gulp-babel'); var cached = require('gulp-cached'); var remember = require('gulp-remember'); -var merge = require('merge-stream'); var streamqueue = require('streamqueue'); var staticFiles = [ diff --git a/extensions/tags/js/package.json b/extensions/tags/js/package.json index b83b01cc5..c9193ee46 100644 --- a/extensions/tags/js/package.json +++ b/extensions/tags/js/package.json @@ -1,5 +1,5 @@ { - "name": "flarum-sticky", + "name": "flarum-categories", "devDependencies": { "gulp": "^3.8.11", "gulp-babel": "^5.1.0", @@ -9,10 +9,7 @@ "gulp-livereload": "^3.8.0", "gulp-remember": "^0.3.0", "gulp-uglify": "^1.2.0", - "merge-stream": "^0.1.7", - "yargs": "^3.7.2" - }, - "dependencies": { + "yargs": "^3.7.2", "streamqueue": "^0.1.3" } } From 3936cc4e238a19b4134df23c9c5c07f211213ea2 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 18 May 2015 13:51:58 +0930 Subject: [PATCH 026/554] Implement "move" permission --- extensions/tags/js/bootstrap.js | 3 ++- extensions/tags/src/CategoriesServiceProvider.php | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 10794dec8..4641b17fc 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -35,6 +35,7 @@ app.initializers.add('categories', function() { // Register models. app.store.models['categories'] = Category; Discussion.prototype.category = Model.one('category'); + Discussion.prototype.canMove = Model.prop('canMove'); // Register components. app.postComponentRegistry['discussionMoved'] = PostDiscussionMoved; @@ -140,7 +141,7 @@ app.initializers.add('categories', function() { // Add a control allowing the discussion to be moved to another category. extend(Discussion.prototype, 'controls', function(items) { - if (this.canEdit()) { + if (this.canMove()) { items.add('move', ActionButton.component({ label: 'Move', icon: 'arrow-right', diff --git a/extensions/tags/src/CategoriesServiceProvider.php b/extensions/tags/src/CategoriesServiceProvider.php index 24a571290..7988de644 100644 --- a/extensions/tags/src/CategoriesServiceProvider.php +++ b/extensions/tags/src/CategoriesServiceProvider.php @@ -9,6 +9,7 @@ use Flarum\Extend\NotificationType; use Flarum\Extend\Relationship; use Flarum\Extend\SerializeRelationship; use Flarum\Extend\ApiInclude; +use Flarum\Extend\Permission; class CategoriesServiceProvider extends ServiceProvider { @@ -41,7 +42,14 @@ class CategoriesServiceProvider extends ServiceProvider new SerializeRelationship('Flarum\Api\Serializers\DiscussionSerializer', 'hasOne', 'category', 'Flarum\Categories\CategorySerializer'), - new ApiInclude(['discussions.index', 'discussions.show'], 'category', true) + new ApiInclude(['discussions.index', 'discussions.show'], 'category', true), + + (new Permission('discussion.move')) + ->serialize() + ->grant(function ($grant, $user) { + $grant->where('start_user_id', $user->id); + // @todo add limitations to time etc. according to a config setting + }) ); } From 73c101b075707a8f9daeff514983d79c3ec0382d Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 18 May 2015 15:28:04 +0930 Subject: [PATCH 027/554] props method just modifies props now, doesn't need to return --- extensions/tags/js/src/components/category-nav-item.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/extensions/tags/js/src/components/category-nav-item.js b/extensions/tags/js/src/components/category-nav-item.js index ce1911468..632daca96 100644 --- a/extensions/tags/js/src/components/category-nav-item.js +++ b/extensions/tags/js/src/components/category-nav-item.js @@ -16,7 +16,5 @@ export default class CategoryNavItem extends NavItem { props.params.categories = category ? category.slug() : 'uncategorized'; props.href = app.route('category', props.params); props.label = category ? category.title() : 'Uncategorized'; - - return props; } } From c99055c6a3cb41e84b48a2fec8529850d4007af4 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 18 May 2015 18:47:49 +0930 Subject: [PATCH 028/554] Rename ActivityPost to EventPost --- extensions/tags/src/DiscussionMovedPost.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/tags/src/DiscussionMovedPost.php b/extensions/tags/src/DiscussionMovedPost.php index e57a4914c..01b8a87da 100755 --- a/extensions/tags/src/DiscussionMovedPost.php +++ b/extensions/tags/src/DiscussionMovedPost.php @@ -1,9 +1,9 @@ Date: Mon, 18 May 2015 18:50:50 +0930 Subject: [PATCH 029/554] Rename JS sub-components so that descriptors are before the noun, not after --- extensions/tags/js/bootstrap.js | 22 +++++++++---------- ...ed.js => discussion-moved-notification.js} | 2 +- ...sion-moved.js => discussion-moved-post.js} | 4 ++-- extensions/tags/less/categories.less | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) rename extensions/tags/js/src/components/{notification-discussion-moved.js => discussion-moved-notification.js} (89%) rename extensions/tags/js/src/components/{post-discussion-moved.js => discussion-moved-post.js} (76%) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 4641b17fc..43705571d 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -8,9 +8,9 @@ import DiscussionHero from 'flarum/components/discussion-hero'; import Separator from 'flarum/components/separator'; import ActionButton from 'flarum/components/action-button'; import NavItem from 'flarum/components/nav-item'; -import ComposerDiscussion from 'flarum/components/composer-discussion'; +import DiscussionComposer from 'flarum/components/discussion-composer'; import SettingsPage from 'flarum/components/settings-page'; -import ActivityPost from 'flarum/components/activity-post'; +import PostActivity from 'flarum/components/post-activity'; import icon from 'flarum/helpers/icon'; import app from 'flarum/app'; @@ -19,8 +19,8 @@ import CategoriesPage from 'categories/components/categories-page'; import CategoryHero from 'categories/components/category-hero'; import CategoryNavItem from 'categories/components/category-nav-item'; import MoveDiscussionModal from 'categories/components/move-discussion-modal'; -import NotificationDiscussionMoved from 'categories/components/notification-discussion-moved'; -import PostDiscussionMoved from 'categories/components/post-discussion-moved'; +import DiscussionMovedNotification from 'categories/components/discussion-moved-notification'; +import DiscussionMovedPost from 'categories/components/discussion-moved-post'; import categoryLabel from 'categories/helpers/category-label'; import categoryIcon from 'categories/helpers/category-icon'; @@ -38,8 +38,8 @@ app.initializers.add('categories', function() { Discussion.prototype.canMove = Model.prop('canMove'); // Register components. - app.postComponentRegistry['discussionMoved'] = PostDiscussionMoved; - app.notificationComponentRegistry['discussionMoved'] = NotificationDiscussionMoved; + app.postComponentRegistry['discussionMoved'] = DiscussionMovedPost; + app.notificationComponentRegistry['discussionMoved'] = DiscussionMovedNotification; // --------------------------------------------------------------------------- // INDEX PAGE @@ -182,8 +182,8 @@ app.initializers.add('categories', function() { }); // Add category-selection abilities to the discussion composer. - ComposerDiscussion.prototype.category = m.prop(); - ComposerDiscussion.prototype.chooseCategory = function() { + DiscussionComposer.prototype.category = m.prop(); + DiscussionComposer.prototype.chooseCategory = function() { var modal = new MoveDiscussionModal({ onchange: category => { this.category(category); @@ -195,7 +195,7 @@ app.initializers.add('categories', function() { // Add a category-selection menu to the discussion composer's header, after // the title. - extend(ComposerDiscussion.prototype, 'headerItems', function(items) { + extend(DiscussionComposer.prototype, 'headerItems', function(items) { var category = this.category(); items.add('category', m('a[href=javascript:;][tabindex=-1].btn.btn-link.control-change-category', {onclick: this.chooseCategory.bind(this)}, [ @@ -206,7 +206,7 @@ app.initializers.add('categories', function() { }); // Add the selected category as data to submit to the server. - extend(ComposerDiscussion.prototype, 'data', function(data) { + extend(DiscussionComposer.prototype, 'data', function(data) { data.links = data.links || {}; data.links.category = this.category(); }); @@ -216,7 +216,7 @@ app.initializers.add('categories', function() { // --------------------------------------------------------------------------- // Add a category label next to the discussion title in post activity items. - extend(ActivityPost.prototype, 'headerItems', function(items) { + extend(PostActivity.prototype, 'headerItems', function(items) { var category = this.props.activity.post().discussion().category(); if (category) { items.add('category', categoryLabel(category)); diff --git a/extensions/tags/js/src/components/notification-discussion-moved.js b/extensions/tags/js/src/components/discussion-moved-notification.js similarity index 89% rename from extensions/tags/js/src/components/notification-discussion-moved.js rename to extensions/tags/js/src/components/discussion-moved-notification.js index 90ac47ca4..4e622a4eb 100644 --- a/extensions/tags/js/src/components/notification-discussion-moved.js +++ b/extensions/tags/js/src/components/discussion-moved-notification.js @@ -2,7 +2,7 @@ import Notification from 'flarum/components/notification'; import username from 'flarum/helpers/username'; import categoryLabel from 'categories/helpers/category-label'; -export default class NotificationDiscussionMoved extends Notification { +export default class DiscussionMovedNotification extends Notification { view() { var notification = this.props.notification; var discussion = notification.subject(); diff --git a/extensions/tags/js/src/components/post-discussion-moved.js b/extensions/tags/js/src/components/discussion-moved-post.js similarity index 76% rename from extensions/tags/js/src/components/post-discussion-moved.js rename to extensions/tags/js/src/components/discussion-moved-post.js index de0b3380b..72373b8ff 100644 --- a/extensions/tags/js/src/components/post-discussion-moved.js +++ b/extensions/tags/js/src/components/discussion-moved-post.js @@ -1,7 +1,7 @@ -import PostActivity from 'flarum/components/post-activity'; +import EventPost from 'flarum/components/event-post'; import categoryLabel from 'categories/helpers/category-label'; -export default class PostDiscussionMoved extends PostActivity { +export default class DiscussionMovedPost extends EventPost { view() { var post = this.props.post; var oldCategory = app.store.getById('categories', post.content()[0]); diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less index 6b0a97edd..9ca4c60d3 100644 --- a/extensions/tags/less/categories.less +++ b/extensions/tags/less/categories.less @@ -23,7 +23,7 @@ padding: 2px 6px; } - .post-discussion-moved & { + .discussion-moved-post & { margin: 0 2px; } } From 72a676ddfd5c52561f323367759fa03d5e5a406e Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 20 May 2015 12:30:57 +0930 Subject: [PATCH 030/554] Update for new notifications API --- .../tags/src/CategoriesServiceProvider.php | 2 +- .../tags/src/DiscussionMovedNotification.php | 24 ++++--------- .../src/Handlers/DiscussionMovedNotifier.php | 35 +++++++------------ 3 files changed, 21 insertions(+), 40 deletions(-) diff --git a/extensions/tags/src/CategoriesServiceProvider.php b/extensions/tags/src/CategoriesServiceProvider.php index 7988de644..8e67f8700 100644 --- a/extensions/tags/src/CategoriesServiceProvider.php +++ b/extensions/tags/src/CategoriesServiceProvider.php @@ -36,7 +36,7 @@ class CategoriesServiceProvider extends ServiceProvider new DiscussionGambit('Flarum\Categories\CategoryGambit'), - (new NotificationType('Flarum\Categories\DiscussionMovedNotification'))->enableByDefault('alert'), + (new NotificationType('Flarum\Categories\DiscussionMovedNotification', 'Flarum\Api\Serializers\DiscussionBasicSerializer'))->enableByDefault('alert'), new Relationship('Flarum\Core\Models\Discussion', 'belongsTo', 'category', 'Flarum\Categories\Category'), diff --git a/extensions/tags/src/DiscussionMovedNotification.php b/extensions/tags/src/DiscussionMovedNotification.php index d10eade16..10a82ba84 100644 --- a/extensions/tags/src/DiscussionMovedNotification.php +++ b/extensions/tags/src/DiscussionMovedNotification.php @@ -1,39 +1,29 @@ discussion = $discussion; - $this->sender = $sender; $this->post = $post; } public function getSubject() { - return $this->discussion; + return $this->post->discussion; } public function getSender() { - return $this->sender; + return $this->post->user; } - public function getAlertData() + public function getData() { - return ['postNumber' => $this->post->number]; + return ['postNumber' => (int) $this->post->number]; } public static function getType() diff --git a/extensions/tags/src/Handlers/DiscussionMovedNotifier.php b/extensions/tags/src/Handlers/DiscussionMovedNotifier.php index 43a25173f..cb7723902 100755 --- a/extensions/tags/src/Handlers/DiscussionMovedNotifier.php +++ b/extensions/tags/src/Handlers/DiscussionMovedNotifier.php @@ -3,16 +3,16 @@ use Flarum\Categories\DiscussionMovedPost; use Flarum\Categories\DiscussionMovedNotification; use Flarum\Categories\Events\DiscussionWasMoved; -use Flarum\Core\Notifications\Notifier; +use Flarum\Core\Notifications\NotificationSyncer; use Illuminate\Contracts\Events\Dispatcher; class DiscussionMovedNotifier { - protected $notifier; + protected $notifications; - public function __construct(Notifier $notifier) + public function __construct(NotificationSyncer $notifications) { - $this->notifier = $notifier; + $this->notifications = $notifications; } /** @@ -27,28 +27,19 @@ class DiscussionMovedNotifier public function whenDiscussionWasMoved(DiscussionWasMoved $event) { - $post = $this->createPost($event); - - $post = $event->discussion->addPost($post); - - if ($event->discussion->start_user_id !== $event->user->id) { - $notification = new DiscussionMovedNotification($event->discussion, $post->user, $post); - - if ($post->exists) { - $this->notifier->send($notification, [$post->discussion->startUser]); - } else { - $this->notifier->retract($notification); - } - } - } - - protected function createPost(DiscussionWasMoved $event) - { - return DiscussionMovedPost::reply( + $post = DiscussionMovedPost::reply( $event->discussion->id, $event->user->id, $event->oldCategoryId, $event->discussion->category_id ); + + $post = $event->discussion->addPost($post); + + if ($event->discussion->start_user_id !== $event->user->id) { + $notification = new DiscussionMovedNotification($post); + + $this->notifications->sync($notification, $post->exists ? [$event->discussion->startUser] : []); + } } } From cfb6ad79026364a0f45906e5b56037a734acd380 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 20 May 2015 12:31:07 +0930 Subject: [PATCH 031/554] Update for new activity API --- extensions/tags/js/bootstrap.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 43705571d..901278cd3 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -10,7 +10,7 @@ import ActionButton from 'flarum/components/action-button'; import NavItem from 'flarum/components/nav-item'; import DiscussionComposer from 'flarum/components/discussion-composer'; import SettingsPage from 'flarum/components/settings-page'; -import PostActivity from 'flarum/components/post-activity'; +import PostedActivity from 'flarum/components/posted-activity'; import icon from 'flarum/helpers/icon'; import app from 'flarum/app'; @@ -216,7 +216,7 @@ app.initializers.add('categories', function() { // --------------------------------------------------------------------------- // Add a category label next to the discussion title in post activity items. - extend(PostActivity.prototype, 'headerItems', function(items) { + extend(PostedActivity.prototype, 'headerItems', function(items) { var category = this.props.activity.post().discussion().category(); if (category) { items.add('category', categoryLabel(category)); From 7ab72a5f2881c06ace09241f38212ca743cc01de Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 21 May 2015 06:49:27 +0930 Subject: [PATCH 032/554] Fix for new activity API --- extensions/tags/js/bootstrap.js | 2 +- extensions/tags/src/CategoriesServiceProvider.php | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 901278cd3..769382d83 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -217,7 +217,7 @@ app.initializers.add('categories', function() { // Add a category label next to the discussion title in post activity items. extend(PostedActivity.prototype, 'headerItems', function(items) { - var category = this.props.activity.post().discussion().category(); + var category = this.props.activity.subject().discussion().category(); if (category) { items.add('category', categoryLabel(category)); } diff --git a/extensions/tags/src/CategoriesServiceProvider.php b/extensions/tags/src/CategoriesServiceProvider.php index 8e67f8700..5906e8e7c 100644 --- a/extensions/tags/src/CategoriesServiceProvider.php +++ b/extensions/tags/src/CategoriesServiceProvider.php @@ -40,10 +40,12 @@ class CategoriesServiceProvider extends ServiceProvider new Relationship('Flarum\Core\Models\Discussion', 'belongsTo', 'category', 'Flarum\Categories\Category'), - new SerializeRelationship('Flarum\Api\Serializers\DiscussionSerializer', 'hasOne', 'category', 'Flarum\Categories\CategorySerializer'), + new SerializeRelationship('Flarum\Api\Serializers\DiscussionBasicSerializer', 'hasOne', 'category', 'Flarum\Categories\CategorySerializer'), new ApiInclude(['discussions.index', 'discussions.show'], 'category', true), + new ApiInclude(['activity.index'], 'subject.discussion.category', true), + (new Permission('discussion.move')) ->serialize() ->grant(function ($grant, $user) { From 6e470b0c43570e4daada89c4b76a28ad5865343c Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 21 May 2015 13:11:09 +0930 Subject: [PATCH 033/554] Compatibility with core skin changes; allow skin to customize labels --- extensions/tags/js/bootstrap.js | 4 ++-- extensions/tags/js/src/components/category-hero.js | 2 +- extensions/tags/js/src/helpers/category-label.js | 4 ++-- extensions/tags/less/categories.less | 11 +++++++++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 769382d83..785d3fe7a 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -125,7 +125,7 @@ app.initializers.add('categories', function() { extend(DiscussionHero.prototype, 'view', function(view) { var category = this.props.discussion.category(); if (category) { - view.attrs.style = 'background-color: '+category.color(); + view.attrs.style = 'color: #fff; background-color: '+category.color(); } }); @@ -134,7 +134,7 @@ app.initializers.add('categories', function() { extend(DiscussionHero.prototype, 'items', function(items) { var category = this.props.discussion.category(); if (category) { - items.add('category', categoryLabel(category, {inverted: true}), {before: 'title'}); + items.add('category', categoryLabel(category), {before: 'title'}); items.title.content.wrapperClass = 'block-item'; } }); diff --git a/extensions/tags/js/src/components/category-hero.js b/extensions/tags/js/src/components/category-hero.js index babe90069..3dc0af779 100644 --- a/extensions/tags/js/src/components/category-hero.js +++ b/extensions/tags/js/src/components/category-hero.js @@ -4,7 +4,7 @@ export default class CategoryHero extends Component { view() { var category = this.props.category; - return m('header.hero.category-hero', {style: 'background-color: '+category.color()}, [ + return m('header.hero.category-hero', {style: 'color: #fff; background-color: '+category.color()}, [ m('div.container', [ m('div.container-narrow', [ m('h2', category.title()), diff --git a/extensions/tags/js/src/helpers/category-label.js b/extensions/tags/js/src/helpers/category-label.js index 8b97723b0..dffa257d0 100644 --- a/extensions/tags/js/src/helpers/category-label.js +++ b/extensions/tags/js/src/helpers/category-label.js @@ -3,10 +3,10 @@ export default function categoryLabel(category, attrs) { if (category) { attrs.style = attrs.style || {}; - attrs.style[attrs.inverted ? 'color' : 'backgroundColor'] = category.color(); + attrs.style.backgroundColor = attrs.style.color = category.color(); } else { attrs.className = (attrs.className || '')+' uncategorized'; } - return m('span.category-label', attrs, category ? category.title() : 'Uncategorized'); + return m('span.category-label', attrs, m('span.category-label-text', category ? category.title() : 'Uncategorized')); } diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less index 9ca4c60d3..f917fec19 100644 --- a/extensions/tags/less/categories.less +++ b/extensions/tags/less/categories.less @@ -3,7 +3,6 @@ font-size: 80%; font-weight: 600; display: inline-block; - color: #fff; padding: 0.1em 0.45em; border-radius: 4px; @@ -12,6 +11,10 @@ color: @fl-body-muted-color; } + & .category-label-text { + color: #fff !important; + } + .discussion-summary & { margin-right: 5px; font-size: 11px; @@ -19,8 +22,12 @@ .discussion-hero & { font-size: 14px; - background: #fff; + background: #fff !important; padding: 2px 6px; + + & .category-label-text { + color: inherit !important; + } } .discussion-moved-post & { From 8bb8122283e2a47f7b76bb732726dc84ee7bab2a Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 29 May 2015 18:22:36 +0930 Subject: [PATCH 034/554] Update for new post stream --- extensions/tags/js/src/components/move-discussion-modal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/tags/js/src/components/move-discussion-modal.js b/extensions/tags/js/src/components/move-discussion-modal.js index 96f35ef95..90bc9359a 100644 --- a/extensions/tags/js/src/components/move-discussion-modal.js +++ b/extensions/tags/js/src/components/move-discussion-modal.js @@ -43,7 +43,7 @@ export default class MoveDiscussionModal extends Component { if (discussion) { discussion.save({links: {category}}).then(discussion => { if (app.current instanceof DiscussionPage) { - app.current.stream().sync(); + app.current.stream.sync(); } m.redraw(); }); From 45c4cffeea441a5acad493a1faaa3c72c58e18d4 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 30 May 2015 13:58:37 +0930 Subject: [PATCH 035/554] Load category when getting notifications --- extensions/tags/src/CategoriesServiceProvider.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/tags/src/CategoriesServiceProvider.php b/extensions/tags/src/CategoriesServiceProvider.php index 5906e8e7c..0ba18fb5d 100644 --- a/extensions/tags/src/CategoriesServiceProvider.php +++ b/extensions/tags/src/CategoriesServiceProvider.php @@ -44,7 +44,9 @@ class CategoriesServiceProvider extends ServiceProvider new ApiInclude(['discussions.index', 'discussions.show'], 'category', true), - new ApiInclude(['activity.index'], 'subject.discussion.category', true), + new ApiInclude('activity.index', 'subject.discussion.category', true), + + new ApiInclude('notifications.index', 'subject.category', true), (new Permission('discussion.move')) ->serialize() From 39c7dd51650e5eff2f6c566b0b39cc50393a1fd5 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sat, 30 May 2015 14:59:14 +0930 Subject: [PATCH 036/554] Sort the categories list in the sidebar correctly --- extensions/tags/js/bootstrap.js | 2 +- extensions/tags/js/src/models/category.js | 1 + extensions/tags/src/CategorySerializer.php | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 785d3fe7a..8d3ac42d2 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -67,7 +67,7 @@ app.initializers.add('categories', function() { items.add('uncategorized', CategoryNavItem.component({params: this.stickyParams()}), {last: true}); - app.store.all('categories').forEach(category => { + app.store.all('categories').sort((a, b) => a.position() - b.position()).forEach(category => { items.add('category'+category.id(), CategoryNavItem.component({category, params: this.stickyParams()}), {last: true}); }); }); diff --git a/extensions/tags/js/src/models/category.js b/extensions/tags/js/src/models/category.js index 4de836019..83d3e6b8e 100644 --- a/extensions/tags/js/src/models/category.js +++ b/extensions/tags/js/src/models/category.js @@ -8,5 +8,6 @@ Category.prototype.slug = Model.prop('slug'); Category.prototype.description = Model.prop('description'); Category.prototype.color = Model.prop('color'); Category.prototype.discussionsCount = Model.prop('discussionsCount'); +Category.prototype.position = Model.prop('position'); export default Category; diff --git a/extensions/tags/src/CategorySerializer.php b/extensions/tags/src/CategorySerializer.php index 367e1920f..79674ab7c 100644 --- a/extensions/tags/src/CategorySerializer.php +++ b/extensions/tags/src/CategorySerializer.php @@ -24,7 +24,8 @@ class CategorySerializer extends BaseSerializer 'description' => $category->description, 'slug' => $category->slug, 'color' => $category->color, - 'discussionsCount' => (int) $category->discussions_count + 'discussionsCount' => (int) $category->discussions_count, + 'position' => (int) $category->position ]; return $this->extendAttributes($category, $attributes); From 0b12752be300406e716444f0fff45592532ad451 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 3 Jun 2015 18:03:16 +0930 Subject: [PATCH 037/554] Make the category in the discussion hero a link --- extensions/tags/js/bootstrap.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 8d3ac42d2..474954cc8 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -134,7 +134,11 @@ app.initializers.add('categories', function() { extend(DiscussionHero.prototype, 'items', function(items) { var category = this.props.discussion.category(); if (category) { - items.add('category', categoryLabel(category), {before: 'title'}); + items.add('category', m('a', { + href: app.route('category', {categories: category.slug()}), + config: m.route + }, categoryLabel(category)), {before: 'title'}); + items.title.content.wrapperClass = 'block-item'; } }); From 1abc0e2a750ca5c1e3eb9d3abcb03143d0492e3c Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 8 Jun 2015 14:57:47 +0930 Subject: [PATCH 038/554] Use new flarum-gulp package. --- extensions/tags/js/Gulpfile.js | 45 ++----------------- extensions/tags/js/bootstrap.js | 20 ++++----- extensions/tags/js/package.json | 12 +---- .../js/src/components/category-nav-item.js | 2 +- .../discussion-moved-notification.js | 2 +- .../src/components/discussion-moved-post.js | 2 +- .../src/components/move-discussion-modal.js | 2 +- 7 files changed, 19 insertions(+), 66 deletions(-) diff --git a/extensions/tags/js/Gulpfile.js b/extensions/tags/js/Gulpfile.js index cf805541a..b84d6a97d 100644 --- a/extensions/tags/js/Gulpfile.js +++ b/extensions/tags/js/Gulpfile.js @@ -1,44 +1,5 @@ -var gulp = require('gulp'); -var livereload = require('gulp-livereload'); -var concat = require('gulp-concat'); -var argv = require('yargs').argv; -var uglify = require('gulp-uglify'); -var gulpif = require('gulp-if'); -var babel = require('gulp-babel'); -var cached = require('gulp-cached'); -var remember = require('gulp-remember'); -var streamqueue = require('streamqueue'); +var gulp = require('flarum-gulp'); -var staticFiles = [ - 'bootstrap.js' -]; -var moduleFiles = [ - 'src/**/*.js' -]; -var modulePrefix = 'categories'; - -gulp.task('default', function() { - return streamqueue({objectMode: true}, - gulp.src(moduleFiles) - .pipe(cached('scripts')) - .pipe(babel({ modules: 'amd', moduleIds: true, moduleRoot: modulePrefix })) - .pipe(remember('scripts')), - gulp.src(staticFiles) - .pipe(babel()) - ) - .pipe(concat('extension.js')) - .pipe(gulpif(argv.production, uglify())) - .pipe(gulp.dest('dist')) - .pipe(livereload()); -}); - -gulp.task('watch', ['default'], function () { - livereload.listen(); - var watcher = gulp.watch(moduleFiles.concat(staticFiles), ['default']); - watcher.on('change', function (event) { - if (event.type === 'deleted') { - delete cached.caches.scripts[event.path]; - remember.forget('scripts', event.path); - } - }); +gulp({ + modulePrefix: 'flarum-categories' }); diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 474954cc8..f8aa94a8c 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -14,17 +14,17 @@ import PostedActivity from 'flarum/components/posted-activity'; import icon from 'flarum/helpers/icon'; import app from 'flarum/app'; -import Category from 'categories/models/category'; -import CategoriesPage from 'categories/components/categories-page'; -import CategoryHero from 'categories/components/category-hero'; -import CategoryNavItem from 'categories/components/category-nav-item'; -import MoveDiscussionModal from 'categories/components/move-discussion-modal'; -import DiscussionMovedNotification from 'categories/components/discussion-moved-notification'; -import DiscussionMovedPost from 'categories/components/discussion-moved-post'; -import categoryLabel from 'categories/helpers/category-label'; -import categoryIcon from 'categories/helpers/category-icon'; +import Category from 'flarum-categories/models/category'; +import CategoriesPage from 'flarum-categories/components/categories-page'; +import CategoryHero from 'flarum-categories/components/category-hero'; +import CategoryNavItem from 'flarum-categories/components/category-nav-item'; +import MoveDiscussionModal from 'flarum-categories/components/move-discussion-modal'; +import DiscussionMovedNotification from 'flarum-categories/components/discussion-moved-notification'; +import DiscussionMovedPost from 'flarum-categories/components/discussion-moved-post'; +import categoryLabel from 'flarum-categories/helpers/category-label'; +import categoryIcon from 'flarum-categories/helpers/category-icon'; -app.initializers.add('categories', function() { +app.initializers.add('flarum-categories', function() { // Register routes. app.routes['categories'] = ['/categories', CategoriesPage.component()]; app.routes['category'] = ['/c/:categories', IndexPage.component()]; diff --git a/extensions/tags/js/package.json b/extensions/tags/js/package.json index c9193ee46..3e0ef919d 100644 --- a/extensions/tags/js/package.json +++ b/extensions/tags/js/package.json @@ -1,15 +1,7 @@ { - "name": "flarum-categories", + "private": true, "devDependencies": { "gulp": "^3.8.11", - "gulp-babel": "^5.1.0", - "gulp-cached": "^1.0.4", - "gulp-concat": "^2.5.2", - "gulp-if": "^1.2.5", - "gulp-livereload": "^3.8.0", - "gulp-remember": "^0.3.0", - "gulp-uglify": "^1.2.0", - "yargs": "^3.7.2", - "streamqueue": "^0.1.3" + "flarum-gulp": "git+https://github.com/flarum/gulp.git" } } diff --git a/extensions/tags/js/src/components/category-nav-item.js b/extensions/tags/js/src/components/category-nav-item.js index 632daca96..7d985db4f 100644 --- a/extensions/tags/js/src/components/category-nav-item.js +++ b/extensions/tags/js/src/components/category-nav-item.js @@ -1,5 +1,5 @@ import NavItem from 'flarum/components/nav-item'; -import categoryIcon from 'categories/helpers/category-icon'; +import categoryIcon from 'flarum-categories/helpers/category-icon'; export default class CategoryNavItem extends NavItem { view() { diff --git a/extensions/tags/js/src/components/discussion-moved-notification.js b/extensions/tags/js/src/components/discussion-moved-notification.js index 4e622a4eb..fc77d7bcb 100644 --- a/extensions/tags/js/src/components/discussion-moved-notification.js +++ b/extensions/tags/js/src/components/discussion-moved-notification.js @@ -1,6 +1,6 @@ import Notification from 'flarum/components/notification'; import username from 'flarum/helpers/username'; -import categoryLabel from 'categories/helpers/category-label'; +import categoryLabel from 'flarum-categories/helpers/category-label'; export default class DiscussionMovedNotification extends Notification { view() { diff --git a/extensions/tags/js/src/components/discussion-moved-post.js b/extensions/tags/js/src/components/discussion-moved-post.js index 72373b8ff..54870f5e1 100644 --- a/extensions/tags/js/src/components/discussion-moved-post.js +++ b/extensions/tags/js/src/components/discussion-moved-post.js @@ -1,5 +1,5 @@ import EventPost from 'flarum/components/event-post'; -import categoryLabel from 'categories/helpers/category-label'; +import categoryLabel from 'flarum-categories/helpers/category-label'; export default class DiscussionMovedPost extends EventPost { view() { diff --git a/extensions/tags/js/src/components/move-discussion-modal.js b/extensions/tags/js/src/components/move-discussion-modal.js index 90bc9359a..cf8a8722f 100644 --- a/extensions/tags/js/src/components/move-discussion-modal.js +++ b/extensions/tags/js/src/components/move-discussion-modal.js @@ -1,7 +1,7 @@ import Component from 'flarum/component'; import DiscussionPage from 'flarum/components/discussion-page'; import icon from 'flarum/helpers/icon'; -import categoryLabel from 'categories/helpers/category-label'; +import categoryLabel from 'flarum-categories/helpers/category-label'; export default class MoveDiscussionModal extends Component { constructor(props) { From 6f5061485599b5c2d551edcfc49e787578cca799 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 8 Jun 2015 14:58:08 +0930 Subject: [PATCH 039/554] Update manifest. --- extensions/tags/{extension.json => flarum.json} | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) rename extensions/tags/{extension.json => flarum.json} (50%) diff --git a/extensions/tags/extension.json b/extensions/tags/flarum.json similarity index 50% rename from extensions/tags/extension.json rename to extensions/tags/flarum.json index be33153e4..e0edb918d 100644 --- a/extensions/tags/extension.json +++ b/extensions/tags/flarum.json @@ -1,15 +1,23 @@ { - "name": "categories", + "name": "flarum-categories", + "title": "Categories", "description": "Organise discussions into a heirarchy of categories.", + "tags": [ + "discussions" + ], "version": "0.1.0", "author": { "name": "Toby Zerner", "email": "toby@flarum.org", - "website": "http://tobyzerner.com" + "homepage": "http://tobyzerner.com" }, "license": "MIT", "require": { "php": ">=5.4.0", "flarum": ">1.0.0" + }, + "links": { + "github": "https://github.com/flarum/categories", + "issues": "https://github.com/flarum/categories/issues" } } From f569d00314a5f409fe409ca27e9a82e8cf24be0b Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 8 Jun 2015 15:05:22 +0930 Subject: [PATCH 040/554] Update manifest. --- extensions/tags/composer.json | 6 +----- extensions/tags/flarum.json | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/extensions/tags/composer.json b/extensions/tags/composer.json index f95f058d8..01851fb88 100644 --- a/extensions/tags/composer.json +++ b/extensions/tags/composer.json @@ -1,11 +1,7 @@ { - "require": { - "php": ">=5.4.0" - }, "autoload": { "psr-4": { "Flarum\\Categories\\": "src/" } - }, - "minimum-stability": "dev" + } } diff --git a/extensions/tags/flarum.json b/extensions/tags/flarum.json index e0edb918d..2a6e27bfc 100644 --- a/extensions/tags/flarum.json +++ b/extensions/tags/flarum.json @@ -14,7 +14,7 @@ "license": "MIT", "require": { "php": ">=5.4.0", - "flarum": ">1.0.0" + "flarum": ">0.1.0" }, "links": { "github": "https://github.com/flarum/categories", From c9a03d9d8a1ffdb14eedbe95cbcae76b2315b508 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 11 Jun 2015 18:34:48 +0930 Subject: [PATCH 041/554] Rename extension to Tags. Allow multiple tags per discussion. WIP! --- extensions/tags/bootstrap.php | 2 +- extensions/tags/composer.json | 2 +- extensions/tags/flarum.json | 19 +- extensions/tags/js/Gulpfile.js | 2 +- extensions/tags/js/bootstrap.js | 241 ++---------------- extensions/tags/js/src/add-tag-filter.js | 50 ++++ extensions/tags/js/src/add-tag-labels.js | 40 +++ extensions/tags/js/src/add-tag-list.js | 50 ++++ .../tags/js/src/components/category-hero.js | 16 -- .../js/src/components/category-nav-item.js | 20 -- extensions/tags/js/src/components/tag-hero.js | 17 ++ .../tags/js/src/components/tag-nav-item.js | 39 +++ .../{categories-page.js => tags-page.js} | 2 +- .../tags/js/src/helpers/category-icon.js | 12 - .../tags/js/src/helpers/category-label.js | 12 - extensions/tags/js/src/helpers/tag-icon.js | 12 + extensions/tags/js/src/helpers/tag-label.js | 24 ++ extensions/tags/js/src/helpers/tags-label.js | 19 ++ extensions/tags/js/src/models/category.js | 13 - extensions/tags/js/src/models/tag.js | 18 ++ extensions/tags/less/extension.less | 93 +++++++ ...5_02_24_000000_create_categories_table.php | 35 --- ..._000000_create_discussions_tags_table.php} | 12 +- .../2015_02_24_000000_create_tags_table.php | 39 +++ ...5_02_24_000000_create_users_tags_table.php | 33 +++ extensions/tags/src/Category.php | 8 - extensions/tags/src/CategoryGambit.php | 54 ---- extensions/tags/src/CategorySerializer.php | 33 --- ...pository.php => EloquentTagRepository.php} | 14 +- ...CategoryPreloader.php => TagPreloader.php} | 12 +- extensions/tags/src/Tag.php | 8 + extensions/tags/src/TagGambit.php | 63 +++++ ...terface.php => TagRepositoryInterface.php} | 8 +- extensions/tags/src/TagSerializer.php | 42 +++ extensions/tags/src/TagsServiceProvider.php | 64 +++++ 35 files changed, 663 insertions(+), 465 deletions(-) create mode 100644 extensions/tags/js/src/add-tag-filter.js create mode 100644 extensions/tags/js/src/add-tag-labels.js create mode 100644 extensions/tags/js/src/add-tag-list.js delete mode 100644 extensions/tags/js/src/components/category-hero.js delete mode 100644 extensions/tags/js/src/components/category-nav-item.js create mode 100644 extensions/tags/js/src/components/tag-hero.js create mode 100644 extensions/tags/js/src/components/tag-nav-item.js rename extensions/tags/js/src/components/{categories-page.js => tags-page.js} (96%) delete mode 100644 extensions/tags/js/src/helpers/category-icon.js delete mode 100644 extensions/tags/js/src/helpers/category-label.js create mode 100644 extensions/tags/js/src/helpers/tag-icon.js create mode 100644 extensions/tags/js/src/helpers/tag-label.js create mode 100644 extensions/tags/js/src/helpers/tags-label.js delete mode 100644 extensions/tags/js/src/models/category.js create mode 100644 extensions/tags/js/src/models/tag.js create mode 100644 extensions/tags/less/extension.less delete mode 100644 extensions/tags/migrations/2015_02_24_000000_create_categories_table.php rename extensions/tags/migrations/{2015_02_24_000000_add_category_to_discussions.php => 2015_02_24_000000_create_discussions_tags_table.php} (50%) create mode 100644 extensions/tags/migrations/2015_02_24_000000_create_tags_table.php create mode 100644 extensions/tags/migrations/2015_02_24_000000_create_users_tags_table.php delete mode 100644 extensions/tags/src/Category.php delete mode 100644 extensions/tags/src/CategoryGambit.php delete mode 100644 extensions/tags/src/CategorySerializer.php rename extensions/tags/src/{EloquentCategoryRepository.php => EloquentTagRepository.php} (74%) rename extensions/tags/src/Handlers/{CategoryPreloader.php => TagPreloader.php} (51%) create mode 100644 extensions/tags/src/Tag.php create mode 100644 extensions/tags/src/TagGambit.php rename extensions/tags/src/{CategoryRepositoryInterface.php => TagRepositoryInterface.php} (67%) create mode 100644 extensions/tags/src/TagSerializer.php create mode 100644 extensions/tags/src/TagsServiceProvider.php diff --git a/extensions/tags/bootstrap.php b/extensions/tags/bootstrap.php index 2f78404cb..04f029c04 100644 --- a/extensions/tags/bootstrap.php +++ b/extensions/tags/bootstrap.php @@ -6,4 +6,4 @@ require __DIR__.'/vendor/autoload.php'; // Register our service provider with the Flarum application. In here we can // register bindings and execute code when the application boots. -return $this->app->register('Flarum\Categories\CategoriesServiceProvider'); +return $this->app->register('Flarum\Tags\TagsServiceProvider'); diff --git a/extensions/tags/composer.json b/extensions/tags/composer.json index 01851fb88..8decb8a7c 100644 --- a/extensions/tags/composer.json +++ b/extensions/tags/composer.json @@ -1,7 +1,7 @@ { "autoload": { "psr-4": { - "Flarum\\Categories\\": "src/" + "Flarum\\Tags\\": "src/" } } } diff --git a/extensions/tags/flarum.json b/extensions/tags/flarum.json index 2a6e27bfc..f4c30f9c7 100644 --- a/extensions/tags/flarum.json +++ b/extensions/tags/flarum.json @@ -1,23 +1,16 @@ { - "name": "flarum-categories", - "title": "Categories", - "description": "Organise discussions into a heirarchy of categories.", - "tags": [ - "discussions" - ], + "name": "flarum-tags", + "title": "Tags", + "description": "Organise discussions into a heirarchy of tags and categories.", + "tags": [], "version": "0.1.0", "author": { "name": "Toby Zerner", - "email": "toby@flarum.org", - "homepage": "http://tobyzerner.com" + "email": "toby.zerner@gmail.com" }, "license": "MIT", "require": { "php": ">=5.4.0", "flarum": ">0.1.0" - }, - "links": { - "github": "https://github.com/flarum/categories", - "issues": "https://github.com/flarum/categories/issues" } -} +} \ No newline at end of file diff --git a/extensions/tags/js/Gulpfile.js b/extensions/tags/js/Gulpfile.js index b84d6a97d..db9c6a6d2 100644 --- a/extensions/tags/js/Gulpfile.js +++ b/extensions/tags/js/Gulpfile.js @@ -1,5 +1,5 @@ var gulp = require('flarum-gulp'); gulp({ - modulePrefix: 'flarum-categories' + modulePrefix: 'flarum-tags' }); diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index f8aa94a8c..3570940d4 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -1,237 +1,34 @@ -import { extend, override } from 'flarum/extension-utils'; +import app from 'flarum/app'; import Model from 'flarum/model'; import Discussion from 'flarum/models/discussion'; import IndexPage from 'flarum/components/index-page'; -import DiscussionPage from 'flarum/components/discussion-page'; -import DiscussionList from 'flarum/components/discussion-list'; -import DiscussionHero from 'flarum/components/discussion-hero'; -import Separator from 'flarum/components/separator'; -import ActionButton from 'flarum/components/action-button'; -import NavItem from 'flarum/components/nav-item'; -import DiscussionComposer from 'flarum/components/discussion-composer'; -import SettingsPage from 'flarum/components/settings-page'; -import PostedActivity from 'flarum/components/posted-activity'; -import icon from 'flarum/helpers/icon'; -import app from 'flarum/app'; -import Category from 'flarum-categories/models/category'; -import CategoriesPage from 'flarum-categories/components/categories-page'; -import CategoryHero from 'flarum-categories/components/category-hero'; -import CategoryNavItem from 'flarum-categories/components/category-nav-item'; -import MoveDiscussionModal from 'flarum-categories/components/move-discussion-modal'; -import DiscussionMovedNotification from 'flarum-categories/components/discussion-moved-notification'; -import DiscussionMovedPost from 'flarum-categories/components/discussion-moved-post'; -import categoryLabel from 'flarum-categories/helpers/category-label'; -import categoryIcon from 'flarum-categories/helpers/category-icon'; +import Tag from 'flarum-tags/models/tag'; +import TagsPage from 'flarum-tags/components/tags-page'; +import addTagList from 'flarum-tags/add-tag-list'; +import addTagFilter from 'flarum-tags/add-tag-filter'; +import addTagLabels from 'flarum-tags/add-tag-labels'; -app.initializers.add('flarum-categories', function() { +app.initializers.add('flarum-tags', function() { // Register routes. - app.routes['categories'] = ['/categories', CategoriesPage.component()]; - app.routes['category'] = ['/c/:categories', IndexPage.component()]; - - // @todo support combination with filters - // app.routes['category.filter'] = ['/c/:slug/:filter', IndexPage.component({category: true})]; + app.routes['tags'] = ['/tags', TagsPage.component()]; + app.routes['tag'] = ['/t/:tags', IndexPage.component()]; // Register models. - app.store.models['categories'] = Category; - Discussion.prototype.category = Model.one('category'); + app.store.models['tags'] = Tag; + Discussion.prototype.tags = Model.many('tags'); Discussion.prototype.canMove = Model.prop('canMove'); - // Register components. - app.postComponentRegistry['discussionMoved'] = DiscussionMovedPost; - app.notificationComponentRegistry['discussionMoved'] = DiscussionMovedNotification; + // Add a list of tags to the index navigation. + addTagList(); - // --------------------------------------------------------------------------- - // INDEX PAGE - // --------------------------------------------------------------------------- + // When a tag is selected, filter the discussion list by that tag. + addTagFilter(); - // Add a category label to each discussion in the discussion list. - extend(DiscussionList.prototype, 'infoItems', function(items, discussion) { - var category = discussion.category(); - if (category && category.slug() !== this.props.params.categories) { - items.add('category', categoryLabel(category), {first: true}); - } - }); + // Add tags to the discussion list and discussion hero. + addTagLabels(); - // Add a link to the categories page, as well as a list of all the categories, - // to the index page's sidebar. - extend(IndexPage.prototype, 'navItems', function(items) { - items.add('categories', NavItem.component({ - icon: 'reorder', - label: 'Categories', - href: app.route('categories'), - config: m.route - }), {last: true}); + // addMoveDiscussionControl(); - items.add('separator', Separator.component(), {last: true}); - - items.add('uncategorized', CategoryNavItem.component({params: this.stickyParams()}), {last: true}); - - app.store.all('categories').sort((a, b) => a.position() - b.position()).forEach(category => { - items.add('category'+category.id(), CategoryNavItem.component({category, params: this.stickyParams()}), {last: true}); - }); - }); - - IndexPage.prototype.currentCategory = function() { - var slug = this.params().categories; - if (slug) { - return app.store.getBy('categories', 'slug', slug); - } - }; - - // If currently viewing a category, insert a category hero at the top of the - // view. - extend(IndexPage.prototype, 'view', function(view) { - var category = this.currentCategory(); - if (category) { - view.children[0] = CategoryHero.component({category}); - } - }); - - // If currently viewing a category, restyle the 'new discussion' button to use - // the category's color. - extend(IndexPage.prototype, 'sidebarItems', function(items) { - var category = this.currentCategory(); - if (category) { - items.newDiscussion.content.props.style = 'background-color: '+category.color(); - } - }); - - // Add a parameter for the IndexPage to pass on to the DiscussionList that - // will let us filter discussions by category. - extend(IndexPage.prototype, 'params', function(params) { - params.categories = m.route.param('categories'); - }); - - // Translate that parameter into a gambit appended to the search query. - extend(DiscussionList.prototype, 'params', function(params) { - params.include.push('category'); - if (params.categories) { - params.q = (params.q || '')+' category:'+params.categories; - delete params.categories; - } - }); - - // --------------------------------------------------------------------------- - // DISCUSSION PAGE - // --------------------------------------------------------------------------- - - // Include a discussion's category when fetching it. - extend(DiscussionPage.prototype, 'params', function(params) { - params.include.push('category'); - }); - - // Restyle a discussion's hero to use its category color. - extend(DiscussionHero.prototype, 'view', function(view) { - var category = this.props.discussion.category(); - if (category) { - view.attrs.style = 'color: #fff; background-color: '+category.color(); - } - }); - - // Add the name of a discussion's category to the discussion hero, displayed - // before the title. Put the title on its own line. - extend(DiscussionHero.prototype, 'items', function(items) { - var category = this.props.discussion.category(); - if (category) { - items.add('category', m('a', { - href: app.route('category', {categories: category.slug()}), - config: m.route - }, categoryLabel(category)), {before: 'title'}); - - items.title.content.wrapperClass = 'block-item'; - } - }); - - // Add a control allowing the discussion to be moved to another category. - extend(Discussion.prototype, 'controls', function(items) { - if (this.canMove()) { - items.add('move', ActionButton.component({ - label: 'Move', - icon: 'arrow-right', - onclick: () => app.modal.show(new MoveDiscussionModal({discussion: this})) - }), {after: 'rename'}); - } - }); - - // --------------------------------------------------------------------------- - // COMPOSER - // --------------------------------------------------------------------------- - - // When the 'new discussion' button is clicked... - override(IndexPage.prototype, 'newDiscussion', function(original) { - var slug = this.params().categories; - - // If we're currently viewing a specific category, or if the user isn't - // logged in, then we'll let the core code proceed. If that results in the - // composer appearing, we'll set the composer's current category to the one - // we're viewing. - if (slug || !app.session.user()) { - if (original()) { - var category = app.store.getBy('categories', 'slug', slug); - app.composer.component.category(category); - } - } else { - // If we're logged in and we're viewing All Discussions, we'll present the - // user with a category selection dialog before proceeding to show the - // composer. - var modal = new MoveDiscussionModal({ - onchange: category => { - original(); - app.composer.component.category(category); - } - }); - app.modal.show(modal); - } - }); - - // Add category-selection abilities to the discussion composer. - DiscussionComposer.prototype.category = m.prop(); - DiscussionComposer.prototype.chooseCategory = function() { - var modal = new MoveDiscussionModal({ - onchange: category => { - this.category(category); - this.$('textarea').focus(); - } - }); - app.modal.show(modal); - }; - - // Add a category-selection menu to the discussion composer's header, after - // the title. - extend(DiscussionComposer.prototype, 'headerItems', function(items) { - var category = this.category(); - - items.add('category', m('a[href=javascript:;][tabindex=-1].btn.btn-link.control-change-category', {onclick: this.chooseCategory.bind(this)}, [ - categoryIcon(category), ' ', - m('span.label', category ? category.title() : 'Uncategorized'), - icon('sort') - ])); - }); - - // Add the selected category as data to submit to the server. - extend(DiscussionComposer.prototype, 'data', function(data) { - data.links = data.links || {}; - data.links.category = this.category(); - }); - - // --------------------------------------------------------------------------- - // USER PROFILE - // --------------------------------------------------------------------------- - - // Add a category label next to the discussion title in post activity items. - extend(PostedActivity.prototype, 'headerItems', function(items) { - var category = this.props.activity.subject().discussion().category(); - if (category) { - items.add('category', categoryLabel(category)); - } - }); - - // Add a notification preference. - extend(SettingsPage.prototype, 'notificationTypes', function(items) { - items.add('discussionMoved', { - name: 'discussionMoved', - label: [icon('arrow-right'), ' Someone moves a discussion I started'] - }); - }); + // addDiscussionComposer(); }); diff --git a/extensions/tags/js/src/add-tag-filter.js b/extensions/tags/js/src/add-tag-filter.js new file mode 100644 index 000000000..8bc2265e2 --- /dev/null +++ b/extensions/tags/js/src/add-tag-filter.js @@ -0,0 +1,50 @@ +import { extend } from 'flarum/extension-utils'; +import IndexPage from 'flarum/components/index-page'; +import DiscussionList from 'flarum/components/discussion-list'; + +import TagHero from 'flarum-tags/components/tag-hero'; + +export default function() { + IndexPage.prototype.currentTag = function() { + var slug = this.params().tags; + if (slug) { + return app.store.getBy('tags', 'slug', slug); + } + }; + + // If currently viewing a tag, insert a tag hero at the top of the + // view. + extend(IndexPage.prototype, 'view', function(view) { + var tag = this.currentTag(); + if (tag) { + view.children[0] = TagHero.component({tag}); + } + }); + + // If currently viewing a tag, restyle the 'new discussion' button to use + // the tag's color. + extend(IndexPage.prototype, 'sidebarItems', function(items) { + var tag = this.currentTag(); + if (tag) { + var color = tag.color(); + if (color) { + items.newDiscussion.content.props.style = 'background-color: '+color; + } + } + }); + + // Add a parameter for the IndexPage to pass on to the DiscussionList that + // will let us filter discussions by tag. + extend(IndexPage.prototype, 'params', function(params) { + params.tags = m.route.param('tags'); + }); + + // Translate that parameter into a gambit appended to the search query. + extend(DiscussionList.prototype, 'params', function(params) { + params.include.push('tags'); + if (params.tags) { + params.q = (params.q || '')+' tag:'+params.tags; + delete params.tags; + } + }); +}; diff --git a/extensions/tags/js/src/add-tag-labels.js b/extensions/tags/js/src/add-tag-labels.js new file mode 100644 index 000000000..11edc01bb --- /dev/null +++ b/extensions/tags/js/src/add-tag-labels.js @@ -0,0 +1,40 @@ +import { extend } from 'flarum/extension-utils'; +import DiscussionList from 'flarum/components/discussion-list'; +import DiscussionPage from 'flarum/components/discussion-page'; +import DiscussionHero from 'flarum/components/discussion-hero'; + +import tagsLabel from 'flarum-tags/helpers/tags-label'; + +export default function() { + // Add tag labels to each discussion in the discussion list. + extend(DiscussionList.prototype, 'infoItems', function(items, discussion) { + var tags = discussion.tags(); + if (tags) { + items.add('tags', tagsLabel(tags.filter(tag => tag.slug() !== this.props.params.tags)), {first: true}); + } + }); + + // Include a discussion's tags when fetching it. + extend(DiscussionPage.prototype, 'params', function(params) { + params.include.push('tags'); + }); + + // Restyle a discussion's hero to use its first tag's color. + extend(DiscussionHero.prototype, 'view', function(view) { + var tags = this.props.discussion.tags(); + if (tags) { + view.attrs.style = 'color: #fff; background-color: '+tags[0].color(); + } + }); + + // Add a list of a discussion's tags to the discussion hero, displayed + // before the title. Put the title on its own line. + extend(DiscussionHero.prototype, 'items', function(items) { + var tags = this.props.discussion.tags(); + if (tags) { + items.add('tags', tagsLabel(tags, {link: true}), {before: 'title'}); + + items.title.content.wrapperClass = 'block-item'; + } + }); +}; diff --git a/extensions/tags/js/src/add-tag-list.js b/extensions/tags/js/src/add-tag-list.js new file mode 100644 index 000000000..1e4714e33 --- /dev/null +++ b/extensions/tags/js/src/add-tag-list.js @@ -0,0 +1,50 @@ +import { extend } from 'flarum/extension-utils'; +import IndexPage from 'flarum/components/index-page'; +import NavItem from 'flarum/components/nav-item'; +import Separator from 'flarum/components/separator'; + +import TagNavItem from 'flarum-tags/components/tag-nav-item'; + +export default function() { + // Add a link to the tags page, as well as a list of all the tags, + // to the index page's sidebar. + extend(IndexPage.prototype, 'navItems', function(items) { + items.add('tags', NavItem.component({ + icon: 'reorder', + label: 'Tags', + href: app.route('tags'), + config: m.route + }), {last: true}); + + items.add('separator', Separator.component(), {last: true}); + + var params = this.stickyParams(); + var tags = app.store.all('tags'); + + items.add('untagged', TagNavItem.component({params}), {last: true}); + + var addTag = tag => { + var currentTag = this.currentTag(); + var active = currentTag === tag; + if (!active && currentTag) { + currentTag = currentTag.parent(); + active = currentTag === tag; + } + items.add('tag'+tag.id(), TagNavItem.component({tag, params, active}), {last: true}); + } + + tags.filter(tag => tag.position() !== null && !tag.isChild()).sort((a, b) => a.position() - b.position()).forEach(addTag); + + var more = tags.filter(tag => tag.position() === null).sort((a, b) => b.discussionsCount() - a.discussionsCount()); + + more.splice(0, 3).forEach(addTag); + + if (more.length) { + items.add('moreTags', NavItem.component({ + label: 'More...', + href: app.route('tags'), + config: m.route + }), {last: true});; + } + }); +}; diff --git a/extensions/tags/js/src/components/category-hero.js b/extensions/tags/js/src/components/category-hero.js deleted file mode 100644 index 3dc0af779..000000000 --- a/extensions/tags/js/src/components/category-hero.js +++ /dev/null @@ -1,16 +0,0 @@ -import Component from 'flarum/component'; - -export default class CategoryHero extends Component { - view() { - var category = this.props.category; - - return m('header.hero.category-hero', {style: 'color: #fff; background-color: '+category.color()}, [ - m('div.container', [ - m('div.container-narrow', [ - m('h2', category.title()), - m('div.subtitle', category.description()) - ]) - ]) - ]); - } -} diff --git a/extensions/tags/js/src/components/category-nav-item.js b/extensions/tags/js/src/components/category-nav-item.js deleted file mode 100644 index 7d985db4f..000000000 --- a/extensions/tags/js/src/components/category-nav-item.js +++ /dev/null @@ -1,20 +0,0 @@ -import NavItem from 'flarum/components/nav-item'; -import categoryIcon from 'flarum-categories/helpers/category-icon'; - -export default class CategoryNavItem extends NavItem { - view() { - var category = this.props.category; - var active = this.constructor.active(this.props); - return m('li'+(active ? '.active' : ''), m('a', {href: this.props.href, config: m.route, onclick: () => {app.cache.discussionList = null; m.redraw.strategy('none')}, style: (active && category) ? 'color: '+category.color() : '', title: category ? category.description() : ''}, [ - categoryIcon(category, {className: 'icon'}), - this.props.label - ])); - } - - static props(props) { - var category = props.category; - props.params.categories = category ? category.slug() : 'uncategorized'; - props.href = app.route('category', props.params); - props.label = category ? category.title() : 'Uncategorized'; - } -} diff --git a/extensions/tags/js/src/components/tag-hero.js b/extensions/tags/js/src/components/tag-hero.js new file mode 100644 index 000000000..e36b1b5ba --- /dev/null +++ b/extensions/tags/js/src/components/tag-hero.js @@ -0,0 +1,17 @@ +import Component from 'flarum/component'; + +export default class TagHero extends Component { + view() { + var tag = this.props.tag; + var color = tag.color(); + + return m('header.hero.tag-hero', {style: color ? 'color: #fff; background-color: '+tag.color() : ''}, [ + m('div.container', [ + m('div.container-narrow', [ + m('h2', tag.name()), + m('div.subtitle', tag.description()) + ]) + ]) + ]); + } +} diff --git a/extensions/tags/js/src/components/tag-nav-item.js b/extensions/tags/js/src/components/tag-nav-item.js new file mode 100644 index 000000000..e6aca2bca --- /dev/null +++ b/extensions/tags/js/src/components/tag-nav-item.js @@ -0,0 +1,39 @@ +import NavItem from 'flarum/components/nav-item'; +import tagIcon from 'flarum-tags/helpers/tag-icon'; + +export default class TagNavItem extends NavItem { + view() { + var tag = this.props.tag; + var active = this.constructor.active(this.props); + var description = tag && tag.description(); + var children; + + if (active && tag) { + children = app.store.all('tags').filter(child => { + var parent = child.parent(); + return parent && parent.id() == tag.id(); + }); + } + + return m('li'+(active ? '.active' : ''), + m('a', { + href: this.props.href, + config: m.route, + onclick: () => {app.cache.discussionList = null; m.redraw.strategy('none')}, + style: (active && tag) ? 'color: '+tag.color() : '', + title: description || '' + }, [ + tagIcon(tag, {className: 'icon'}), + this.props.label + ]), + children && children.length ? m('ul.dropdown-menu', children.map(tag => TagNavItem.component({tag, params: this.props.params}))) : '' + ); + } + + static props(props) { + var tag = props.tag; + props.params.tags = tag ? tag.slug() : 'untagged'; + props.href = app.route('tag', props.params); + props.label = tag ? tag.name() : 'Untagged'; + } +} diff --git a/extensions/tags/js/src/components/categories-page.js b/extensions/tags/js/src/components/tags-page.js similarity index 96% rename from extensions/tags/js/src/components/categories-page.js rename to extensions/tags/js/src/components/tags-page.js index 4bf407de6..bd5a34ae7 100644 --- a/extensions/tags/js/src/components/categories-page.js +++ b/extensions/tags/js/src/components/tags-page.js @@ -2,7 +2,7 @@ import Component from 'flarum/component'; import WelcomeHero from 'flarum/components/welcome-hero'; import icon from 'flarum/helpers/icon'; -export default class CategoriesPage extends Component { +export default class TagsPage extends Component { constructor(props) { super(props); diff --git a/extensions/tags/js/src/helpers/category-icon.js b/extensions/tags/js/src/helpers/category-icon.js deleted file mode 100644 index 2cefb14fc..000000000 --- a/extensions/tags/js/src/helpers/category-icon.js +++ /dev/null @@ -1,12 +0,0 @@ -export default function categoryIcon(category, attrs) { - attrs = attrs || {}; - - if (category) { - attrs.style = attrs.style || {}; - attrs.style.backgroundColor = category.color(); - } else { - attrs.className = (attrs.className || '')+' uncategorized'; - } - - return m('span.icon.category-icon', attrs); -} diff --git a/extensions/tags/js/src/helpers/category-label.js b/extensions/tags/js/src/helpers/category-label.js deleted file mode 100644 index dffa257d0..000000000 --- a/extensions/tags/js/src/helpers/category-label.js +++ /dev/null @@ -1,12 +0,0 @@ -export default function categoryLabel(category, attrs) { - attrs = attrs || {}; - - if (category) { - attrs.style = attrs.style || {}; - attrs.style.backgroundColor = attrs.style.color = category.color(); - } else { - attrs.className = (attrs.className || '')+' uncategorized'; - } - - return m('span.category-label', attrs, m('span.category-label-text', category ? category.title() : 'Uncategorized')); -} diff --git a/extensions/tags/js/src/helpers/tag-icon.js b/extensions/tags/js/src/helpers/tag-icon.js new file mode 100644 index 000000000..91f4de7c3 --- /dev/null +++ b/extensions/tags/js/src/helpers/tag-icon.js @@ -0,0 +1,12 @@ +export default function tagIcon(tag, attrs) { + attrs = attrs || {}; + + if (tag) { + attrs.style = attrs.style || {}; + attrs.style.backgroundColor = tag.color(); + } else { + attrs.className = (attrs.className || '')+' untagged'; + } + + return m('span.icon.tag-icon', attrs); +} diff --git a/extensions/tags/js/src/helpers/tag-label.js b/extensions/tags/js/src/helpers/tag-label.js new file mode 100644 index 000000000..c49ef2eb3 --- /dev/null +++ b/extensions/tags/js/src/helpers/tag-label.js @@ -0,0 +1,24 @@ +export default function tagsLabel(tag, attrs) { + attrs = attrs || {}; + attrs.style = attrs.style || {}; + attrs.className = attrs.className || ''; + + var link = attrs.link; + delete attrs.link; + if (link) { + attrs.href = app.route('tag', {tags: tag.slug()}); + attrs.config = m.route; + } + + if (tag) { + var color = tag.color(); + if (color) { + attrs.style.backgroundColor = attrs.style.color = color; + attrs.className += ' colored'; + } + } else { + attrs.className += ' untagged'; + } + + return m((link ? 'a' : 'span')+'.tag-label', attrs, m('span.tag-label-text', tag ? tag.name() : 'Untagged')); +} diff --git a/extensions/tags/js/src/helpers/tags-label.js b/extensions/tags/js/src/helpers/tags-label.js new file mode 100644 index 000000000..b2e288814 --- /dev/null +++ b/extensions/tags/js/src/helpers/tags-label.js @@ -0,0 +1,19 @@ +import tagLabel from 'flarum-tags/helpers/tag-label'; + +export default function tagsLabel(tags, attrs) { + attrs = attrs || {}; + var children = []; + + var link = attrs.link; + delete attrs.link; + + if (tags) { + tags.forEach(tag => { + children.push(tagLabel(tag, {link})); + }); + } else { + children.push(tagLabel()); + } + + return m('span.tags-label', attrs, children); +} diff --git a/extensions/tags/js/src/models/category.js b/extensions/tags/js/src/models/category.js deleted file mode 100644 index 83d3e6b8e..000000000 --- a/extensions/tags/js/src/models/category.js +++ /dev/null @@ -1,13 +0,0 @@ -import Model from 'flarum/model'; - -class Category extends Model {} - -Category.prototype.id = Model.prop('id'); -Category.prototype.title = Model.prop('title'); -Category.prototype.slug = Model.prop('slug'); -Category.prototype.description = Model.prop('description'); -Category.prototype.color = Model.prop('color'); -Category.prototype.discussionsCount = Model.prop('discussionsCount'); -Category.prototype.position = Model.prop('position'); - -export default Category; diff --git a/extensions/tags/js/src/models/tag.js b/extensions/tags/js/src/models/tag.js new file mode 100644 index 000000000..0ce8c1271 --- /dev/null +++ b/extensions/tags/js/src/models/tag.js @@ -0,0 +1,18 @@ +import Model from 'flarum/model'; + +class Tag extends Model {} + +Tag.prototype.id = Model.prop('id'); +Tag.prototype.name = Model.prop('name'); +Tag.prototype.slug = Model.prop('slug'); +Tag.prototype.description = Model.prop('description'); +Tag.prototype.color = Model.prop('color'); +Tag.prototype.backgroundUrl = Model.prop('backgroundUrl'); +Tag.prototype.iconUrl = Model.prop('iconUrl'); +Tag.prototype.discussionsCount = Model.prop('discussionsCount'); +Tag.prototype.position = Model.prop('position'); +Tag.prototype.parent = Model.one('parent'); +Tag.prototype.defaultSort = Model.prop('defaultSort'); +Tag.prototype.isChild = Model.prop('isChild'); + +export default Tag; diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less new file mode 100644 index 000000000..9d6266bc4 --- /dev/null +++ b/extensions/tags/less/extension.less @@ -0,0 +1,93 @@ +.tag-label { + font-size: 85%; + font-weight: 600; + display: inline-block; + padding: 0.2em 0.55em; + border-radius: @border-radius-base; + background: @fl-body-secondary-color; + + &.untagged { + background: transparent; + border: 1px dotted @fl-body-muted-color; + color: @fl-body-muted-color; + } + + &.colored { + & .tag-label-text { + color: #fff !important; + } + } + + .discussion-hero .tags-label & { + background: transparent; + border-radius: 4px !important; + + &.colored { + margin-right: 5px; + background: #fff !important; + color: @fl-body-muted-color; + + & .tag-label-text { + color: inherit !important; + } + } + } + + .discussion-moved-post & { + margin: 0 2px; + } +} +.tags-label { + .discussion-summary & { + margin-right: 10px; + } + + & .tag-label { + border-radius: 0; + margin-right: 1px; + + &:first-child { + border-radius: @border-radius-base 0 0 @border-radius-base; + } + &:last-child { + border-radius: 0 @border-radius-base @border-radius-base 0; + } + &:first-child:last-child { + border-radius: @border-radius-base; + } + } +} + +// @todo give all
  • s a class in core, get rid of block-item +.discussion-hero { + & .block-item { + margin-top: 15px; + } +} + +.tag-icon { + border-radius: @border-radius-base; + width: 16px; + height: 16px; + display: inline-block; + vertical-align: -3px; + margin-left: 1px; + background: @fl-body-secondary-color; + + &.untagged { + border: 1px dotted @fl-body-muted-color; + background: transparent; + } +} +.side-nav .dropdown-menu > li > .dropdown-menu { + margin-bottom: 10px; + + & .tag-icon { + display: none; + } + & > li > a { + padding-top: 4px; + padding-bottom: 4px; + margin-left: 10px; + } +} diff --git a/extensions/tags/migrations/2015_02_24_000000_create_categories_table.php b/extensions/tags/migrations/2015_02_24_000000_create_categories_table.php deleted file mode 100644 index 7d4af43ed..000000000 --- a/extensions/tags/migrations/2015_02_24_000000_create_categories_table.php +++ /dev/null @@ -1,35 +0,0 @@ -increments('id'); - $table->string('title'); - $table->string('slug'); - $table->text('description'); - $table->string('color'); - $table->integer('discussions_count')->unsigned()->default(0); - $table->integer('position')->nullable(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::drop('categories'); - } -} diff --git a/extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php b/extensions/tags/migrations/2015_02_24_000000_create_discussions_tags_table.php similarity index 50% rename from extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php rename to extensions/tags/migrations/2015_02_24_000000_create_discussions_tags_table.php index 9d4753334..1674c4687 100644 --- a/extensions/tags/migrations/2015_02_24_000000_add_category_to_discussions.php +++ b/extensions/tags/migrations/2015_02_24_000000_create_discussions_tags_table.php @@ -3,7 +3,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class AddCategoryToDiscussions extends Migration +class CreateDiscussionsTagsTable extends Migration { /** * Run the migrations. @@ -12,8 +12,10 @@ class AddCategoryToDiscussions extends Migration */ public function up() { - Schema::table('discussions', function (Blueprint $table) { - $table->integer('category_id')->unsigned()->nullable(); + Schema::create('discussions_tags', function (Blueprint $table) { + $table->integer('discussion_id')->unsigned(); + $table->integer('tag_id')->unsigned(); + $table->primary(['discussion_id', 'tag_id']); }); } @@ -24,8 +26,6 @@ class AddCategoryToDiscussions extends Migration */ public function down() { - Schema::table('discussions', function (Blueprint $table) { - $table->dropColumn('category_id'); - }); + Schema::drop('discussions_tags'); } } diff --git a/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php b/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php new file mode 100644 index 000000000..a3b1f64bc --- /dev/null +++ b/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php @@ -0,0 +1,39 @@ +increments('id'); + $table->string('name', 100); + $table->string('slug', 100); + $table->text('description')->nullable(); + $table->string('color', 50)->nullable(); + $table->string('background_path', 100)->nullable(); + $table->string('icon_path', 100)->nullable(); + $table->integer('discussions_count')->unsigned()->default(0); + $table->integer('position')->nullable(); + $table->integer('parent_id')->unsigned()->nullable(); + $table->string('default_sort', 50)->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('tags'); + } +} diff --git a/extensions/tags/migrations/2015_02_24_000000_create_users_tags_table.php b/extensions/tags/migrations/2015_02_24_000000_create_users_tags_table.php new file mode 100644 index 000000000..9a3f35bfd --- /dev/null +++ b/extensions/tags/migrations/2015_02_24_000000_create_users_tags_table.php @@ -0,0 +1,33 @@ +integer('user_id')->unsigned(); + $table->integer('tag_id')->unsigned(); + $table->dateTime('read_time')->nullable(); + $table->boolean('is_hidden')->default(0); + $table->primary(['user_id', 'tag_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('users_tags'); + } +} diff --git a/extensions/tags/src/Category.php b/extensions/tags/src/Category.php deleted file mode 100644 index 65bb38817..000000000 --- a/extensions/tags/src/Category.php +++ /dev/null @@ -1,8 +0,0 @@ -categories = $categories; - } - - /** - * Apply conditions to the searcher, given matches from the gambit's - * regex. - * - * @param array $matches The matches from the gambit's regex. - * @param \Flarum\Core\Search\SearcherInterface $searcher - * @return void - */ - public function conditions($matches, SearcherInterface $searcher) - { - $slugs = explode(',', trim($matches[1], '"')); - - $searcher->query()->where(function ($query) use ($slugs) { - foreach ($slugs as $slug) { - if ($slug === 'uncategorized') { - $query->orWhereNull('category_id'); - } else { - $id = $this->categories->getIdForSlug($slug); - $query->orWhere('category_id', $id); - } - } - }); - } -} diff --git a/extensions/tags/src/CategorySerializer.php b/extensions/tags/src/CategorySerializer.php deleted file mode 100644 index 79674ab7c..000000000 --- a/extensions/tags/src/CategorySerializer.php +++ /dev/null @@ -1,33 +0,0 @@ - $category->title, - 'description' => $category->description, - 'slug' => $category->slug, - 'color' => $category->color, - 'discussionsCount' => (int) $category->discussions_count, - 'position' => (int) $category->position - ]; - - return $this->extendAttributes($category, $attributes); - } -} diff --git a/extensions/tags/src/EloquentCategoryRepository.php b/extensions/tags/src/EloquentTagRepository.php similarity index 74% rename from extensions/tags/src/EloquentCategoryRepository.php rename to extensions/tags/src/EloquentTagRepository.php index a04a2408e..d11d73385 100644 --- a/extensions/tags/src/EloquentCategoryRepository.php +++ b/extensions/tags/src/EloquentTagRepository.php @@ -1,13 +1,13 @@ -scopeVisibleForUser($query, $user)->get(); } /** - * Get the ID of a category with the given slug. + * Get the ID of a tag with the given slug. * * @param string $slug * @param \Flarum\Core\Models\User|null $user @@ -29,7 +29,7 @@ class EloquentCategoryRepository implements CategoryRepositoryInterface */ public function getIdForSlug($slug, User $user = null) { - $query = Category::where('slug', 'like', $slug); + $query = Tag::where('slug', 'like', $slug); return $this->scopeVisibleForUser($query, $user)->pluck('id'); } diff --git a/extensions/tags/src/Handlers/CategoryPreloader.php b/extensions/tags/src/Handlers/TagPreloader.php similarity index 51% rename from extensions/tags/src/Handlers/CategoryPreloader.php rename to extensions/tags/src/Handlers/TagPreloader.php index a6ac6e9f8..cb238cb1c 100755 --- a/extensions/tags/src/Handlers/CategoryPreloader.php +++ b/extensions/tags/src/Handlers/TagPreloader.php @@ -1,10 +1,10 @@ -action->actor); - $event->view->data = array_merge($event->view->data, $serializer->collection(Category::orderBy('position')->get())->toArray()); + $serializer = new TagSerializer($event->action->actor, null, ['parent']); + $event->view->data = array_merge($event->view->data, $serializer->collection(Tag::orderBy('position')->get())->toArray()); } } diff --git a/extensions/tags/src/Tag.php b/extensions/tags/src/Tag.php new file mode 100644 index 000000000..2ed3b74db --- /dev/null +++ b/extensions/tags/src/Tag.php @@ -0,0 +1,8 @@ +tags = $tags; + } + + /** + * Apply conditions to the searcher, given matches from the gambit's + * regex. + * + * @param array $matches The matches from the gambit's regex. + * @param \Flarum\Core\Search\SearcherInterface $searcher + * @return void + */ + public function conditions($matches, SearcherInterface $searcher) + { + $slugs = explode(',', trim($matches[1], '"')); + + $searcher->query()->where(function ($query) use ($slugs) { + foreach ($slugs as $slug) { + if ($slug === 'uncategorized') { + $query->orWhereNotExists(function ($query) { + $query->select(app('db')->raw(1)) + ->from('discussions_tags') + ->whereRaw('discussion_id = discussions.id'); + }); + } else { + $id = $this->tags->getIdForSlug($slug); + + $query->orWhereExists(function ($query) use ($id) { + $query->select(app('db')->raw(1)) + ->from('discussions_tags') + ->whereRaw('discussion_id = discussions.id AND tag_id = ?', [$id]); + }); + } + } + }); + } +} diff --git a/extensions/tags/src/CategoryRepositoryInterface.php b/extensions/tags/src/TagRepositoryInterface.php similarity index 67% rename from extensions/tags/src/CategoryRepositoryInterface.php rename to extensions/tags/src/TagRepositoryInterface.php index 01c0875a9..3e74b1ed5 100644 --- a/extensions/tags/src/CategoryRepositoryInterface.php +++ b/extensions/tags/src/TagRepositoryInterface.php @@ -1,11 +1,11 @@ - $tag->name, + 'description' => $tag->description, + 'slug' => $tag->slug, + 'color' => $tag->color, + 'backgroundUrl' => $tag->background_path, + 'iconUrl' => $tag->icon_path, + 'discussionsCount' => (int) $tag->discussions_count, + 'position' => $tag->position === null ? null : (int) $tag->position, + 'defaultSort' => $tag->default_sort, + 'isChild' => (bool) $tag->parent_id + ]; + + return $this->extendAttributes($tag, $attributes); + } + + protected function parent() + { + return $this->hasOne('Flarum\Tags\TagSerializer'); + } +} diff --git a/extensions/tags/src/TagsServiceProvider.php b/extensions/tags/src/TagsServiceProvider.php new file mode 100644 index 000000000..4d4e1d157 --- /dev/null +++ b/extensions/tags/src/TagsServiceProvider.php @@ -0,0 +1,64 @@ +extend( + new ForumAssets([ + __DIR__.'/../js/dist/extension.js', + __DIR__.'/../less/extension.less' + ]), + + new EventSubscribers([ + // 'Flarum\Categories\Handlers\DiscussionMovedNotifier', + 'Flarum\Tags\Handlers\TagPreloader', + // 'Flarum\Categories\Handlers\CategorySaver' + ]), + + new Relationship('Flarum\Core\Models\Discussion', 'tags', function ($model) { + return $model->belongsToMany('Flarum\Tags\Tag', 'discussions_tags'); + }), + + new SerializeRelationship('Flarum\Api\Serializers\DiscussionBasicSerializer', 'hasMany', 'tags', 'Flarum\Tags\TagSerializer'), + + new ApiInclude(['discussions.index', 'discussions.show'], 'tags', true), + + (new Permission('discussion.editTags')) + ->serialize() + ->grant(function ($grant, $user) { + $grant->where('start_user_id', $user->id); + // @todo add limitations to time etc. according to a config setting + }), + + new DiscussionGambit('Flarum\Tags\TagGambit') + ); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register() + { + $this->app->bind( + 'Flarum\Tags\TagRepositoryInterface', + 'Flarum\Tags\EloquentTagRepository' + ); + } +} From e3b26b48a9e5d6bf4438b948a2f6448bc2d4800e Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 12 Jun 2015 16:43:41 +0930 Subject: [PATCH 042/554] New tag selection modal when composing a discussion Also numerous bug fixes. Still WIP --- extensions/tags/js/bootstrap.js | 8 +- extensions/tags/js/src/add-tag-composer.js | 54 ++++ .../tags/js/src/add-tag-discussion-control.js | 18 ++ extensions/tags/js/src/add-tag-labels.js | 9 +- ...n.js => discussion-tagged-notification.js} | 0 ...oved-post.js => discussion-tagged-post.js} | 0 .../src/components/move-discussion-modal.js | 58 ----- .../js/src/components/tag-discussion-modal.js | 234 ++++++++++++++++++ extensions/tags/js/src/helpers/tags-label.js | 3 +- extensions/tags/js/src/utils/sort-tags.js | 25 ++ extensions/tags/less/extension.less | 120 +++++++++ .../2015_02_24_000000_create_tags_table.php | 9 +- ...onWasMoved.php => DiscussionWasTagged.php} | 12 +- .../tags/src/Handlers/CategorySaver.php | 35 --- extensions/tags/src/Handlers/TagSaver.php | 56 +++++ extensions/tags/src/TagsServiceProvider.php | 6 +- 16 files changed, 536 insertions(+), 111 deletions(-) create mode 100644 extensions/tags/js/src/add-tag-composer.js create mode 100644 extensions/tags/js/src/add-tag-discussion-control.js rename extensions/tags/js/src/components/{discussion-moved-notification.js => discussion-tagged-notification.js} (100%) rename extensions/tags/js/src/components/{discussion-moved-post.js => discussion-tagged-post.js} (100%) delete mode 100644 extensions/tags/js/src/components/move-discussion-modal.js create mode 100644 extensions/tags/js/src/components/tag-discussion-modal.js create mode 100644 extensions/tags/js/src/utils/sort-tags.js rename extensions/tags/src/Events/{DiscussionWasMoved.php => DiscussionWasTagged.php} (75%) delete mode 100755 extensions/tags/src/Handlers/CategorySaver.php create mode 100755 extensions/tags/src/Handlers/TagSaver.php diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 3570940d4..0a6e0e70f 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -8,6 +8,8 @@ import TagsPage from 'flarum-tags/components/tags-page'; import addTagList from 'flarum-tags/add-tag-list'; import addTagFilter from 'flarum-tags/add-tag-filter'; import addTagLabels from 'flarum-tags/add-tag-labels'; +import addTagDiscussionControl from 'flarum-tags/add-tag-discussion-control'; +import addTagComposer from 'flarum-tags/add-tag-composer'; app.initializers.add('flarum-tags', function() { // Register routes. @@ -17,7 +19,7 @@ app.initializers.add('flarum-tags', function() { // Register models. app.store.models['tags'] = Tag; Discussion.prototype.tags = Model.many('tags'); - Discussion.prototype.canMove = Model.prop('canMove'); + Discussion.prototype.canTag = Model.prop('canTag'); // Add a list of tags to the index navigation. addTagList(); @@ -28,7 +30,7 @@ app.initializers.add('flarum-tags', function() { // Add tags to the discussion list and discussion hero. addTagLabels(); - // addMoveDiscussionControl(); + addTagDiscussionControl(); - // addDiscussionComposer(); + addTagComposer(); }); diff --git a/extensions/tags/js/src/add-tag-composer.js b/extensions/tags/js/src/add-tag-composer.js new file mode 100644 index 000000000..ab2cd983f --- /dev/null +++ b/extensions/tags/js/src/add-tag-composer.js @@ -0,0 +1,54 @@ +import { extend, override } from 'flarum/extension-utils'; +import IndexPage from 'flarum/components/index-page'; +import DiscussionComposer from 'flarum/components/discussion-composer'; +import icon from 'flarum/helpers/icon'; + +import TagDiscussionModal from 'flarum-tags/components/tag-discussion-modal'; +import tagsLabel from 'flarum-tags/helpers/tags-label'; + +export default function() { + override(IndexPage.prototype, 'composeNewDiscussion', function(original, deferred) { + var tag = app.store.getBy('tags', 'slug', this.params().tags); + + app.modal.show( + new TagDiscussionModal({ + selectedTags: tag ? [tag] : [], + onsubmit: tags => { + original(deferred).then(component => component.tags(tags)); + } + }) + ); + + return deferred.promise; + }); + + // Add tag-selection abilities to the discussion composer. + DiscussionComposer.prototype.tags = m.prop([]); + DiscussionComposer.prototype.chooseTags = function() { + app.modal.show( + new TagDiscussionModal({ + selectedTags: this.tags().slice(0), + onsubmit: tags => { + this.tags(tags); + this.$('textarea').focus(); + } + }) + ); + }; + + // Add a tag-selection menu to the discussion composer's header, after the + // title. + extend(DiscussionComposer.prototype, 'headerItems', function(items) { + var tags = this.tags(); + + items.add('tags', m('a[href=javascript:;][tabindex=-1].control-change-tags', {onclick: this.chooseTags.bind(this)}, [ + tagsLabel(tags) + ])); + }); + + // Add the selected tags as data to submit to the server. + extend(DiscussionComposer.prototype, 'data', function(data) { + data.links = data.links || {}; + data.links.tags = this.tags(); + }); +}; diff --git a/extensions/tags/js/src/add-tag-discussion-control.js b/extensions/tags/js/src/add-tag-discussion-control.js new file mode 100644 index 000000000..449179a7d --- /dev/null +++ b/extensions/tags/js/src/add-tag-discussion-control.js @@ -0,0 +1,18 @@ +import { extend } from 'flarum/extension-utils'; +import Discussion from 'flarum/models/discussion'; +import ActionButton from 'flarum/components/action-button'; + +import TagDiscussionModal from 'flarum-tags/components/tag-discussion-modal'; + +export default function() { + // Add a control allowing the discussion to be moved to another category. + extend(Discussion.prototype, 'controls', function(items) { + if (this.canTag()) { + items.add('tags', ActionButton.component({ + label: 'Edit Tags', + icon: 'tag', + onclick: () => app.modal.show(new TagDiscussionModal({ discussion: this })) + }), {after: 'rename'}); + } + }); +}; diff --git a/extensions/tags/js/src/add-tag-labels.js b/extensions/tags/js/src/add-tag-labels.js index 11edc01bb..61e70dd88 100644 --- a/extensions/tags/js/src/add-tag-labels.js +++ b/extensions/tags/js/src/add-tag-labels.js @@ -4,12 +4,13 @@ import DiscussionPage from 'flarum/components/discussion-page'; import DiscussionHero from 'flarum/components/discussion-hero'; import tagsLabel from 'flarum-tags/helpers/tags-label'; +import sortTags from 'flarum-tags/utils/sort-tags'; export default function() { // Add tag labels to each discussion in the discussion list. extend(DiscussionList.prototype, 'infoItems', function(items, discussion) { var tags = discussion.tags(); - if (tags) { + if (tags && tags.length) { items.add('tags', tagsLabel(tags.filter(tag => tag.slug() !== this.props.params.tags)), {first: true}); } }); @@ -21,8 +22,8 @@ export default function() { // Restyle a discussion's hero to use its first tag's color. extend(DiscussionHero.prototype, 'view', function(view) { - var tags = this.props.discussion.tags(); - if (tags) { + var tags = sortTags(this.props.discussion.tags()); + if (tags && tags.length) { view.attrs.style = 'color: #fff; background-color: '+tags[0].color(); } }); @@ -31,7 +32,7 @@ export default function() { // before the title. Put the title on its own line. extend(DiscussionHero.prototype, 'items', function(items) { var tags = this.props.discussion.tags(); - if (tags) { + if (tags && tags.length) { items.add('tags', tagsLabel(tags, {link: true}), {before: 'title'}); items.title.content.wrapperClass = 'block-item'; diff --git a/extensions/tags/js/src/components/discussion-moved-notification.js b/extensions/tags/js/src/components/discussion-tagged-notification.js similarity index 100% rename from extensions/tags/js/src/components/discussion-moved-notification.js rename to extensions/tags/js/src/components/discussion-tagged-notification.js diff --git a/extensions/tags/js/src/components/discussion-moved-post.js b/extensions/tags/js/src/components/discussion-tagged-post.js similarity index 100% rename from extensions/tags/js/src/components/discussion-moved-post.js rename to extensions/tags/js/src/components/discussion-tagged-post.js diff --git a/extensions/tags/js/src/components/move-discussion-modal.js b/extensions/tags/js/src/components/move-discussion-modal.js deleted file mode 100644 index cf8a8722f..000000000 --- a/extensions/tags/js/src/components/move-discussion-modal.js +++ /dev/null @@ -1,58 +0,0 @@ -import Component from 'flarum/component'; -import DiscussionPage from 'flarum/components/discussion-page'; -import icon from 'flarum/helpers/icon'; -import categoryLabel from 'flarum-categories/helpers/category-label'; - -export default class MoveDiscussionModal extends Component { - constructor(props) { - super(props); - - this.categories = m.prop(app.store.all('categories')); - } - - view() { - var discussion = this.props.discussion; - var discussionCategory = discussion && discussion.category(); - - return m('div.modal-dialog.modal-move-discussion', [ - m('div.modal-content', [ - m('button.btn.btn-icon.btn-link.close.back-control', {onclick: app.modal.close.bind(app.modal)}, icon('times')), - m('div.modal-header', m('h3.title-control', discussion - ? ['Move ', m('em', discussion.title()), ' from ', categoryLabel(discussionCategory), ' to...'] - : ['Start a Discussion In...'])), - m('div', [ - m('ul.category-list', [ - this.categories().map(category => - (discussion && discussionCategory && category.id() === discussionCategory.id()) ? '' : m('li.category-tile', {style: 'background-color: '+category.color()}, [ - m('a[href=javascript:;]', {onclick: this.save.bind(this, category)}, [ - m('h3.title', category.title()), - m('p.description', category.description()), - m('span.count', category.discussionsCount()+' discussions'), - ]) - ]) - ) - ]) - ]) - ]) - ]); - } - - save(category) { - var discussion = this.props.discussion; - - if (discussion) { - discussion.save({links: {category}}).then(discussion => { - if (app.current instanceof DiscussionPage) { - app.current.stream.sync(); - } - m.redraw(); - }); - } - - this.props.onchange && this.props.onchange(category); - - app.modal.close(); - - m.redraw.strategy('none'); - } -} diff --git a/extensions/tags/js/src/components/tag-discussion-modal.js b/extensions/tags/js/src/components/tag-discussion-modal.js new file mode 100644 index 000000000..2b6d00653 --- /dev/null +++ b/extensions/tags/js/src/components/tag-discussion-modal.js @@ -0,0 +1,234 @@ +import FormModal from 'flarum/components/form-modal'; +import DiscussionPage from 'flarum/components/discussion-page'; +import highlight from 'flarum/helpers/highlight'; +import classList from 'flarum/utils/class-list'; + +import tagLabel from 'flarum-tags/helpers/tag-label'; +import tagIcon from 'flarum-tags/helpers/tag-icon'; +import sortTags from 'flarum-tags/utils/sort-tags'; + +export default class TagDiscussionModal extends FormModal { + constructor(props) { + super(props); + + this.tags = sortTags(app.store.all('tags')); + + this.selected = m.prop([]); + if (this.props.selectedTags) { + this.props.selectedTags.map(this.addTag.bind(this)); + } else if (this.props.discussion) { + this.props.discussion.tags().map(this.addTag.bind(this)); + } + + this.filter = m.prop(''); + + this.index = m.prop(this.tags[0].id()); + + this.focused = m.prop(false); + } + + addTag(tag) { + var selected = this.selected(); + var parent = tag.parent(); + if (parent) { + var index = selected.indexOf(parent); + if (index === -1) { + selected.push(parent); + } + } + selected.push(tag); + } + + removeTag(tag) { + var selected = this.selected(); + var index = selected.indexOf(tag); + selected.splice(index, 1); + selected.filter(selected => selected.parent() && selected.parent() === tag).forEach(child => { + var index = selected.indexOf(child); + selected.splice(index, 1); + }); + } + + view() { + var discussion = this.props.discussion; + var selected = this.selected(); + + var tags = this.tags; + var filter = this.filter().toLowerCase(); + + if (filter) { + tags = tags.filter(tag => tag.name().substr(0, filter.length).toLowerCase() === filter); + } + + if (tags.indexOf(this.index()) === -1) { + this.index(tags[0]); + } + + return super.view({ + className: 'tag-discussion-modal', + title: discussion + ? ['Edit Tags for ', m('em', discussion.title())] + : 'Start a Discussion About...', + body: [ + m('div.tags-form', [ + m('div.tags-input.form-control', {className: this.focused() ? 'focus' : ''}, [ + m('span.tags-input-selected', selected.map(tag => + m('span.remove-tag', {onclick: () => { + this.removeTag(tag); + this.ready(); + }}, tagLabel(tag)) + )), + m('input.form-control', { + placeholder: !selected.length ? 'Choose one or more topics' : '', + value: this.filter(), + oninput: m.withAttr('value', this.filter), + onkeydown: this.onkeydown.bind(this), + onfocus: () => this.focused(true), + onblur: () => this.focused(false) + }) + ]), + m('button[type=submit].btn.btn-primary', {disabled: !selected.length}, 'Confirm') + ]) + ], + footer: [ + m('ul.tags-select', tags.map(tag => + filter || !tag.parent() || selected.indexOf(tag.parent()) !== -1 + ? m('li', { + 'data-index': tag.id(), + className: classList({ + category: tag.position() !== null, + selected: selected.indexOf(tag) !== -1, + active: this.index() == tag + }), + style: { + color: tag.color() + }, + onmouseover: () => { + this.index(tag); + }, + onclick: () => { + var selected = this.selected(); + var index = selected.indexOf(tag); + if (index !== -1) { + this.removeTag(tag); + } else { + this.addTag(tag); + } + if (this.filter()) { + this.filter(''); + this.index(this.tags[0]); + } + this.ready(); + } + }, [ + tagIcon(tag), + m('span.name', highlight(tag.name(), filter)), + tag.description() ? m('span.description', tag.description()) : '' + ]) + : '' + )) + ] + }); + } + + onkeydown(e) { + switch (e.which) { + case 40: + case 38: // Down/Up + e.preventDefault(); + this.setIndex(this.getCurrentNumericIndex() + (e.which === 40 ? 1 : -1), true); + break; + + case 13: // Return + e.preventDefault(); + if (e.metaKey || e.ctrlKey || this.selected().indexOf(this.index()) !== -1) { + if (this.selected().length) { + this.$('form').submit(); + } + } else { + this.getItem(this.index())[0].dispatchEvent(new Event('click')); + } + break; + + case 8: // Backspace + if (e.target.selectionStart == 0 && e.target.selectionEnd == 0) { + e.preventDefault(); + var selected = this.selected(); + selected.splice(selected.length - 1, 1); + } + } + } + + selectableItems() { + return this.$('.tags-select > li'); + } + + getCurrentNumericIndex() { + return this.selectableItems().index( + this.getItem(this.index()) + ); + } + + getItem(index) { + var $items = this.selectableItems(); + return $items.filter('[data-index='+index.id()+']'); + } + + setIndex(index, scrollToItem) { + var $items = this.selectableItems(); + var $dropdown = $items.parent(); + + if (index < 0) { + index = $items.length - 1; + } else if (index >= $items.length) { + index = 0; + } + + var $item = $items.eq(index); + + this.index(app.store.getById('tags', $item.attr('data-index'))); + + m.redraw(); + + if (scrollToItem) { + var dropdownScroll = $dropdown.scrollTop(); + var dropdownTop = $dropdown.offset().top; + var dropdownBottom = dropdownTop + $dropdown.outerHeight(); + var itemTop = $item.offset().top; + var itemBottom = itemTop + $item.outerHeight(); + + var scrollTop; + if (itemTop < dropdownTop) { + scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top')); + } else if (itemBottom > dropdownBottom) { + scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom')); + } + + if (typeof scrollTop !== 'undefined') { + $dropdown.stop(true).animate({scrollTop}, 100); + } + } + } + + onsubmit(e) { + e.preventDefault(); + + var discussion = this.props.discussion; + var tags = this.selected(); + + if (discussion) { + discussion.save({links: {tags}}).then(discussion => { + if (app.current instanceof DiscussionPage) { + app.current.stream.sync(); + } + m.redraw(); + }); + } + + this.props.onsubmit && this.props.onsubmit(tags); + + app.modal.close(); + + m.redraw.strategy('none'); + } +} diff --git a/extensions/tags/js/src/helpers/tags-label.js b/extensions/tags/js/src/helpers/tags-label.js index b2e288814..d725f9fef 100644 --- a/extensions/tags/js/src/helpers/tags-label.js +++ b/extensions/tags/js/src/helpers/tags-label.js @@ -1,4 +1,5 @@ import tagLabel from 'flarum-tags/helpers/tag-label'; +import sortTags from 'flarum-tags/utils/sort-tags'; export default function tagsLabel(tags, attrs) { attrs = attrs || {}; @@ -8,7 +9,7 @@ export default function tagsLabel(tags, attrs) { delete attrs.link; if (tags) { - tags.forEach(tag => { + sortTags(tags).forEach(tag => { children.push(tagLabel(tag, {link})); }); } else { diff --git a/extensions/tags/js/src/utils/sort-tags.js b/extensions/tags/js/src/utils/sort-tags.js new file mode 100644 index 000000000..9e3a72750 --- /dev/null +++ b/extensions/tags/js/src/utils/sort-tags.js @@ -0,0 +1,25 @@ +export default function sortTags(tags) { + return tags.slice(0).sort((a, b) => { + var aPos = a.position(); + var bPos = b.position(); + + var aParent = a.parent(); + var bParent = b.parent(); + + if (aPos === null && bPos === null) { + return b.discussionsCount() - a.discussionsCount(); + } else if (bPos === null) { + return -1; + } else if (aPos === null) { + return 1; + } else if (aParent === bParent) { + return aPos - bPos; + } else if (aParent) { + return aParent.position() - bPos; + } else if (bParent) { + return aPos - bParent.position(); + } + + return 0; + }); +}; diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index 9d6266bc4..bc14a3d5b 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -5,6 +5,7 @@ padding: 0.2em 0.55em; border-radius: @border-radius-base; background: @fl-body-secondary-color; + color: @fl-body-muted-color; &.untagged { background: transparent; @@ -91,3 +92,122 @@ margin-left: 10px; } } + +.tag-discussion-modal { + & .modal-header { + background: @fl-body-secondary-color; + padding: 20px; + + & h3 { + text-align: left; + color: @fl-body-muted-color; + font-size: 16px; + } + } + & .modal-body { + padding: 0 20px 20px; + } + & .modal-footer { + padding: 1px 0 0; + text-align: left; + } +} +.tags-form { + padding-right: 100px; + overflow: hidden; + + & .tags-input { + float: left; + } + & .btn { + margin-right: -100px; + float: right; + width: 85px; + } +} +.tags-input { + padding-top: 0; + padding-bottom: 0; + overflow: hidden; + white-space: nowrap; + + & input { + display: inline; + outline: none; + margin-top: -2px; + border: 0 !important; + padding: 0; + width: 100%; + margin-right: -100%; + } + & .remove-tag { + cursor: not-allowed; + } +} +.tags-input-selected { + & .tag-label { + margin-right: 5px; + } +} + +.tags-select { + padding: 0; + margin: 0; + list-style: none; + overflow: auto; + max-height: 50vh; + + & > li { + padding: 7px 20px; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + &.category { + padding-top: 10px; + padding-bottom: 10px; + + & .name { + font-size: 16px; + } + &.selected .tag-icon:before { + color: #fff; + } + } + &.active { + background: @fl-body-secondary-color; + } + & .name { + display: inline-block; + width: 150px; + margin-right: 10px; + margin-left: 10px; + } + & .description { + color: @fl-body-muted-color; + font-size: 12px; + } + &.selected { + & .tag-icon { + position: relative; + + &:before { + .fa(); + content: @fa-var-check; + color: @fl-body-muted-color; + position: absolute; + font-size: 14px; + width: 100%; + text-align: center; + padding-top: 1px; + } + } + } + & mark { + font-weight: bold; + background: none; + box-shadow: none; + color: inherit; + } + } +} diff --git a/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php b/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php index a3b1f64bc..f74187ff7 100644 --- a/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php +++ b/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php @@ -17,13 +17,20 @@ class CreateTagsTable extends Migration $table->string('name', 100); $table->string('slug', 100); $table->text('description')->nullable(); + $table->string('color', 50)->nullable(); $table->string('background_path', 100)->nullable(); + $table->string('background_mode', 100)->nullable(); $table->string('icon_path', 100)->nullable(); - $table->integer('discussions_count')->unsigned()->default(0); + $table->integer('position')->nullable(); $table->integer('parent_id')->unsigned()->nullable(); $table->string('default_sort', 50)->nullable(); + + $table->integer('discussions_count')->unsigned()->default(0); + $table->integer('last_time')->unsigned()->nullable(); + $table->integer('last_discussion_id')->unsigned()->nullable(); + }); } diff --git a/extensions/tags/src/Events/DiscussionWasMoved.php b/extensions/tags/src/Events/DiscussionWasTagged.php similarity index 75% rename from extensions/tags/src/Events/DiscussionWasMoved.php rename to extensions/tags/src/Events/DiscussionWasTagged.php index 6393accbe..33c96bd2f 100644 --- a/extensions/tags/src/Events/DiscussionWasMoved.php +++ b/extensions/tags/src/Events/DiscussionWasTagged.php @@ -1,9 +1,9 @@ -discussion = $discussion; $this->user = $user; - $this->oldCategoryId = $oldCategoryId; + $this->oldTags = $oldTags; } } diff --git a/extensions/tags/src/Handlers/CategorySaver.php b/extensions/tags/src/Handlers/CategorySaver.php deleted file mode 100755 index 8074da9ed..000000000 --- a/extensions/tags/src/Handlers/CategorySaver.php +++ /dev/null @@ -1,35 +0,0 @@ -listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved'); - } - - public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event) - { - if (isset($event->command->data['links']['category']['linkage'])) { - $linkage = $event->command->data['links']['category']['linkage']; - - $categoryId = (int) $linkage['id']; - $discussion = $event->discussion; - $user = $event->command->user; - - $oldCategoryId = (int) $discussion->category_id; - - if ($oldCategoryId === $categoryId) { - return; - } - - $discussion->category_id = $categoryId; - - if ($discussion->exists) { - $discussion->raise(new DiscussionWasMoved($discussion, $user, $oldCategoryId)); - } - } - } -} diff --git a/extensions/tags/src/Handlers/TagSaver.php b/extensions/tags/src/Handlers/TagSaver.php new file mode 100755 index 000000000..d2d107259 --- /dev/null +++ b/extensions/tags/src/Handlers/TagSaver.php @@ -0,0 +1,56 @@ +listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved'); + $events->listen('Flarum\Core\Events\DiscussionWasDeleted', __CLASS__.'@whenDiscussionWasDeleted'); + } + + public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event) + { + if (isset($event->command->data['links']['tags']['linkage'])) { + $discussion = $event->discussion; + $user = $event->command->user; + $linkage = (array) $event->command->data['links']['tags']['linkage']; + + $newTagIds = []; + foreach ($linkage as $link) { + $newTagIds[] = (int) $link['id']; + } + + $oldTags = []; + + if ($discussion->exists) { + $oldTags = $discussion->tags()->get(); + $oldTagIds = $oldTags->lists('id'); + + if ($oldTagIds == $newTagIds) { + return; + } + } + + // @todo is there a better (safer) way to do this? + // maybe store some info on the discussion model and then use the + // DiscussionWasTagged event to actually save the data? + Discussion::saved(function ($discussion) use ($newTagIds) { + $discussion->tags()->sync($newTagIds); + }); + + if ($discussion->exists) { + $discussion->raise(new DiscussionWasTagged($discussion, $user, $oldTags->all())); + } + } + } + + public function whenDiscussionWasDeleted(DiscussionWasDeleted $event) + { + $event->discussion->tags()->sync([]); + } +} diff --git a/extensions/tags/src/TagsServiceProvider.php b/extensions/tags/src/TagsServiceProvider.php index 4d4e1d157..328149a45 100644 --- a/extensions/tags/src/TagsServiceProvider.php +++ b/extensions/tags/src/TagsServiceProvider.php @@ -25,9 +25,9 @@ class TagsServiceProvider extends ServiceProvider ]), new EventSubscribers([ - // 'Flarum\Categories\Handlers\DiscussionMovedNotifier', + // 'Flarum\Tags\Handlers\DiscussionTaggedNotifier', 'Flarum\Tags\Handlers\TagPreloader', - // 'Flarum\Categories\Handlers\CategorySaver' + 'Flarum\Tags\Handlers\TagSaver' ]), new Relationship('Flarum\Core\Models\Discussion', 'tags', function ($model) { @@ -38,7 +38,7 @@ class TagsServiceProvider extends ServiceProvider new ApiInclude(['discussions.index', 'discussions.show'], 'tags', true), - (new Permission('discussion.editTags')) + (new Permission('discussion.tag')) ->serialize() ->grant(function ($grant, $user) { $grant->where('start_user_id', $user->id); From bec1f73c363032e364607e1fdd4970b025cb94ef Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 15 Jun 2015 09:00:30 +0930 Subject: [PATCH 043/554] Add event post when a discussion's tags are changed --- extensions/tags/js/bootstrap.js | 3 ++ .../discussion-tagged-notification.js | 16 ------- .../src/components/discussion-tagged-post.js | 23 +++++++--- extensions/tags/less/extension.less | 7 ++- .../tags/src/DiscussionMovedNotification.php | 38 ---------------- ...MovedPost.php => DiscussionTaggedPost.php} | 24 +++++----- .../src/Handlers/DiscussionMovedNotifier.php | 45 ------------------- .../src/Handlers/DiscussionTaggedNotifier.php | 31 +++++++++++++ extensions/tags/src/TagsServiceProvider.php | 11 +++-- 9 files changed, 75 insertions(+), 123 deletions(-) delete mode 100644 extensions/tags/js/src/components/discussion-tagged-notification.js delete mode 100644 extensions/tags/src/DiscussionMovedNotification.php rename extensions/tags/src/{DiscussionMovedPost.php => DiscussionTaggedPost.php} (67%) delete mode 100755 extensions/tags/src/Handlers/DiscussionMovedNotifier.php create mode 100755 extensions/tags/src/Handlers/DiscussionTaggedNotifier.php diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 0a6e0e70f..4d6317826 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -5,6 +5,7 @@ import IndexPage from 'flarum/components/index-page'; import Tag from 'flarum-tags/models/tag'; import TagsPage from 'flarum-tags/components/tags-page'; +import DiscussionTaggedPost from 'flarum-tags/components/discussion-tagged-post'; import addTagList from 'flarum-tags/add-tag-list'; import addTagFilter from 'flarum-tags/add-tag-filter'; import addTagLabels from 'flarum-tags/add-tag-labels'; @@ -21,6 +22,8 @@ app.initializers.add('flarum-tags', function() { Discussion.prototype.tags = Model.many('tags'); Discussion.prototype.canTag = Model.prop('canTag'); + app.postComponentRegistry['discussionTagged'] = DiscussionTaggedPost; + // Add a list of tags to the index navigation. addTagList(); diff --git a/extensions/tags/js/src/components/discussion-tagged-notification.js b/extensions/tags/js/src/components/discussion-tagged-notification.js deleted file mode 100644 index fc77d7bcb..000000000 --- a/extensions/tags/js/src/components/discussion-tagged-notification.js +++ /dev/null @@ -1,16 +0,0 @@ -import Notification from 'flarum/components/notification'; -import username from 'flarum/helpers/username'; -import categoryLabel from 'flarum-categories/helpers/category-label'; - -export default class DiscussionMovedNotification extends Notification { - view() { - var notification = this.props.notification; - var discussion = notification.subject(); - - return super.view({ - href: app.route.discussion(discussion, notification.content().postNumber), - icon: 'arrow-right', - content: [username(notification.sender()), ' moved to ', categoryLabel(discussion.category())] - }); - } -} diff --git a/extensions/tags/js/src/components/discussion-tagged-post.js b/extensions/tags/js/src/components/discussion-tagged-post.js index 54870f5e1..f67954051 100644 --- a/extensions/tags/js/src/components/discussion-tagged-post.js +++ b/extensions/tags/js/src/components/discussion-tagged-post.js @@ -1,12 +1,25 @@ import EventPost from 'flarum/components/event-post'; -import categoryLabel from 'flarum-categories/helpers/category-label'; +import tagsLabel from 'flarum-tags/helpers/tags-label'; -export default class DiscussionMovedPost extends EventPost { +export default class DiscussionTaggedPost extends EventPost { view() { var post = this.props.post; - var oldCategory = app.store.getById('categories', post.content()[0]); - var newCategory = app.store.getById('categories', post.content()[1]); + var oldTags = post.content()[0]; + var newTags = post.content()[1]; - return super.view('arrow-right', ['moved the discussion from ', categoryLabel(oldCategory), ' to ', categoryLabel(newCategory), '.']); + var added = newTags.filter(tag => oldTags.indexOf(tag) === -1).map(id => app.store.getById('tags', id)); + var removed = oldTags.filter(tag => newTags.indexOf(tag) === -1).map(id => app.store.getById('tags', id)); + var total = added.concat(removed); + + var build = function(verb, tags, only) { + return tags.length ? [verb, ' ', only && tags.length == 1 ? 'the ' : '', tagsLabel(tags)] : ''; + }; + + return super.view('tag', [ + build('added', added, !removed.length), + added.length && removed.length ? ' and ' : '', + build('removed', removed, !added.length), + total.length ? (total.length == 1 ? ' tag.' : ' tags.') : '' + ]); } } diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index bc14a3d5b..7842c72a4 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -33,15 +33,14 @@ } } } - - .discussion-moved-post & { - margin: 0 2px; - } } .tags-label { .discussion-summary & { margin-right: 10px; } + .discussion-tagged-post & { + margin: 0 2px; + } & .tag-label { border-radius: 0; diff --git a/extensions/tags/src/DiscussionMovedNotification.php b/extensions/tags/src/DiscussionMovedNotification.php deleted file mode 100644 index 10a82ba84..000000000 --- a/extensions/tags/src/DiscussionMovedNotification.php +++ /dev/null @@ -1,38 +0,0 @@ -post = $post; - } - - public function getSubject() - { - return $this->post->discussion; - } - - public function getSender() - { - return $this->post->user; - } - - public function getData() - { - return ['postNumber' => (int) $this->post->number]; - } - - public static function getType() - { - return 'discussionMoved'; - } - - public static function getSubjectModel() - { - return 'Flarum\Core\Models\Discussion'; - } -} diff --git a/extensions/tags/src/DiscussionMovedPost.php b/extensions/tags/src/DiscussionTaggedPost.php similarity index 67% rename from extensions/tags/src/DiscussionMovedPost.php rename to extensions/tags/src/DiscussionTaggedPost.php index 01b8a87da..987f4676f 100755 --- a/extensions/tags/src/DiscussionMovedPost.php +++ b/extensions/tags/src/DiscussionTaggedPost.php @@ -1,16 +1,16 @@ -content = static::buildContent($previous->content[0], $this->content[1]); + $previous->time = $this->time; + return $previous; } @@ -38,15 +40,15 @@ class DiscussionMovedPost extends EventPost * * @param integer $discussionId * @param integer $userId - * @param integer $oldCategoryId - * @param integer $newCategoryId + * @param array $oldTagIds + * @param array $newTagIds * @return static */ - public static function reply($discussionId, $userId, $oldCategoryId, $newCategoryId) + public static function reply($discussionId, $userId, array $oldTagIds, array $newTagIds) { $post = new static; - $post->content = static::buildContent($oldCategoryId, $newCategoryId); + $post->content = static::buildContent($oldTagIds, $newTagIds); $post->time = time(); $post->discussion_id = $discussionId; $post->user_id = $userId; @@ -57,12 +59,12 @@ class DiscussionMovedPost extends EventPost /** * Build the content attribute. * - * @param boolean $oldCategoryId The old category ID. - * @param boolean $newCategoryId The new category ID. + * @param array $oldTagIds + * @param array $newTagIds * @return array */ - public static function buildContent($oldCategoryId, $newCategoryId) + public static function buildContent(array $oldTagIds, array $newTagIds) { - return [$oldCategoryId, $newCategoryId]; + return [$oldTagIds, $newTagIds]; } } diff --git a/extensions/tags/src/Handlers/DiscussionMovedNotifier.php b/extensions/tags/src/Handlers/DiscussionMovedNotifier.php deleted file mode 100755 index cb7723902..000000000 --- a/extensions/tags/src/Handlers/DiscussionMovedNotifier.php +++ /dev/null @@ -1,45 +0,0 @@ -notifications = $notifications; - } - - /** - * Register the listeners for the subscriber. - * - * @param \Illuminate\Contracts\Events\Dispatcher $events - */ - public function subscribe(Dispatcher $events) - { - $events->listen('Flarum\Categories\Events\DiscussionWasMoved', __CLASS__.'@whenDiscussionWasMoved'); - } - - public function whenDiscussionWasMoved(DiscussionWasMoved $event) - { - $post = DiscussionMovedPost::reply( - $event->discussion->id, - $event->user->id, - $event->oldCategoryId, - $event->discussion->category_id - ); - - $post = $event->discussion->addPost($post); - - if ($event->discussion->start_user_id !== $event->user->id) { - $notification = new DiscussionMovedNotification($post); - - $this->notifications->sync($notification, $post->exists ? [$event->discussion->startUser] : []); - } - } -} diff --git a/extensions/tags/src/Handlers/DiscussionTaggedNotifier.php b/extensions/tags/src/Handlers/DiscussionTaggedNotifier.php new file mode 100755 index 000000000..2c915d548 --- /dev/null +++ b/extensions/tags/src/Handlers/DiscussionTaggedNotifier.php @@ -0,0 +1,31 @@ +listen('Flarum\Tags\Events\DiscussionWasTagged', __CLASS__.'@whenDiscussionWasTagged'); + } + + public function whenDiscussionWasTagged(DiscussionWasTagged $event) + { + $post = DiscussionTaggedPost::reply( + $event->discussion->id, + $event->user->id, + array_pluck($event->oldTags, 'id'), + $event->discussion->tags()->lists('id') + ); + + $post = $event->discussion->addPost($post); + } +} diff --git a/extensions/tags/src/TagsServiceProvider.php b/extensions/tags/src/TagsServiceProvider.php index 328149a45..2a43f0c09 100644 --- a/extensions/tags/src/TagsServiceProvider.php +++ b/extensions/tags/src/TagsServiceProvider.php @@ -8,6 +8,7 @@ use Flarum\Extend\SerializeRelationship; use Flarum\Extend\ApiInclude; use Flarum\Extend\Permission; use Flarum\Extend\DiscussionGambit; +use Flarum\Extend\PostType; class TagsServiceProvider extends ServiceProvider { @@ -18,14 +19,14 @@ class TagsServiceProvider extends ServiceProvider */ public function boot() { - $this->extend( + $this->extend([ new ForumAssets([ __DIR__.'/../js/dist/extension.js', __DIR__.'/../less/extension.less' ]), new EventSubscribers([ - // 'Flarum\Tags\Handlers\DiscussionTaggedNotifier', + 'Flarum\Tags\Handlers\DiscussionTaggedNotifier', 'Flarum\Tags\Handlers\TagPreloader', 'Flarum\Tags\Handlers\TagSaver' ]), @@ -45,8 +46,10 @@ class TagsServiceProvider extends ServiceProvider // @todo add limitations to time etc. according to a config setting }), - new DiscussionGambit('Flarum\Tags\TagGambit') - ); + new DiscussionGambit('Flarum\Tags\TagGambit'), + + new PostType('Flarum\Tags\DiscussionTaggedPost') + ]); } /** From d5b1d3bdb2ab82062b8d7726a8dc1e3b0cdd7fd5 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 15 Jun 2015 12:16:41 +0930 Subject: [PATCH 044/554] Don't show tags label if there are no tags to show --- extensions/tags/js/src/add-tag-labels.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/src/add-tag-labels.js b/extensions/tags/js/src/add-tag-labels.js index 61e70dd88..d8e3a0bca 100644 --- a/extensions/tags/js/src/add-tag-labels.js +++ b/extensions/tags/js/src/add-tag-labels.js @@ -10,8 +10,11 @@ export default function() { // Add tag labels to each discussion in the discussion list. extend(DiscussionList.prototype, 'infoItems', function(items, discussion) { var tags = discussion.tags(); - if (tags && tags.length) { - items.add('tags', tagsLabel(tags.filter(tag => tag.slug() !== this.props.params.tags)), {first: true}); + if (tags) { + tags = tags.filter(tag => tag.slug() !== this.props.params.tags); + if (tags.length) { + items.add('tags', tagsLabel(tags), {first: true}); + } } }); From 85d7dc8752df89c4b3b6f2cbbb53190ba3ae80e9 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 15 Jun 2015 12:20:26 +0930 Subject: [PATCH 045/554] Add new fields, include tags on the new forum API action --- extensions/tags/js/src/models/tag.js | 8 +++++++- extensions/tags/src/Handlers/TagPreloader.php | 19 ------------------- extensions/tags/src/Tag.php | 12 ++++++++++++ extensions/tags/src/TagSerializer.php | 9 ++++++++- extensions/tags/src/TagsServiceProvider.php | 9 ++++++++- 5 files changed, 35 insertions(+), 22 deletions(-) delete mode 100755 extensions/tags/src/Handlers/TagPreloader.php diff --git a/extensions/tags/js/src/models/tag.js b/extensions/tags/js/src/models/tag.js index 0ce8c1271..85b010da5 100644 --- a/extensions/tags/js/src/models/tag.js +++ b/extensions/tags/js/src/models/tag.js @@ -6,13 +6,19 @@ Tag.prototype.id = Model.prop('id'); Tag.prototype.name = Model.prop('name'); Tag.prototype.slug = Model.prop('slug'); Tag.prototype.description = Model.prop('description'); + Tag.prototype.color = Model.prop('color'); Tag.prototype.backgroundUrl = Model.prop('backgroundUrl'); +Tag.prototype.backgroundMode = Model.prop('backgroundMode'); Tag.prototype.iconUrl = Model.prop('iconUrl'); -Tag.prototype.discussionsCount = Model.prop('discussionsCount'); + Tag.prototype.position = Model.prop('position'); Tag.prototype.parent = Model.one('parent'); Tag.prototype.defaultSort = Model.prop('defaultSort'); Tag.prototype.isChild = Model.prop('isChild'); +Tag.prototype.discussionsCount = Model.prop('discussionsCount'); +Tag.prototype.lastTime = Model.prop('lastTime', Model.date); +Tag.prototype.lastDiscussion = Model.one('lastDiscussion'); + export default Tag; diff --git a/extensions/tags/src/Handlers/TagPreloader.php b/extensions/tags/src/Handlers/TagPreloader.php deleted file mode 100755 index cb238cb1c..000000000 --- a/extensions/tags/src/Handlers/TagPreloader.php +++ /dev/null @@ -1,19 +0,0 @@ -listen('Flarum\Forum\Events\RenderView', __CLASS__.'@renderForum'); - } - - public function renderForum(RenderView $event) - { - $serializer = new TagSerializer($event->action->actor, null, ['parent']); - $event->view->data = array_merge($event->view->data, $serializer->collection(Tag::orderBy('position')->get())->toArray()); - } -} diff --git a/extensions/tags/src/Tag.php b/extensions/tags/src/Tag.php index 2ed3b74db..40db19454 100644 --- a/extensions/tags/src/Tag.php +++ b/extensions/tags/src/Tag.php @@ -5,4 +5,16 @@ use Flarum\Core\Models\Model; class Tag extends Model { protected $table = 'tags'; + + protected $dates = ['last_time']; + + public function parent() + { + return $this->belongsTo('Flarum\Tags\Tag', 'parent_id'); + } + + public function lastDiscussion() + { + return $this->belongsTo('Flarum\Core\Models\Discussion', 'last_discussion_id'); + } } diff --git a/extensions/tags/src/TagSerializer.php b/extensions/tags/src/TagSerializer.php index bdc38ba50..197bc7a17 100644 --- a/extensions/tags/src/TagSerializer.php +++ b/extensions/tags/src/TagSerializer.php @@ -25,11 +25,13 @@ class TagSerializer extends BaseSerializer 'slug' => $tag->slug, 'color' => $tag->color, 'backgroundUrl' => $tag->background_path, + 'backgroundMode' => $tag->background_mode, 'iconUrl' => $tag->icon_path, 'discussionsCount' => (int) $tag->discussions_count, 'position' => $tag->position === null ? null : (int) $tag->position, 'defaultSort' => $tag->default_sort, - 'isChild' => (bool) $tag->parent_id + 'isChild' => (bool) $tag->parent_id, + 'lastTime' => $tag->last_time ? $tag->last_time->toRFC3339String() : null ]; return $this->extendAttributes($tag, $attributes); @@ -39,4 +41,9 @@ class TagSerializer extends BaseSerializer { return $this->hasOne('Flarum\Tags\TagSerializer'); } + + protected function lastDiscussion() + { + return $this->hasOne('Flarum\Api\Serializers\DiscussionSerializer'); + } } diff --git a/extensions/tags/src/TagsServiceProvider.php b/extensions/tags/src/TagsServiceProvider.php index 2a43f0c09..4a0b97768 100644 --- a/extensions/tags/src/TagsServiceProvider.php +++ b/extensions/tags/src/TagsServiceProvider.php @@ -27,7 +27,6 @@ class TagsServiceProvider extends ServiceProvider new EventSubscribers([ 'Flarum\Tags\Handlers\DiscussionTaggedNotifier', - 'Flarum\Tags\Handlers\TagPreloader', 'Flarum\Tags\Handlers\TagSaver' ]), @@ -39,6 +38,14 @@ class TagsServiceProvider extends ServiceProvider new ApiInclude(['discussions.index', 'discussions.show'], 'tags', true), + new Relationship('Flarum\Core\Models\Forum', 'tags', function ($model) { + return Tag::query(); + }), + + new SerializeRelationship('Flarum\Api\Serializers\ForumSerializer', 'hasMany', 'tags', 'Flarum\Tags\TagSerializer'), + + new ApiInclude(['forum.show'], ['tags', 'tags.parent', 'tags.lastDiscussion'], true), + (new Permission('discussion.tag')) ->serialize() ->grant(function ($grant, $user) { From ed2fb779e6be2d47936050b9510204d92a95bacd Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 15 Jun 2015 12:20:49 +0930 Subject: [PATCH 046/554] Allow non-pinned tags to be colored --- extensions/tags/js/src/components/tag-discussion-modal.js | 3 ++- extensions/tags/less/extension.less | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/src/components/tag-discussion-modal.js b/extensions/tags/js/src/components/tag-discussion-modal.js index 2b6d00653..429d5990b 100644 --- a/extensions/tags/js/src/components/tag-discussion-modal.js +++ b/extensions/tags/js/src/components/tag-discussion-modal.js @@ -96,7 +96,8 @@ export default class TagDiscussionModal extends FormModal { ? m('li', { 'data-index': tag.id(), className: classList({ - category: tag.position() !== null, + pinned: tag.position() !== null, + colored: !!tag.color(), selected: selected.indexOf(tag) !== -1, active: this.index() == tag }), diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index 7842c72a4..c68cc472f 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -162,13 +162,15 @@ text-overflow: ellipsis; cursor: pointer; - &.category { + &.pinned { padding-top: 10px; padding-bottom: 10px; & .name { font-size: 16px; } + } + &.colored { &.selected .tag-icon:before { color: #fff; } From f3f0684eee83eb1806f06ef1ac451cb44032f2f8 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 15 Jun 2015 12:21:08 +0930 Subject: [PATCH 047/554] Add dedicated tags page --- extensions/tags/js/bootstrap.js | 4 + extensions/tags/js/src/add-tag-list.js | 7 +- .../tags/js/src/components/tags-page.js | 77 ++++++++--- extensions/tags/less/extension.less | 120 ++++++++++++++++++ 4 files changed, 185 insertions(+), 23 deletions(-) diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js index 4d6317826..f6b326bc2 100644 --- a/extensions/tags/js/bootstrap.js +++ b/extensions/tags/js/bootstrap.js @@ -17,6 +17,10 @@ app.initializers.add('flarum-tags', function() { app.routes['tags'] = ['/tags', TagsPage.component()]; app.routes['tag'] = ['/t/:tags', IndexPage.component()]; + app.route.tag = function(tag) { + return app.route('tag', { tags: tag.slug() }); + }; + // Register models. app.store.models['tags'] = Tag; Discussion.prototype.tags = Model.many('tags'); diff --git a/extensions/tags/js/src/add-tag-list.js b/extensions/tags/js/src/add-tag-list.js index 1e4714e33..8f3c142ee 100644 --- a/extensions/tags/js/src/add-tag-list.js +++ b/extensions/tags/js/src/add-tag-list.js @@ -4,25 +4,26 @@ import NavItem from 'flarum/components/nav-item'; import Separator from 'flarum/components/separator'; import TagNavItem from 'flarum-tags/components/tag-nav-item'; +import TagsPage from 'flarum-tags/components/tags-page'; export default function() { // Add a link to the tags page, as well as a list of all the tags, // to the index page's sidebar. extend(IndexPage.prototype, 'navItems', function(items) { items.add('tags', NavItem.component({ - icon: 'reorder', + icon: 'th-large', label: 'Tags', href: app.route('tags'), config: m.route }), {last: true}); + if (app.current instanceof TagsPage) return; + items.add('separator', Separator.component(), {last: true}); var params = this.stickyParams(); var tags = app.store.all('tags'); - items.add('untagged', TagNavItem.component({params}), {last: true}); - var addTag = tag => { var currentTag = this.currentTag(); var active = currentTag === tag; diff --git a/extensions/tags/js/src/components/tags-page.js b/extensions/tags/js/src/components/tags-page.js index bd5a34ae7..89db7fc3a 100644 --- a/extensions/tags/js/src/components/tags-page.js +++ b/extensions/tags/js/src/components/tags-page.js @@ -1,38 +1,75 @@ import Component from 'flarum/component'; import WelcomeHero from 'flarum/components/welcome-hero'; +import IndexPage from 'flarum/components/index-page'; import icon from 'flarum/helpers/icon'; +import listItems from 'flarum/helpers/list-items'; +import abbreviateNumber from 'flarum/utils/abbreviate-number'; +import humanTime from 'flarum/helpers/human-time'; + +import sortTags from 'flarum-tags/utils/sort-tags'; export default class TagsPage extends Component { constructor(props) { super(props); - this.categories = m.prop(app.store.all('categories')); + this.tags = sortTags(app.store.all('tags').filter(tag => !tag.parent())); + + app.current = this; + app.history.push('tags'); } view() { - return m('div.categories-area', [ - m('div.title-control.categories-forum-title', app.config.forum_title), - WelcomeHero.component(), + var pinned = this.tags.filter(tag => tag.position() !== null); + var cloud = this.tags.filter(tag => tag.position() === null); + + return m('div.tags-area', {config: this.onload.bind(this)}, [ + IndexPage.prototype.hero(), m('div.container', [ - m('ul.category-list.category-list-tiles', [ - m('li.filter-tile', [ - m('a', {href: app.route('index'), config: m.route}, 'All Discussions'), - // m('ul.filter-list', [ - // m('li', m('a', {href: app.route('index'), config: m.route}, m('span', [icon('star'), ' Following']))), - // m('li', m('a', {href: app.route('index'), config: m.route}, m('span', [icon('envelope-o'), ' Inbox']))) - // ]) + m('nav.side-nav.index-nav', [ + m('ul', listItems(IndexPage.prototype.sidebarItems().toArray())) + ]), + m('div.offset-content.tags-content', [ + m('ul.tag-tiles', [ + pinned.map(tag => { + var lastDiscussion = tag.lastDiscussion(); + var children = app.store.all('tags').filter(child => { + var parent = child.parent(); + return parent && parent.id() == tag.id(); + }); + + return m('li.tag-tile', {style: 'background-color: '+tag.color()}, [ + m('a.tag-info', {href: app.route.tag(tag), config: m.route}, [ + m('h3.name', tag.name()), + m('p.description', tag.description()), + children ? m('div.children', children.map(tag => + m('a', {href: app.route.tag(tag), config: m.route, onclick: (e) => e.stopPropagation()}, tag.name()) + )) : '' + ]), + lastDiscussion + ? m('a.last-discussion', { + href: app.route.discussion(lastDiscussion, lastDiscussion.lastPostNumber()), + config: m.route + }, [m('span.title', lastDiscussion.title()), humanTime(lastDiscussion.lastTime())]) + : m('span.last-discussion') + ]); + }) ]), - this.categories().map(category => - m('li.category-tile', {style: 'background-color: '+category.color()}, [ - m('a', {href: app.route('category', {categories: category.slug()}), config: m.route}, [ - m('h3.title', category.title()), - m('p.description', category.description()), - m('span.count', category.discussionsCount()+' discussions'), - ]) - ]) - ) + m('div.tag-cloud', [ + m('h4', 'Tags'), + m('div.tag-cloud-content', cloud.map(tag => + m('a', {href: app.route.tag(tag), config: m.route, style: tag.color() ? 'color: '+tag.color() : ''}, tag.name()) + )) + ]) ]) ]) ]); } + + onload(element, isInitialized, context) { + IndexPage.prototype.onload.apply(this, arguments); + } + + onunload() { + IndexPage.prototype.onunload.apply(this); + } } diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index c68cc472f..08b9f27ac 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -212,3 +212,123 @@ } } } + + +.tag-tiles { + list-style-type: none; + padding: 0; + margin: 0; + overflow: hidden; + + & > li { + float: left; + width: ~"calc(50% - 1px)"; + height: 200px; + margin-right: 1px; + margin-bottom: 1px; + overflow: hidden; + + &:first-child { + border-radius: @border-radius-base 0 0 0; + } + &:nth-child(2) { + border-radius: 0 @border-radius-base 0 0; + } + &:nth-last-child(2):nth-child(even), &:last-child { + border-radius: 0 0 @border-radius-base 0; + } + &:nth-last-child(2):nth-child(odd), &:last-child:nth-child(odd) { + border-radius: 0 0 0 @border-radius-base; + } + } +} +.tag-tile { + position: relative; + + &, & a { + color: #fff; + } + & .tag-info, & .last-discussion { + padding: 20px; + text-decoration: none; + display: block; + position: absolute; + left: 0; + right: 0; + } + & > a { + transition: background 0.2s; + + &:hover { + background: fade(#000, 10%); + } + &:active { + background: fade(#000, 20%); + } + } + & .tag-info { + top: 0; + bottom: 55px; + padding-right: 20px; + + & .name { + font-size: 20px; + margin: 0 0 10px; + font-weight: normal; + } + & .description { + color: fade(#fff, 50%); + margin: 0 0 15px; + } + & .children { + text-transform: uppercase; + font-weight: 600; + font-size: 12px; + + & > a { + margin-right: 15px; + } + } + } + & .last-discussion { + bottom: 0; + height: 55px; + border-top: 1px solid rgba(0, 0, 0, 0.1); + padding-top: 17px; + padding-bottom: 17px; + color: fade(#fff, 50%); + + & .title { + margin-right: 15px; + } + } +} +.tag-cloud { + margin-top: 30px; + text-align: center; + + & h4 { + font-size: 12px; + font-weight: bold; + color: @fl-body-muted-color; + text-transform: uppercase; + margin-bottom: 15px; + + &:before { + .fa(); + content: @fa-var-tags; + margin-right: 5px; + font-size: 14px; + } + } +} +.tag-cloud-content { + font-size: 14px; + + &, & a { + color: @fl-body-muted-color; + } + & a { + margin: 0 6px; + } +} From 5345a1ef9e25930c856606809650c480422b4205 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 16 Jun 2015 21:58:18 +0930 Subject: [PATCH 048/554] Per-tag permissions! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pretty easy to implement with the groundwork I’ve done for permissions. (All the logic is in TagsServiceProvider currently) --- .../js/src/components/tag-discussion-modal.js | 4 +- extensions/tags/js/src/models/tag.js | 2 + .../2015_02_24_000000_create_tags_table.php | 1 + extensions/tags/src/Handlers/TagLoader.php | 25 ++++++ extensions/tags/src/Handlers/TagSaver.php | 9 ++ extensions/tags/src/Tag.php | 5 ++ extensions/tags/src/TagSerializer.php | 7 +- extensions/tags/src/TagsServiceProvider.php | 90 ++++++++++++++++--- 8 files changed, 129 insertions(+), 14 deletions(-) create mode 100755 extensions/tags/src/Handlers/TagLoader.php diff --git a/extensions/tags/js/src/components/tag-discussion-modal.js b/extensions/tags/js/src/components/tag-discussion-modal.js index 429d5990b..26e38d7fb 100644 --- a/extensions/tags/js/src/components/tag-discussion-modal.js +++ b/extensions/tags/js/src/components/tag-discussion-modal.js @@ -11,7 +11,7 @@ export default class TagDiscussionModal extends FormModal { constructor(props) { super(props); - this.tags = sortTags(app.store.all('tags')); + this.tags = sortTags(app.store.all('tags').filter(tag => tag.canStartDiscussion())); this.selected = m.prop([]); if (this.props.selectedTags) { @@ -28,6 +28,8 @@ export default class TagDiscussionModal extends FormModal { } addTag(tag) { + if (!tag.canStartDiscussion()) return; + var selected = this.selected(); var parent = tag.parent(); if (parent) { diff --git a/extensions/tags/js/src/models/tag.js b/extensions/tags/js/src/models/tag.js index 85b010da5..fc8b075bd 100644 --- a/extensions/tags/js/src/models/tag.js +++ b/extensions/tags/js/src/models/tag.js @@ -21,4 +21,6 @@ Tag.prototype.discussionsCount = Model.prop('discussionsCount'); Tag.prototype.lastTime = Model.prop('lastTime', Model.date); Tag.prototype.lastDiscussion = Model.one('lastDiscussion'); +Tag.prototype.canStartDiscussion = Model.prop('canStartDiscussion'); + export default Tag; diff --git a/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php b/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php index f74187ff7..855043944 100644 --- a/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php +++ b/extensions/tags/migrations/2015_02_24_000000_create_tags_table.php @@ -26,6 +26,7 @@ class CreateTagsTable extends Migration $table->integer('position')->nullable(); $table->integer('parent_id')->unsigned()->nullable(); $table->string('default_sort', 50)->nullable(); + $table->boolean('is_restricted')->default(0); $table->integer('discussions_count')->unsigned()->default(0); $table->integer('last_time')->unsigned()->nullable(); diff --git a/extensions/tags/src/Handlers/TagLoader.php b/extensions/tags/src/Handlers/TagLoader.php new file mode 100755 index 000000000..71bac1fc4 --- /dev/null +++ b/extensions/tags/src/Handlers/TagLoader.php @@ -0,0 +1,25 @@ +listen('Flarum\Api\Events\WillRespond', __CLASS__.'@whenWillRespond'); + } + + public function whenWillRespond(WillRespond $event) + { + if ($event->action instanceof ForumShowAction) { + $forum = $event->data; + + $query = Tag::whereVisibleTo($event->request->actor->getUser()); + + $forum->tags = $query->with('lastDiscussion')->get(); + $forum->tags_ids = $forum->tags->lists('id'); + } + } +} diff --git a/extensions/tags/src/Handlers/TagSaver.php b/extensions/tags/src/Handlers/TagSaver.php index d2d107259..739318e6c 100755 --- a/extensions/tags/src/Handlers/TagSaver.php +++ b/extensions/tags/src/Handlers/TagSaver.php @@ -1,9 +1,11 @@ can($user, 'startDiscussion')) { + throw new PermissionDeniedException; + } + } + $oldTags = []; if ($discussion->exists) { diff --git a/extensions/tags/src/Tag.php b/extensions/tags/src/Tag.php index 40db19454..2e2aabd4a 100644 --- a/extensions/tags/src/Tag.php +++ b/extensions/tags/src/Tag.php @@ -1,9 +1,14 @@ actor->getUser(); + $attributes = [ 'name' => $tag->name, 'description' => $tag->description, @@ -31,7 +33,8 @@ class TagSerializer extends BaseSerializer 'position' => $tag->position === null ? null : (int) $tag->position, 'defaultSort' => $tag->default_sort, 'isChild' => (bool) $tag->parent_id, - 'lastTime' => $tag->last_time ? $tag->last_time->toRFC3339String() : null + 'lastTime' => $tag->last_time ? $tag->last_time->toRFC3339String() : null, + 'canStartDiscussion' => $tag->can($user, 'startDiscussion') ]; return $this->extendAttributes($tag, $attributes); @@ -44,6 +47,6 @@ class TagSerializer extends BaseSerializer protected function lastDiscussion() { - return $this->hasOne('Flarum\Api\Serializers\DiscussionSerializer'); + return $this->hasOne('Flarum\Api\Serializers\DiscussionBasicSerializer'); } } diff --git a/extensions/tags/src/TagsServiceProvider.php b/extensions/tags/src/TagsServiceProvider.php index 4a0b97768..daaa4163c 100644 --- a/extensions/tags/src/TagsServiceProvider.php +++ b/extensions/tags/src/TagsServiceProvider.php @@ -6,9 +6,13 @@ use Flarum\Extend\EventSubscribers; use Flarum\Extend\Relationship; use Flarum\Extend\SerializeRelationship; use Flarum\Extend\ApiInclude; +use Flarum\Extend\ApiLink; use Flarum\Extend\Permission; use Flarum\Extend\DiscussionGambit; use Flarum\Extend\PostType; +use Flarum\Core\Models\Discussion; +use Flarum\Core\Models\Post; +use Flarum\Core\Models\User; class TagsServiceProvider extends ServiceProvider { @@ -27,7 +31,8 @@ class TagsServiceProvider extends ServiceProvider new EventSubscribers([ 'Flarum\Tags\Handlers\DiscussionTaggedNotifier', - 'Flarum\Tags\Handlers\TagSaver' + 'Flarum\Tags\Handlers\TagSaver', + 'Flarum\Tags\Handlers\TagLoader' ]), new Relationship('Flarum\Core\Models\Discussion', 'tags', function ($model) { @@ -38,25 +43,88 @@ class TagsServiceProvider extends ServiceProvider new ApiInclude(['discussions.index', 'discussions.show'], 'tags', true), - new Relationship('Flarum\Core\Models\Forum', 'tags', function ($model) { - return Tag::query(); - }), - new SerializeRelationship('Flarum\Api\Serializers\ForumSerializer', 'hasMany', 'tags', 'Flarum\Tags\TagSerializer'), - new ApiInclude(['forum.show'], ['tags', 'tags.parent', 'tags.lastDiscussion'], true), + new ApiInclude(['forum.show'], ['tags', 'tags.lastDiscussion'], true), + new ApiLink(['forum.show'], ['tags.parent'], true), (new Permission('discussion.tag')) - ->serialize() - ->grant(function ($grant, $user) { - $grant->where('start_user_id', $user->id); - // @todo add limitations to time etc. according to a config setting - }), + ->serialize(), + // ->grant(function ($grant, $user) { + // $grant->where('start_user_id', $user->id); + // // @todo add limitations to time etc. according to a config setting + // }), new DiscussionGambit('Flarum\Tags\TagGambit'), new PostType('Flarum\Tags\DiscussionTaggedPost') ]); + + Tag::scopeVisible(function ($query, User $user) { + $query->whereIn('id', $this->getTagsWithPermission($user, 'view')); + }); + + Tag::allow('startDiscussion', function (Tag $tag, User $user) { + if (! $tag->is_restricted || $user->hasPermission('tag'.$tag->id.'.startDiscussion')) { + return true; + } + }); + + Discussion::scopeVisible(function ($query, User $user) { + $query->whereNotExists(function ($query) use ($user) { + return $query->select(app('db')->raw(1)) + ->from('discussions_tags') + ->whereNotIn('tag_id', $this->getTagsWithPermission($user, 'view')) + ->whereRaw('discussion_id = discussions.id'); + }); + }); + + Discussion::allow('*', function (Discussion $discussion, User $user, $action) { + $tags = $discussion->getRelation('tags'); + + if (! count($tags)) { + return; + } + + $restricted = true; + + // If the discussion has a tag that has been restricted, and the user + // has this permission for that tag, then they are allowed. If the + // discussion only has tags that have been restricted, then the user + // *must* have permission for at least one of them. Otherwise, inherit + // global permissions. + foreach ($tags as $tag) { + if ($tag->is_restricted) { + if ($user->hasPermission('tag'.$tag->id.'.discussion.'.$action)) { + return true; + } + } else { + $restricted = false; + } + } + + if ($restricted) { + return false; + } + }); + + Post::allow('*', function (Post $post, User $user, $action) { + return $post->discussion->can($user, $action.'Posts'); + }); + } + + protected function getTagsWithPermission($user, $permission) { + static $tags; + if (!$tags) $tags = Tag::all(); + + $ids = []; + foreach ($tags as $tag) { + if (! $tag->is_restricted || $user->hasPermission('tag'.$tag->id.'.'.$permission)) { + $ids[] = $tag->id; + } + } + + return $ids; } /** From a31582de5a87ce5baef64d695e56a1b05eabf770 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 16 Jun 2015 21:58:38 +0930 Subject: [PATCH 049/554] Fix tag tiles border-radius --- extensions/tags/less/extension.less | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index 08b9f27ac..81bfa5943 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -229,16 +229,16 @@ overflow: hidden; &:first-child { - border-radius: @border-radius-base 0 0 0; + border-top-left-radius: @border-radius-base; } &:nth-child(2) { - border-radius: 0 @border-radius-base 0 0; + border-top-right-radius: @border-radius-base; } &:nth-last-child(2):nth-child(even), &:last-child { - border-radius: 0 0 @border-radius-base 0; + border-bottom-right-radius: @border-radius-base; } &:nth-last-child(2):nth-child(odd), &:last-child:nth-child(odd) { - border-radius: 0 0 0 @border-radius-base; + border-bottom-left-radius: @border-radius-base; } } } From d4f15858cac9f7ff8f5577cf0d6cdfe4b0fcbaa2 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 17 Jun 2015 12:48:22 +0930 Subject: [PATCH 050/554] Use new event name --- extensions/tags/src/Handlers/TagLoader.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/tags/src/Handlers/TagLoader.php b/extensions/tags/src/Handlers/TagLoader.php index 71bac1fc4..b11d3a440 100755 --- a/extensions/tags/src/Handlers/TagLoader.php +++ b/extensions/tags/src/Handlers/TagLoader.php @@ -1,6 +1,6 @@ listen('Flarum\Api\Events\WillRespond', __CLASS__.'@whenWillRespond'); + $events->listen('Flarum\Api\Events\WillSerializeData', __CLASS__.'@whenWillSerializeData'); } - public function whenWillRespond(WillRespond $event) + public function whenWillSerializeData(WillSerializeData $event) { if ($event->action instanceof ForumShowAction) { $forum = $event->data; From 59736524e0287728d0c282a183c7c0bb9a8d9da0 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 18 Jun 2015 12:46:02 +0930 Subject: [PATCH 051/554] Update APIs --- extensions/tags/src/Tag.php | 17 ++ extensions/tags/src/TagsServiceProvider.php | 235 ++++++++++---------- 2 files changed, 131 insertions(+), 121 deletions(-) diff --git a/extensions/tags/src/Tag.php b/extensions/tags/src/Tag.php index 2e2aabd4a..5ba5fb3ac 100644 --- a/extensions/tags/src/Tag.php +++ b/extensions/tags/src/Tag.php @@ -22,4 +22,21 @@ class Tag extends Model { return $this->belongsTo('Flarum\Core\Models\Discussion', 'last_discussion_id'); } + + public static function getVisibleTo($user) + { + static $tags; + if (!$tags) { + $tags = static::all(); + } + + $ids = []; + foreach ($tags as $tag) { + if (! $tag->is_restricted || $user->hasPermission('tag'.$tag->id.'.view')) { + $ids[] = $tag->id; + } + } + + return $ids; + } } diff --git a/extensions/tags/src/TagsServiceProvider.php b/extensions/tags/src/TagsServiceProvider.php index daaa4163c..3f368db50 100644 --- a/extensions/tags/src/TagsServiceProvider.php +++ b/extensions/tags/src/TagsServiceProvider.php @@ -1,132 +1,12 @@ extend([ - new ForumAssets([ - __DIR__.'/../js/dist/extension.js', - __DIR__.'/../less/extension.less' - ]), - - new EventSubscribers([ - 'Flarum\Tags\Handlers\DiscussionTaggedNotifier', - 'Flarum\Tags\Handlers\TagSaver', - 'Flarum\Tags\Handlers\TagLoader' - ]), - - new Relationship('Flarum\Core\Models\Discussion', 'tags', function ($model) { - return $model->belongsToMany('Flarum\Tags\Tag', 'discussions_tags'); - }), - - new SerializeRelationship('Flarum\Api\Serializers\DiscussionBasicSerializer', 'hasMany', 'tags', 'Flarum\Tags\TagSerializer'), - - new ApiInclude(['discussions.index', 'discussions.show'], 'tags', true), - - new SerializeRelationship('Flarum\Api\Serializers\ForumSerializer', 'hasMany', 'tags', 'Flarum\Tags\TagSerializer'), - - new ApiInclude(['forum.show'], ['tags', 'tags.lastDiscussion'], true), - new ApiLink(['forum.show'], ['tags.parent'], true), - - (new Permission('discussion.tag')) - ->serialize(), - // ->grant(function ($grant, $user) { - // $grant->where('start_user_id', $user->id); - // // @todo add limitations to time etc. according to a config setting - // }), - - new DiscussionGambit('Flarum\Tags\TagGambit'), - - new PostType('Flarum\Tags\DiscussionTaggedPost') - ]); - - Tag::scopeVisible(function ($query, User $user) { - $query->whereIn('id', $this->getTagsWithPermission($user, 'view')); - }); - - Tag::allow('startDiscussion', function (Tag $tag, User $user) { - if (! $tag->is_restricted || $user->hasPermission('tag'.$tag->id.'.startDiscussion')) { - return true; - } - }); - - Discussion::scopeVisible(function ($query, User $user) { - $query->whereNotExists(function ($query) use ($user) { - return $query->select(app('db')->raw(1)) - ->from('discussions_tags') - ->whereNotIn('tag_id', $this->getTagsWithPermission($user, 'view')) - ->whereRaw('discussion_id = discussions.id'); - }); - }); - - Discussion::allow('*', function (Discussion $discussion, User $user, $action) { - $tags = $discussion->getRelation('tags'); - - if (! count($tags)) { - return; - } - - $restricted = true; - - // If the discussion has a tag that has been restricted, and the user - // has this permission for that tag, then they are allowed. If the - // discussion only has tags that have been restricted, then the user - // *must* have permission for at least one of them. Otherwise, inherit - // global permissions. - foreach ($tags as $tag) { - if ($tag->is_restricted) { - if ($user->hasPermission('tag'.$tag->id.'.discussion.'.$action)) { - return true; - } - } else { - $restricted = false; - } - } - - if ($restricted) { - return false; - } - }); - - Post::allow('*', function (Post $post, User $user, $action) { - return $post->discussion->can($user, $action.'Posts'); - }); - } - - protected function getTagsWithPermission($user, $permission) { - static $tags; - if (!$tags) $tags = Tag::all(); - - $ids = []; - foreach ($tags as $tag) { - if (! $tag->is_restricted || $user->hasPermission('tag'.$tag->id.'.'.$permission)) { - $ids[] = $tag->id; - } - } - - return $ids; - } - /** * Register the service provider. * @@ -139,4 +19,117 @@ class TagsServiceProvider extends ServiceProvider 'Flarum\Tags\EloquentTagRepository' ); } + + /** + * Bootstrap the application events. + * + * @return void + */ + public function boot() + { + $this->extend([ + (new Extend\ForumClient()) + ->assets([ + __DIR__.'/../js/dist/extension.js', + __DIR__.'/../less/extension.less' + ]), + + (new Extend\Model('Flarum\Tags\Tag')) + // Hide tags that the user doesn't have permission to see. + ->scopeVisible(function ($query, User $user) { + $query->whereIn('id', Tag::getVisibleTo($user)); + }) + + // Allow the user to start discussions in tags which aren't + // restricted, or for which the user has explicitly been granted + // permission. + ->allow('startDiscussion', function (Tag $tag, User $user) { + if (! $tag->is_restricted || $user->hasPermission('tag'.$tag->id.'.startDiscussion')) { + return true; + } + }), + + // Expose the complete tag list to clients by adding it as a + // relationship to the /api/forum endpoint. Since the Forum model + // doesn't actually have a tags relationship, we will manually + // load and assign the tags data to it using an event listener. + (new Extend\ApiSerializer('Flarum\Api\Serializers\ForumSerializer')) + ->hasMany('tags', 'Flarum\Tags\TagSerializer'), + + (new Extend\ApiAction('Flarum\Api\Actions\Forum\ShowAction')) + ->addInclude('tags') + ->addInclude('tags.lastDiscussion') + ->addLink('tags.parent'), + + new Extend\EventSubscriber('Flarum\Tags\Handlers\TagLoader'), + + // Extend the Discussion model and API: add the tags relationship + // and modify permissions. + (new Extend\Model('Flarum\Core\Models\Discussion')) + ->belongsToMany('tags', 'Flarum\Tags\Tag', 'discussions_tags') + + // Hide discussions which have tags that the user is not allowed + // to see. + ->scopeVisible(function ($query, User $user) { + $query->whereNotExists(function ($query) use ($user) { + return $query->select(app('db')->raw(1)) + ->from('discussions_tags') + ->whereNotIn('tag_id', Tag::getVisibleTo($user)) + ->whereRaw('discussion_id = discussions.id'); + }); + }) + + // Wrap all discussion permission checks with some logic + // pertaining to the discussion's tags. If the discussion has a + // tag that has been restricted, and the user has this + // permission for that tag, then they are allowed. If the + // discussion only has tags that have been restricted, then the + // user *must* have permission for at least one of them. + ->allow('*', function (Discussion $discussion, User $user, $action) { + $tags = $discussion->getRelation('tags'); + + if (count($tags)) { + $restricted = true; + + foreach ($tags as $tag) { + if ($tag->is_restricted) { + if ($user->hasPermission('tag'.$tag->id.'.discussion.'.$action)) { + return true; + } + } else { + $restricted = false; + } + } + + if ($restricted) { + return false; + } + } + }), + + (new Extend\ApiSerializer('Flarum\Api\Serializers\DiscussionBasicSerializer')) + ->hasMany('tags', 'Flarum\Tags\TagSerializer') + ->attributes(function (&$attributes, $discussion, $user) { + $attributes['canTag'] = $discussion->can($user, 'tag'); + }), + + (new Extend\ApiAction([ + 'Flarum\Api\Actions\Discussions\IndexAction', + 'Flarum\Api\Actions\Discussions\ShowAction' + ])) + ->addInclude('tags'), + + // Add an event subscriber so that tags data is persisted when + // saving a discussion. + new Extend\EventSubscriber('Flarum\Tags\Handlers\TagSaver'), + + // Add a gambit that allows filtering discussions by tag(s). + new Extend\DiscussionGambit('Flarum\Tags\TagGambit'), + + // Add a new post type which indicates when a discussion's tags were + // changed. + new Extend\PostType('Flarum\Tags\DiscussionTaggedPost'), + new Extend\EventSubscriber('Flarum\Tags\Handlers\DiscussionTaggedNotifier') + ]); + } } From 9c047485f0f1c1060e8c488efdf6d2981c30185f Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 18 Jun 2015 17:44:02 +0930 Subject: [PATCH 052/554] Add routes to server --- extensions/tags/src/TagsServiceProvider.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/tags/src/TagsServiceProvider.php b/extensions/tags/src/TagsServiceProvider.php index 3f368db50..a1688999e 100644 --- a/extensions/tags/src/TagsServiceProvider.php +++ b/extensions/tags/src/TagsServiceProvider.php @@ -32,7 +32,9 @@ class TagsServiceProvider extends ServiceProvider ->assets([ __DIR__.'/../js/dist/extension.js', __DIR__.'/../less/extension.less' - ]), + ]) + ->route('get', '/t/{slug}', 'flarum-tags.forum.tag') + ->route('get', '/tags', 'flarum-tags.forum.tags'), (new Extend\Model('Flarum\Tags\Tag')) // Hide tags that the user doesn't have permission to see. From d656d0ddb508771719deef64f09bf5e539a32e58 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 08:18:27 +0930 Subject: [PATCH 053/554] Keep tag discussions_count up-to-date --- .../tags/src/Handlers/TagMetadataUpdater.php | 49 +++++++++++++++++++ extensions/tags/src/Handlers/TagSaver.php | 7 --- extensions/tags/src/TagsServiceProvider.php | 1 + 3 files changed, 50 insertions(+), 7 deletions(-) create mode 100755 extensions/tags/src/Handlers/TagMetadataUpdater.php diff --git a/extensions/tags/src/Handlers/TagMetadataUpdater.php b/extensions/tags/src/Handlers/TagMetadataUpdater.php new file mode 100755 index 000000000..cbc75fd49 --- /dev/null +++ b/extensions/tags/src/Handlers/TagMetadataUpdater.php @@ -0,0 +1,49 @@ +listen('Flarum\Core\Events\DiscussionWasStarted', __CLASS__.'@whenDiscussionWasStarted'); + $events->listen('Flarum\Tags\Events\DiscussionWasTagged', __CLASS__.'@whenDiscussionWasTagged'); + $events->listen('Flarum\Core\Events\DiscussionWasDeleted', __CLASS__.'@whenDiscussionWasDeleted'); + } + + public function whenDiscussionWasStarted(DiscussionWasStarted $event) + { + $tags = $event->discussion->tags(); + + $this->updateTagCounts($tags, 1); + } + + public function whenDiscussionWasTagged(DiscussionWasTagged $event) + { + $oldTags = Tag::whereIn('id', array_pluck($event->oldTags, 'id')); + + $this->updateTagCounts($oldTags, -1); + + $newTags = $event->discussion->tags(); + + $this->updateTagCounts($newTags, 1); + } + + public function whenDiscussionWasDeleted(DiscussionWasDeleted $event) + { + $tags = $event->discussion->tags(); + + $this->updateTagCounts($tags, -1); + + $tags->detach(); + } + + protected function updateTagCounts($query, $delta) + { + $query->update(['discussions_count' => app('db')->raw('discussions_count + '.$delta)]); + } +} diff --git a/extensions/tags/src/Handlers/TagSaver.php b/extensions/tags/src/Handlers/TagSaver.php index 739318e6c..c20c76afa 100755 --- a/extensions/tags/src/Handlers/TagSaver.php +++ b/extensions/tags/src/Handlers/TagSaver.php @@ -3,7 +3,6 @@ use Flarum\Tags\Tag; use Flarum\Tags\Events\DiscussionWasTagged; use Flarum\Core\Events\DiscussionWillBeSaved; -use Flarum\Core\Events\DiscussionWasDeleted; use Flarum\Core\Models\Discussion; use Flarum\Core\Exceptions\PermissionDeniedException; @@ -12,7 +11,6 @@ class TagSaver public function subscribe($events) { $events->listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved'); - $events->listen('Flarum\Core\Events\DiscussionWasDeleted', __CLASS__.'@whenDiscussionWasDeleted'); } public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event) @@ -57,9 +55,4 @@ class TagSaver } } } - - public function whenDiscussionWasDeleted(DiscussionWasDeleted $event) - { - $event->discussion->tags()->sync([]); - } } diff --git a/extensions/tags/src/TagsServiceProvider.php b/extensions/tags/src/TagsServiceProvider.php index a1688999e..78098c977 100644 --- a/extensions/tags/src/TagsServiceProvider.php +++ b/extensions/tags/src/TagsServiceProvider.php @@ -124,6 +124,7 @@ class TagsServiceProvider extends ServiceProvider // Add an event subscriber so that tags data is persisted when // saving a discussion. new Extend\EventSubscriber('Flarum\Tags\Handlers\TagSaver'), + new Extend\EventSubscriber('Flarum\Tags\Handlers\TagMetadataUpdater'), // Add a gambit that allows filtering discussions by tag(s). new Extend\DiscussionGambit('Flarum\Tags\TagGambit'), From cf89af426646fea802db3466c8759b76d967f261 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 09:07:19 +0930 Subject: [PATCH 054/554] Also update tag last discussion info --- .../tags/src/Handlers/TagMetadataUpdater.php | 22 ++++++++---- extensions/tags/src/Tag.php | 36 ++++++++++++++++++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/extensions/tags/src/Handlers/TagMetadataUpdater.php b/extensions/tags/src/Handlers/TagMetadataUpdater.php index cbc75fd49..9bf280bfd 100755 --- a/extensions/tags/src/Handlers/TagMetadataUpdater.php +++ b/extensions/tags/src/Handlers/TagMetadataUpdater.php @@ -19,31 +19,41 @@ class TagMetadataUpdater { $tags = $event->discussion->tags(); - $this->updateTagCounts($tags, 1); + $this->updateTags($tags, 1, $event->discussion); } public function whenDiscussionWasTagged(DiscussionWasTagged $event) { $oldTags = Tag::whereIn('id', array_pluck($event->oldTags, 'id')); - $this->updateTagCounts($oldTags, -1); + $this->updateTags($oldTags, -1, $event->discussion); $newTags = $event->discussion->tags(); - $this->updateTagCounts($newTags, 1); + $this->updateTags($newTags, 1, $event->discussion); } public function whenDiscussionWasDeleted(DiscussionWasDeleted $event) { $tags = $event->discussion->tags(); - $this->updateTagCounts($tags, -1); + $this->updateTags($tags, -1, $event->discussion); $tags->detach(); } - protected function updateTagCounts($query, $delta) + protected function updateTags($query, $delta, $discussion) { - $query->update(['discussions_count' => app('db')->raw('discussions_count + '.$delta)]); + foreach ($query->get() as $tag) { + $tag->discussions_count += $delta; + + if ($delta > 0 && max($discussion->start_time, $discussion->last_time) > $tag->last_time) { + $tag->setLastDiscussion($discussion); + } elseif ($delta < 0 && $discussion->id == $tag->last_discussion_id) { + $tag->refreshLastDiscussion(); + } + + $tag->save(); + } } } diff --git a/extensions/tags/src/Tag.php b/extensions/tags/src/Tag.php index 5ba5fb3ac..9766ebbf8 100644 --- a/extensions/tags/src/Tag.php +++ b/extensions/tags/src/Tag.php @@ -1,6 +1,7 @@ belongsTo('Flarum\Core\Models\Discussion', 'last_discussion_id'); } + public function discussions() + { + return $this->belongsToMany('Flarum\Core\Models\Discussion', 'discussions_tags'); + } + + /** + * Refresh a tag's last discussion details. + * + * @return $this + */ + public function refreshLastDiscussion() + { + if ($lastDiscussion = $this->discussions()->orderBy('last_time', 'desc')->first()) { + $this->setLastDiscussion($lastDiscussion); + } + + return $this; + } + + /** + * Set the tag's last discussion details. + * + * @param \Flarum\Core\Models\Discussion $discussion + * @return $this + */ + public function setLastDiscussion(Discussion $discussion) + { + $this->last_time = $discussion->last_time; + $this->last_discussion_id = $discussion->id; + + return $this; + } + public static function getVisibleTo($user) { static $tags; - if (!$tags) { + if (! $tags) { $tags = static::all(); } From d91f208b1e297cbd59f8e2db6b71b4711baebe15 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 11:17:07 +0930 Subject: [PATCH 055/554] Don't show tag cloud if empty --- extensions/tags/js/src/components/tags-page.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/src/components/tags-page.js b/extensions/tags/js/src/components/tags-page.js index 89db7fc3a..865e7e93e 100644 --- a/extensions/tags/js/src/components/tags-page.js +++ b/extensions/tags/js/src/components/tags-page.js @@ -54,12 +54,12 @@ export default class TagsPage extends Component { ]); }) ]), - m('div.tag-cloud', [ + cloud.length ? m('div.tag-cloud', [ m('h4', 'Tags'), m('div.tag-cloud-content', cloud.map(tag => m('a', {href: app.route.tag(tag), config: m.route, style: tag.color() ? 'color: '+tag.color() : ''}, tag.name()) )) - ]) + ]) : '' ]) ]) ]); From 37c0b916147a562c5dfdaada80d272f59fe7c4fa Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 11:17:43 +0930 Subject: [PATCH 056/554] Update tag metadata when posts are altered --- .../tags/src/Handlers/TagMetadataUpdater.php | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/extensions/tags/src/Handlers/TagMetadataUpdater.php b/extensions/tags/src/Handlers/TagMetadataUpdater.php index 9bf280bfd..91682fea4 100755 --- a/extensions/tags/src/Handlers/TagMetadataUpdater.php +++ b/extensions/tags/src/Handlers/TagMetadataUpdater.php @@ -5,6 +5,11 @@ use Flarum\Tags\Events\DiscussionWasTagged; use Flarum\Core\Events\DiscussionWasStarted; use Flarum\Core\Events\DiscussionWasDeleted; use Flarum\Core\Models\Discussion; +use Flarum\Core\Models\Post; +use Flarum\Core\Events\PostWasPosted; +use Flarum\Core\Events\PostWasDeleted; +use Flarum\Core\Events\PostWasHidden; +use Flarum\Core\Events\PostWasRestored; class TagMetadataUpdater { @@ -13,43 +18,66 @@ class TagMetadataUpdater $events->listen('Flarum\Core\Events\DiscussionWasStarted', __CLASS__.'@whenDiscussionWasStarted'); $events->listen('Flarum\Tags\Events\DiscussionWasTagged', __CLASS__.'@whenDiscussionWasTagged'); $events->listen('Flarum\Core\Events\DiscussionWasDeleted', __CLASS__.'@whenDiscussionWasDeleted'); + + $events->listen('Flarum\Core\Events\PostWasPosted', __CLASS__.'@whenPostWasPosted'); + $events->listen('Flarum\Core\Events\PostWasDeleted', __CLASS__.'@whenPostWasDeleted'); + $events->listen('Flarum\Core\Events\PostWasHidden', __CLASS__.'@whenPostWasHidden'); + $events->listen('Flarum\Core\Events\PostWasRestored', __CLASS__.'@whenPostWasRestored'); } public function whenDiscussionWasStarted(DiscussionWasStarted $event) { - $tags = $event->discussion->tags(); - - $this->updateTags($tags, 1, $event->discussion); + $this->updateTags($event->discussion, 1); } public function whenDiscussionWasTagged(DiscussionWasTagged $event) { $oldTags = Tag::whereIn('id', array_pluck($event->oldTags, 'id')); - $this->updateTags($oldTags, -1, $event->discussion); + $this->updateTags($event->discussion, -1, $oldTags); - $newTags = $event->discussion->tags(); - - $this->updateTags($newTags, 1, $event->discussion); + $this->updateTags($event->discussion, 1); } public function whenDiscussionWasDeleted(DiscussionWasDeleted $event) { - $tags = $event->discussion->tags(); + $this->updateTags($event->discussion, -1); - $this->updateTags($tags, -1, $event->discussion); - - $tags->detach(); + $event->discussion->tags()->detach(); } - protected function updateTags($query, $delta, $discussion) + public function whenPostWasPosted(PostWasPosted $event) { - foreach ($query->get() as $tag) { + $this->updateTags($event->post->discussion); + } + + public function whenPostWasDeleted(PostWasDeleted $event) + { + $this->updateTags($event->post->discussion); + } + + public function whenPostWasHidden(PostWasHidden $event) + { + $this->updateTags($event->post->discussion); + } + + public function whenPostWasRestored(PostWasRestored $event) + { + $this->updateTags($event->post->discussion); + } + + protected function updateTags($discussion, $delta = 0, $tags = null) + { + if (! $tags) { + $tags = $discussion->getRelation('tags'); + } + + foreach ($tags as $tag) { $tag->discussions_count += $delta; - if ($delta > 0 && max($discussion->start_time, $discussion->last_time) > $tag->last_time) { + if ($discussion->last_time > $tag->last_time) { $tag->setLastDiscussion($discussion); - } elseif ($delta < 0 && $discussion->id == $tag->last_discussion_id) { + } elseif ($discussion->id == $tag->last_discussion_id) { $tag->refreshLastDiscussion(); } From b2d2bfa34e74a2c60407106aa06c564a51f9f8c2 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 14:22:15 +0930 Subject: [PATCH 057/554] Correctly style uncolored tags in discussion hero --- extensions/tags/js/src/add-tag-labels.js | 3 ++- extensions/tags/less/extension.less | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/extensions/tags/js/src/add-tag-labels.js b/extensions/tags/js/src/add-tag-labels.js index d8e3a0bca..1033a43a8 100644 --- a/extensions/tags/js/src/add-tag-labels.js +++ b/extensions/tags/js/src/add-tag-labels.js @@ -27,7 +27,8 @@ export default function() { extend(DiscussionHero.prototype, 'view', function(view) { var tags = sortTags(this.props.discussion.tags()); if (tags && tags.length) { - view.attrs.style = 'color: #fff; background-color: '+tags[0].color(); + view.attrs.style = 'background-color: '+tags[0].color(); + view.attrs.className += ' discussion-hero-colored'; } }); diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index 81bfa5943..a80fb8657 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -34,6 +34,11 @@ } } } +.discussion-hero-colored { + &, & a { + color: #fff; + } +} .tags-label { .discussion-summary & { margin-right: 10px; From 82f487c4bb58f952e980b22bc4d74f5359b02228 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 14:35:01 +0930 Subject: [PATCH 058/554] Don't destroy discussion list in case of a redraw --- extensions/tags/js/src/components/tag-nav-item.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extensions/tags/js/src/components/tag-nav-item.js b/extensions/tags/js/src/components/tag-nav-item.js index e6aca2bca..e880039a4 100644 --- a/extensions/tags/js/src/components/tag-nav-item.js +++ b/extensions/tags/js/src/components/tag-nav-item.js @@ -19,7 +19,12 @@ export default class TagNavItem extends NavItem { m('a', { href: this.props.href, config: m.route, - onclick: () => {app.cache.discussionList = null; m.redraw.strategy('none')}, + onclick: () => { + if (app.cache.discussionList) { + app.cache.discussionList.forceReload = true; + } + m.redraw.strategy('none'); + }, style: (active && tag) ? 'color: '+tag.color() : '', title: description || '' }, [ From 6aff8ebca565c92ca6cfc9d34cfed12a19e81742 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 17:26:01 +0930 Subject: [PATCH 059/554] Don't color the hero if the tag doesn't have a color --- extensions/tags/js/src/add-tag-labels.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/src/add-tag-labels.js b/extensions/tags/js/src/add-tag-labels.js index 1033a43a8..75073224f 100644 --- a/extensions/tags/js/src/add-tag-labels.js +++ b/extensions/tags/js/src/add-tag-labels.js @@ -27,8 +27,11 @@ export default function() { extend(DiscussionHero.prototype, 'view', function(view) { var tags = sortTags(this.props.discussion.tags()); if (tags && tags.length) { - view.attrs.style = 'background-color: '+tags[0].color(); - view.attrs.className += ' discussion-hero-colored'; + var color = tags[0].color(); + if (color) { + view.attrs.style = 'background-color: '+color; + view.attrs.className += ' discussion-hero-colored'; + } } }); From 0d1d61922fecfc3dad9c1a6191e167943654211b Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 17:26:46 +0930 Subject: [PATCH 060/554] Reverse tag visibility logic So that discussions with non-existent tags are still visible --- extensions/tags/src/Tag.php | 4 ++-- extensions/tags/src/TagsServiceProvider.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/tags/src/Tag.php b/extensions/tags/src/Tag.php index 9766ebbf8..4183fe703 100644 --- a/extensions/tags/src/Tag.php +++ b/extensions/tags/src/Tag.php @@ -57,7 +57,7 @@ class Tag extends Model return $this; } - public static function getVisibleTo($user) + public static function getNotVisibleTo($user) { static $tags; if (! $tags) { @@ -66,7 +66,7 @@ class Tag extends Model $ids = []; foreach ($tags as $tag) { - if (! $tag->is_restricted || $user->hasPermission('tag'.$tag->id.'.view')) { + if ($tag->is_restricted && ! $user->hasPermission('tag'.$tag->id.'.view')) { $ids[] = $tag->id; } } diff --git a/extensions/tags/src/TagsServiceProvider.php b/extensions/tags/src/TagsServiceProvider.php index 78098c977..f8a8b2f39 100644 --- a/extensions/tags/src/TagsServiceProvider.php +++ b/extensions/tags/src/TagsServiceProvider.php @@ -39,7 +39,7 @@ class TagsServiceProvider extends ServiceProvider (new Extend\Model('Flarum\Tags\Tag')) // Hide tags that the user doesn't have permission to see. ->scopeVisible(function ($query, User $user) { - $query->whereIn('id', Tag::getVisibleTo($user)); + $query->whereNotIn('id', Tag::getNotVisibleTo($user)); }) // Allow the user to start discussions in tags which aren't @@ -76,7 +76,7 @@ class TagsServiceProvider extends ServiceProvider $query->whereNotExists(function ($query) use ($user) { return $query->select(app('db')->raw(1)) ->from('discussions_tags') - ->whereNotIn('tag_id', Tag::getVisibleTo($user)) + ->whereIn('tag_id', Tag::getNotVisibleTo($user)) ->whereRaw('discussion_id = discussions.id'); }); }) From 537741dd5aca4ac2251765bde176e7ac000eb941 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 17:28:21 +0930 Subject: [PATCH 061/554] Only show tags that exist in the tags label --- extensions/tags/js/src/helpers/tags-label.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/tags/js/src/helpers/tags-label.js b/extensions/tags/js/src/helpers/tags-label.js index d725f9fef..2d7861d9c 100644 --- a/extensions/tags/js/src/helpers/tags-label.js +++ b/extensions/tags/js/src/helpers/tags-label.js @@ -10,7 +10,9 @@ export default function tagsLabel(tags, attrs) { if (tags) { sortTags(tags).forEach(tag => { - children.push(tagLabel(tag, {link})); + if (tag || tags.length === 1) { + children.push(tagLabel(tag, {link})); + } }); } else { children.push(tagLabel()); From f519a2740b31f1b2d290d19006912e32a6241a78 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 17:43:54 +0930 Subject: [PATCH 062/554] Bad text! No wrap! --- extensions/tags/less/extension.less | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index a80fb8657..e2d4a92c2 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -166,6 +166,7 @@ overflow: hidden; text-overflow: ellipsis; cursor: pointer; + white-space: nowrap; &.pinned { padding-top: 10px; From c04e2e3db2b8867a0f7d2bb264ed6df87829f329 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 17:48:59 +0930 Subject: [PATCH 063/554] Show full description in tooltip --- extensions/tags/js/src/components/tag-discussion-modal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/tags/js/src/components/tag-discussion-modal.js b/extensions/tags/js/src/components/tag-discussion-modal.js index 26e38d7fb..4a5ecbe64 100644 --- a/extensions/tags/js/src/components/tag-discussion-modal.js +++ b/extensions/tags/js/src/components/tag-discussion-modal.js @@ -126,7 +126,7 @@ export default class TagDiscussionModal extends FormModal { }, [ tagIcon(tag), m('span.name', highlight(tag.name(), filter)), - tag.description() ? m('span.description', tag.description()) : '' + tag.description() ? m('span.description', {title: tag.description()}, tag.description()) : '' ]) : '' )) From d0f72fb05d7dc76d10ffcf13b7fb45384af841fd Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 18:19:13 +0930 Subject: [PATCH 064/554] Fix up sorting of children in the tag selection list --- extensions/tags/js/src/components/tag-discussion-modal.js | 1 + extensions/tags/js/src/utils/sort-tags.js | 4 ++-- extensions/tags/less/extension.less | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/extensions/tags/js/src/components/tag-discussion-modal.js b/extensions/tags/js/src/components/tag-discussion-modal.js index 4a5ecbe64..b3cc4c824 100644 --- a/extensions/tags/js/src/components/tag-discussion-modal.js +++ b/extensions/tags/js/src/components/tag-discussion-modal.js @@ -99,6 +99,7 @@ export default class TagDiscussionModal extends FormModal { 'data-index': tag.id(), className: classList({ pinned: tag.position() !== null, + child: !!tag.parent(), colored: !!tag.color(), selected: selected.indexOf(tag) !== -1, active: this.index() == tag diff --git a/extensions/tags/js/src/utils/sort-tags.js b/extensions/tags/js/src/utils/sort-tags.js index 9e3a72750..c46c8e39d 100644 --- a/extensions/tags/js/src/utils/sort-tags.js +++ b/extensions/tags/js/src/utils/sort-tags.js @@ -15,9 +15,9 @@ export default function sortTags(tags) { } else if (aParent === bParent) { return aPos - bPos; } else if (aParent) { - return aParent.position() - bPos; + return aParent === b ? -1 : aParent.position() - bPos; } else if (bParent) { - return aPos - bParent.position(); + return bParent === a ? -1 : aPos - bParent.position(); } return 0; diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index e2d4a92c2..85547de7b 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -168,7 +168,7 @@ cursor: pointer; white-space: nowrap; - &.pinned { + &.pinned:not(.child) { padding-top: 10px; padding-bottom: 10px; From 1ea91dc707d1181fa4d0c9e4b49ccf3b90ab90b2 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 19 Jun 2015 20:57:49 +0930 Subject: [PATCH 065/554] Reduce discussion list padding --- extensions/tags/less/extension.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index 85547de7b..392a62702 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -41,7 +41,7 @@ } .tags-label { .discussion-summary & { - margin-right: 10px; + margin-right: 4px; } .discussion-tagged-post & { margin: 0 2px; From 1acfb19800610b8601175b0d580d530259a2f3b7 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 24 Jun 2015 11:48:15 +0930 Subject: [PATCH 066/554] Make stuff responsive. closes #6 --- .../js/src/components/tag-discussion-modal.js | 5 +- .../tags/js/src/components/tag-nav-item.js | 2 +- .../tags/js/src/components/tags-page.js | 7 +- extensions/tags/less/extension.less | 86 +++++++++++++------ 4 files changed, 68 insertions(+), 32 deletions(-) diff --git a/extensions/tags/js/src/components/tag-discussion-modal.js b/extensions/tags/js/src/components/tag-discussion-modal.js index b3cc4c824..89fbf77e1 100644 --- a/extensions/tags/js/src/components/tag-discussion-modal.js +++ b/extensions/tags/js/src/components/tag-discussion-modal.js @@ -1,6 +1,7 @@ import FormModal from 'flarum/components/form-modal'; import DiscussionPage from 'flarum/components/discussion-page'; import highlight from 'flarum/helpers/highlight'; +import icon from 'flarum/helpers/icon'; import classList from 'flarum/utils/class-list'; import tagLabel from 'flarum-tags/helpers/tag-label'; @@ -89,7 +90,9 @@ export default class TagDiscussionModal extends FormModal { onblur: () => this.focused(false) }) ]), - m('button[type=submit].btn.btn-primary', {disabled: !selected.length}, 'Confirm') + m('span.primary-control', + m('button[type=submit].btn.btn-primary', {disabled: !selected.length}, icon('check icon'), m('span.label', 'Confirm')) + ) ]) ], footer: [ diff --git a/extensions/tags/js/src/components/tag-nav-item.js b/extensions/tags/js/src/components/tag-nav-item.js index e880039a4..87262a9c3 100644 --- a/extensions/tags/js/src/components/tag-nav-item.js +++ b/extensions/tags/js/src/components/tag-nav-item.js @@ -16,7 +16,7 @@ export default class TagNavItem extends NavItem { } return m('li'+(active ? '.active' : ''), - m('a', { + m('a.has-icon', { href: this.props.href, config: m.route, onclick: () => { diff --git a/extensions/tags/js/src/components/tags-page.js b/extensions/tags/js/src/components/tags-page.js index 865e7e93e..f64f16524 100644 --- a/extensions/tags/js/src/components/tags-page.js +++ b/extensions/tags/js/src/components/tags-page.js @@ -56,9 +56,10 @@ export default class TagsPage extends Component { ]), cloud.length ? m('div.tag-cloud', [ m('h4', 'Tags'), - m('div.tag-cloud-content', cloud.map(tag => - m('a', {href: app.route.tag(tag), config: m.route, style: tag.color() ? 'color: '+tag.color() : ''}, tag.name()) - )) + m('div.tag-cloud-content', cloud.map(tag => [ + m('a', {href: app.route.tag(tag), config: m.route, style: tag.color() ? 'color: '+tag.color() : ''}, tag.name()), + ' ' + ])) ]) : '' ]) ]) diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index 392a62702..e33987af8 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -100,33 +100,43 @@ .tag-discussion-modal { & .modal-header { background: @fl-body-secondary-color; - padding: 20px; + padding: 20px 20px 0; & h3 { text-align: left; color: @fl-body-muted-color; font-size: 16px; } + + @media @phone { + padding: 0; + } } & .modal-body { - padding: 0 20px 20px; + padding: 20px; + + @media @phone { + padding: 15px; + } } & .modal-footer { padding: 1px 0 0; text-align: left; } } -.tags-form { - padding-right: 100px; - overflow: hidden; +@media @tablet, @desktop, @desktop-hd { + .tags-form { + padding-right: 100px; + overflow: hidden; - & .tags-input { - float: left; - } - & .btn { - margin-right: -100px; - float: right; - width: 85px; + & .tags-input { + float: left; + } + & .primary-control { + margin-right: -100px; + float: right; + width: 85px; + } } } .tags-input { @@ -161,6 +171,10 @@ overflow: auto; max-height: 50vh; + @media @phone { + max-height: none; + } + & > li { padding: 7px 20px; overflow: hidden; @@ -189,10 +203,18 @@ width: 150px; margin-right: 10px; margin-left: 10px; + + @media @phone { + width: auto; + } } & .description { color: @fl-body-muted-color; font-size: 12px; + + @media @phone { + display: none; + } } &.selected { & .tag-icon { @@ -226,25 +248,31 @@ margin: 0; overflow: hidden; + @media @phone { + margin: -15px -15px 0; + } + & > li { - float: left; - width: ~"calc(50% - 1px)"; height: 200px; - margin-right: 1px; margin-bottom: 1px; overflow: hidden; - &:first-child { - border-top-left-radius: @border-radius-base; - } - &:nth-child(2) { - border-top-right-radius: @border-radius-base; - } - &:nth-last-child(2):nth-child(even), &:last-child { - border-bottom-right-radius: @border-radius-base; - } - &:nth-last-child(2):nth-child(odd), &:last-child:nth-child(odd) { - border-bottom-left-radius: @border-radius-base; + @media @tablet, @desktop, @desktop-hd { + float: left; + width: ~"calc(50% - 1px)"; + margin-right: 1px; + &:first-child { + border-top-left-radius: @border-radius-base; + } + &:nth-child(2) { + border-top-right-radius: @border-radius-base; + } + &:nth-last-child(2):nth-child(even), &:last-child { + border-bottom-right-radius: @border-radius-base; + } + &:nth-last-child(2):nth-child(odd), &:last-child:nth-child(odd) { + border-bottom-left-radius: @border-radius-base; + } } } } @@ -303,6 +331,9 @@ padding-top: 17px; padding-bottom: 17px; color: fade(#fff, 50%); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; & .title { margin-right: 15px; @@ -310,7 +341,7 @@ } } .tag-cloud { - margin-top: 30px; + margin-top: 50px; text-align: center; & h4 { @@ -330,6 +361,7 @@ } .tag-cloud-content { font-size: 14px; + line-height: 1.7; &, & a { color: @fl-body-muted-color; From 7a75e424d7633c4cdf4c91c576aeff57acf6c728 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 24 Jun 2015 11:48:22 +0930 Subject: [PATCH 067/554] Don't need that! --- .../tags/src/CategoriesServiceProvider.php | 72 ------------------- 1 file changed, 72 deletions(-) delete mode 100644 extensions/tags/src/CategoriesServiceProvider.php diff --git a/extensions/tags/src/CategoriesServiceProvider.php b/extensions/tags/src/CategoriesServiceProvider.php deleted file mode 100644 index 0ba18fb5d..000000000 --- a/extensions/tags/src/CategoriesServiceProvider.php +++ /dev/null @@ -1,72 +0,0 @@ -extend( - new EventSubscribers([ - 'Flarum\Categories\Handlers\DiscussionMovedNotifier', - 'Flarum\Categories\Handlers\CategoryPreloader', - 'Flarum\Categories\Handlers\CategorySaver' - ]), - - new ForumAssets([ - __DIR__.'/../js/dist/extension.js', - __DIR__.'/../less/categories.less' - ]), - - new PostType('Flarum\Categories\DiscussionMovedPost'), - - new DiscussionGambit('Flarum\Categories\CategoryGambit'), - - (new NotificationType('Flarum\Categories\DiscussionMovedNotification', 'Flarum\Api\Serializers\DiscussionBasicSerializer'))->enableByDefault('alert'), - - new Relationship('Flarum\Core\Models\Discussion', 'belongsTo', 'category', 'Flarum\Categories\Category'), - - new SerializeRelationship('Flarum\Api\Serializers\DiscussionBasicSerializer', 'hasOne', 'category', 'Flarum\Categories\CategorySerializer'), - - new ApiInclude(['discussions.index', 'discussions.show'], 'category', true), - - new ApiInclude('activity.index', 'subject.discussion.category', true), - - new ApiInclude('notifications.index', 'subject.category', true), - - (new Permission('discussion.move')) - ->serialize() - ->grant(function ($grant, $user) { - $grant->where('start_user_id', $user->id); - // @todo add limitations to time etc. according to a config setting - }) - ); - } - - /** - * Register the service provider. - * - * @return void - */ - public function register() - { - $this->app->bind( - 'Flarum\Categories\CategoryRepositoryInterface', - 'Flarum\Categories\EloquentCategoryRepository' - ); - } -} From 7e99bcd55564f5f93075e96cebb90ddee8feecff Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 25 Jun 2015 08:01:51 +0930 Subject: [PATCH 068/554] Update for discussion list refactoring --- extensions/tags/js/src/add-tag-labels.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/extensions/tags/js/src/add-tag-labels.js b/extensions/tags/js/src/add-tag-labels.js index 75073224f..f685a2498 100644 --- a/extensions/tags/js/src/add-tag-labels.js +++ b/extensions/tags/js/src/add-tag-labels.js @@ -1,5 +1,5 @@ import { extend } from 'flarum/extension-utils'; -import DiscussionList from 'flarum/components/discussion-list'; +import DiscussionListItem from 'flarum/components/discussion-list-item'; import DiscussionPage from 'flarum/components/discussion-page'; import DiscussionHero from 'flarum/components/discussion-hero'; @@ -8,13 +8,10 @@ import sortTags from 'flarum-tags/utils/sort-tags'; export default function() { // Add tag labels to each discussion in the discussion list. - extend(DiscussionList.prototype, 'infoItems', function(items, discussion) { - var tags = discussion.tags(); - if (tags) { - tags = tags.filter(tag => tag.slug() !== this.props.params.tags); - if (tags.length) { - items.add('tags', tagsLabel(tags), {first: true}); - } + extend(DiscussionListItem.prototype, 'infoItems', function(items) { + var tags = this.props.discussion.tags(); + if (tags && tags.length) { + items.add('tags', tagsLabel(tags), {first: true}); } }); From 0389ea53de0df95257a5fb5284fe97b2d9ca8351 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 25 Jun 2015 15:40:15 +0930 Subject: [PATCH 069/554] Update for discussion controls API --- extensions/tags/js/src/add-tag-discussion-control.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/src/add-tag-discussion-control.js b/extensions/tags/js/src/add-tag-discussion-control.js index 449179a7d..20257f2bf 100644 --- a/extensions/tags/js/src/add-tag-discussion-control.js +++ b/extensions/tags/js/src/add-tag-discussion-control.js @@ -6,13 +6,13 @@ import TagDiscussionModal from 'flarum-tags/components/tag-discussion-modal'; export default function() { // Add a control allowing the discussion to be moved to another category. - extend(Discussion.prototype, 'controls', function(items) { + extend(Discussion.prototype, 'moderationControls', function(items) { if (this.canTag()) { items.add('tags', ActionButton.component({ label: 'Edit Tags', icon: 'tag', onclick: () => app.modal.show(new TagDiscussionModal({ discussion: this })) - }), {after: 'rename'}); + })); } }); }; From e41e50f4237f9bca864f97a536928b52f8ce37d8 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 25 Jun 2015 15:43:24 +0930 Subject: [PATCH 070/554] Improve appearance of tags page. closes #4 --- .../tags/js/src/components/tags-page.js | 10 +++-- extensions/tags/less/extension.less | 43 +++++++++++++------ extensions/tags/src/TagSerializer.php | 2 +- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/extensions/tags/js/src/components/tags-page.js b/extensions/tags/js/src/components/tags-page.js index f64f16524..6c7ec39d1 100644 --- a/extensions/tags/js/src/components/tags-page.js +++ b/extensions/tags/js/src/components/tags-page.js @@ -37,19 +37,23 @@ export default class TagsPage extends Component { return parent && parent.id() == tag.id(); }); - return m('li.tag-tile', {style: 'background-color: '+tag.color()}, [ + return m('li.tag-tile', {className: tag.color() ? 'colored' : '', style: 'background-color: '+tag.color()}, [ m('a.tag-info', {href: app.route.tag(tag), config: m.route}, [ m('h3.name', tag.name()), m('p.description', tag.description()), children ? m('div.children', children.map(tag => - m('a', {href: app.route.tag(tag), config: m.route, onclick: (e) => e.stopPropagation()}, tag.name()) + m('a', {href: app.route.tag(tag), config: function(element, isInitialized) { + if (isInitialized) return; + $(element).on('click', e => e.stopPropagation()); + m.route.apply(this, arguments); + }}, tag.name()) )) : '' ]), lastDiscussion ? m('a.last-discussion', { href: app.route.discussion(lastDiscussion, lastDiscussion.lastPostNumber()), config: m.route - }, [m('span.title', lastDiscussion.title()), humanTime(lastDiscussion.lastTime())]) + }, [humanTime(lastDiscussion.lastTime()), m('span.title', lastDiscussion.title())]) : m('span.last-discussion') ]); }) diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less index e33987af8..90984629e 100644 --- a/extensions/tags/less/extension.less +++ b/extensions/tags/less/extension.less @@ -278,9 +278,15 @@ } .tag-tile { position: relative; + background: @fl-body-secondary-color; &, & a { - color: #fff; + color: @fl-body-muted-color; + } + &.colored { + &, & a { + color: #fff; + } } & .tag-info, & .last-discussion { padding: 20px; @@ -294,15 +300,15 @@ transition: background 0.2s; &:hover { - background: fade(#000, 10%); + background: fade(#000, 5%); } &:active { - background: fade(#000, 20%); + background: fade(#000, 15%); } } & .tag-info { top: 0; - bottom: 55px; + bottom: 45px; padding-right: 20px; & .name { @@ -311,8 +317,9 @@ font-weight: normal; } & .description { - color: fade(#fff, 50%); - margin: 0 0 15px; + font-size: 14px; + opacity: 0.5; + margin: 0 0 10px; } & .children { text-transform: uppercase; @@ -326,17 +333,29 @@ } & .last-discussion { bottom: 0; - height: 55px; - border-top: 1px solid rgba(0, 0, 0, 0.1); - padding-top: 17px; - padding-bottom: 17px; - color: fade(#fff, 50%); + height: 45px; + padding-top: 12px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; + line-height: 21px; + opacity: 0.5; + + &, &:hover, &:active { + background: fade(#000, 10%); + } & .title { - margin-right: 15px; + margin-right: 10px; + } + &:hover .title { + text-decoration: underline; + } + & time { + text-transform: uppercase; + font-size: 11px; + font-weight: bold; + float: right; } } } diff --git a/extensions/tags/src/TagSerializer.php b/extensions/tags/src/TagSerializer.php index 27c58e39e..cda168248 100644 --- a/extensions/tags/src/TagSerializer.php +++ b/extensions/tags/src/TagSerializer.php @@ -47,6 +47,6 @@ class TagSerializer extends BaseSerializer protected function lastDiscussion() { - return $this->hasOne('Flarum\Api\Serializers\DiscussionBasicSerializer'); + return $this->hasOne('Flarum\Api\Serializers\DiscussionSerializer'); } } From 8ba856df79ff0069527da583ae37abbaa315a6a0 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 26 Jun 2015 12:22:08 +0930 Subject: [PATCH 071/554] Update gambit for search API --- extensions/tags/src/TagGambit.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/extensions/tags/src/TagGambit.php b/extensions/tags/src/TagGambit.php index dc22027cf..69e618201 100644 --- a/extensions/tags/src/TagGambit.php +++ b/extensions/tags/src/TagGambit.php @@ -36,13 +36,14 @@ class TagGambit extends GambitAbstract * @param \Flarum\Core\Search\SearcherInterface $searcher * @return void */ - public function conditions($matches, SearcherInterface $searcher) + protected function conditions(SearcherInterface $searcher, array $matches, $negate) { $slugs = explode(',', trim($matches[1], '"')); - $searcher->query()->where(function ($query) use ($slugs) { + // TODO: implement $negate + $searcher->getQuery()->where(function ($query) use ($slugs) { foreach ($slugs as $slug) { - if ($slug === 'uncategorized') { + if ($slug === 'untagged') { $query->orWhereNotExists(function ($query) { $query->select(app('db')->raw(1)) ->from('discussions_tags') From 7d38f0880e379b1a7cd39505a3f56037109fd7dc Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 26 Jun 2015 12:22:39 +0930 Subject: [PATCH 072/554] Add title attribute to linked tag labels --- extensions/tags/js/src/components/discussion-tagged-post.js | 2 +- extensions/tags/js/src/helpers/tag-label.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/tags/js/src/components/discussion-tagged-post.js b/extensions/tags/js/src/components/discussion-tagged-post.js index f67954051..0bf37f2d0 100644 --- a/extensions/tags/js/src/components/discussion-tagged-post.js +++ b/extensions/tags/js/src/components/discussion-tagged-post.js @@ -12,7 +12,7 @@ export default class DiscussionTaggedPost extends EventPost { var total = added.concat(removed); var build = function(verb, tags, only) { - return tags.length ? [verb, ' ', only && tags.length == 1 ? 'the ' : '', tagsLabel(tags)] : ''; + return tags.length ? [verb, ' ', only && tags.length == 1 ? 'the ' : '', tagsLabel(tags, {link: true})] : ''; }; return super.view('tag', [ diff --git a/extensions/tags/js/src/helpers/tag-label.js b/extensions/tags/js/src/helpers/tag-label.js index c49ef2eb3..11c401267 100644 --- a/extensions/tags/js/src/helpers/tag-label.js +++ b/extensions/tags/js/src/helpers/tag-label.js @@ -16,6 +16,10 @@ export default function tagsLabel(tag, attrs) { attrs.style.backgroundColor = attrs.style.color = color; attrs.className += ' colored'; } + + if (link) { + attrs.title = tag.description() || ''; + } } else { attrs.className += ' untagged'; } From b799e3bc696a4824ef8921b56be3f30dcfc489a6 Mon Sep 17 00:00:00 2001 From: Franz Liedke Date: Sat, 27 Jun 2015 19:13:21 +0200 Subject: [PATCH 073/554] Change theme color based on tag color --- extensions/tags/js/src/add-tag-filter.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/tags/js/src/add-tag-filter.js b/extensions/tags/js/src/add-tag-filter.js index 8bc2265e2..cb9835afe 100644 --- a/extensions/tags/js/src/add-tag-filter.js +++ b/extensions/tags/js/src/add-tag-filter.js @@ -12,13 +12,16 @@ export default function() { } }; + var originalThemeColor = $('meta[name=theme-color]').attr('content'); + // If currently viewing a tag, insert a tag hero at the top of the - // view. + // view and set the theme color accordingly. extend(IndexPage.prototype, 'view', function(view) { var tag = this.currentTag(); if (tag) { view.children[0] = TagHero.component({tag}); } + $('meta[name=theme-color]').attr('content', tag ? tag.color() : originalThemeColor); }); // If currently viewing a tag, restyle the 'new discussion' button to use From 180b87c71ed394a65bb2ab134acaed71899bc463 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 22 Jul 2015 10:15:25 +0930 Subject: [PATCH 074/554] Update for new extension API; implement i10n --- extensions/tags/.editorconfig | 32 ++ extensions/tags/.eslintignore | 5 + extensions/tags/.eslintrc | 171 ++++++++ extensions/tags/bootstrap.php | 6 +- extensions/tags/flarum.json | 13 +- extensions/tags/js/bootstrap.js | 43 -- extensions/tags/js/{ => forum}/Gulpfile.js | 2 +- extensions/tags/js/{ => forum}/package.json | 0 .../tags/js/forum/src/addTagComposer.js | 53 +++ extensions/tags/js/forum/src/addTagControl.js | 18 + extensions/tags/js/forum/src/addTagFilter.js | 53 +++ extensions/tags/js/forum/src/addTagLabels.js | 46 +++ extensions/tags/js/forum/src/addTagList.js | 55 +++ .../src/components/DiscussionTaggedPost.js | 48 +++ .../src/components/TagDiscussionModal.js | 284 +++++++++++++ .../tags/js/forum/src/components/TagHero.js | 20 + .../js/forum/src/components/TagLinkButton.js | 27 ++ .../tags/js/forum/src/components/TagsPage.js | 101 +++++ .../tags/js/forum/src/helpers/tagIcon.js | 12 + .../src/helpers/tagLabel.js} | 22 +- .../tags/js/forum/src/helpers/tagsLabel.js | 22 + extensions/tags/js/forum/src/main.js | 33 ++ extensions/tags/js/forum/src/models/Tag.js | 24 ++ .../src/utils/sortTags.js} | 10 +- extensions/tags/js/src/add-tag-composer.js | 54 --- .../tags/js/src/add-tag-discussion-control.js | 18 - extensions/tags/js/src/add-tag-filter.js | 50 --- extensions/tags/js/src/add-tag-labels.js | 45 -- extensions/tags/js/src/add-tag-list.js | 51 --- .../src/components/discussion-tagged-post.js | 25 -- .../js/src/components/tag-discussion-modal.js | 241 ----------- extensions/tags/js/src/components/tag-hero.js | 17 - .../tags/js/src/components/tag-nav-item.js | 44 -- .../tags/js/src/components/tags-page.js | 80 ---- extensions/tags/js/src/helpers/tag-icon.js | 12 - extensions/tags/js/src/helpers/tags-label.js | 22 - extensions/tags/js/src/models/tag.js | 26 -- extensions/tags/less/categories.less | 236 ----------- extensions/tags/less/extension.less | 391 ------------------ extensions/tags/less/forum/TagCloud.less | 29 ++ .../tags/less/forum/TagDiscussionModal.less | 145 +++++++ extensions/tags/less/forum/TagIcon.less | 14 + extensions/tags/less/forum/TagLabel.less | 61 +++ extensions/tags/less/forum/TagTiles.less | 115 ++++++ extensions/tags/less/forum/extension.less | 37 ++ extensions/tags/locale/en.yml | 13 + .../tags/src/Events/DiscussionWasTagged.php | 14 +- extensions/tags/src/Extension.php | 20 + extensions/tags/src/Gambits/TagGambit.php | 49 +++ .../src/Handlers/DiscussionTaggedNotifier.php | 31 -- extensions/tags/src/Handlers/TagLoader.php | 25 -- .../tags/src/Listeners/AddApiAttributes.php | 72 ++++ .../tags/src/Listeners/AddClientAssets.php | 50 +++ .../src/Listeners/AddModelRelationship.php | 21 + .../tags/src/Listeners/AddTagGambit.php | 17 + .../ConfigureDiscussionPermissions.php | 59 +++ .../src/Listeners/ConfigureTagPermissions.php | 31 ++ .../src/Listeners/LogDiscussionTagged.php | 32 ++ .../PersistData.php} | 27 +- .../UpdateTagMetadata.php} | 36 +- .../src/{ => Posts}/DiscussionTaggedPost.php | 41 +- extensions/tags/src/Tag.php | 21 +- extensions/tags/src/TagGambit.php | 64 --- ...entTagRepository.php => TagRepository.php} | 20 +- .../tags/src/TagRepositoryInterface.php | 24 -- extensions/tags/src/TagSerializer.php | 49 +-- extensions/tags/src/TagsServiceProvider.php | 138 ------- 67 files changed, 1890 insertions(+), 1777 deletions(-) create mode 100644 extensions/tags/.editorconfig create mode 100644 extensions/tags/.eslintignore create mode 100644 extensions/tags/.eslintrc delete mode 100644 extensions/tags/js/bootstrap.js rename extensions/tags/js/{ => forum}/Gulpfile.js (61%) rename extensions/tags/js/{ => forum}/package.json (100%) create mode 100644 extensions/tags/js/forum/src/addTagComposer.js create mode 100644 extensions/tags/js/forum/src/addTagControl.js create mode 100644 extensions/tags/js/forum/src/addTagFilter.js create mode 100644 extensions/tags/js/forum/src/addTagLabels.js create mode 100644 extensions/tags/js/forum/src/addTagList.js create mode 100644 extensions/tags/js/forum/src/components/DiscussionTaggedPost.js create mode 100644 extensions/tags/js/forum/src/components/TagDiscussionModal.js create mode 100644 extensions/tags/js/forum/src/components/TagHero.js create mode 100644 extensions/tags/js/forum/src/components/TagLinkButton.js create mode 100644 extensions/tags/js/forum/src/components/TagsPage.js create mode 100644 extensions/tags/js/forum/src/helpers/tagIcon.js rename extensions/tags/js/{src/helpers/tag-label.js => forum/src/helpers/tagLabel.js} (50%) create mode 100644 extensions/tags/js/forum/src/helpers/tagsLabel.js create mode 100644 extensions/tags/js/forum/src/main.js create mode 100644 extensions/tags/js/forum/src/models/Tag.js rename extensions/tags/js/{src/utils/sort-tags.js => forum/src/utils/sortTags.js} (80%) delete mode 100644 extensions/tags/js/src/add-tag-composer.js delete mode 100644 extensions/tags/js/src/add-tag-discussion-control.js delete mode 100644 extensions/tags/js/src/add-tag-filter.js delete mode 100644 extensions/tags/js/src/add-tag-labels.js delete mode 100644 extensions/tags/js/src/add-tag-list.js delete mode 100644 extensions/tags/js/src/components/discussion-tagged-post.js delete mode 100644 extensions/tags/js/src/components/tag-discussion-modal.js delete mode 100644 extensions/tags/js/src/components/tag-hero.js delete mode 100644 extensions/tags/js/src/components/tag-nav-item.js delete mode 100644 extensions/tags/js/src/components/tags-page.js delete mode 100644 extensions/tags/js/src/helpers/tag-icon.js delete mode 100644 extensions/tags/js/src/helpers/tags-label.js delete mode 100644 extensions/tags/js/src/models/tag.js delete mode 100644 extensions/tags/less/categories.less delete mode 100644 extensions/tags/less/extension.less create mode 100644 extensions/tags/less/forum/TagCloud.less create mode 100644 extensions/tags/less/forum/TagDiscussionModal.less create mode 100644 extensions/tags/less/forum/TagIcon.less create mode 100644 extensions/tags/less/forum/TagLabel.less create mode 100644 extensions/tags/less/forum/TagTiles.less create mode 100644 extensions/tags/less/forum/extension.less create mode 100644 extensions/tags/locale/en.yml create mode 100644 extensions/tags/src/Extension.php create mode 100644 extensions/tags/src/Gambits/TagGambit.php delete mode 100755 extensions/tags/src/Handlers/DiscussionTaggedNotifier.php delete mode 100755 extensions/tags/src/Handlers/TagLoader.php create mode 100755 extensions/tags/src/Listeners/AddApiAttributes.php create mode 100755 extensions/tags/src/Listeners/AddClientAssets.php create mode 100755 extensions/tags/src/Listeners/AddModelRelationship.php create mode 100755 extensions/tags/src/Listeners/AddTagGambit.php create mode 100755 extensions/tags/src/Listeners/ConfigureDiscussionPermissions.php create mode 100755 extensions/tags/src/Listeners/ConfigureTagPermissions.php create mode 100755 extensions/tags/src/Listeners/LogDiscussionTagged.php rename extensions/tags/src/{Handlers/TagSaver.php => Listeners/PersistData.php} (58%) rename extensions/tags/src/{Handlers/TagMetadataUpdater.php => Listeners/UpdateTagMetadata.php} (60%) rename extensions/tags/src/{ => Posts}/DiscussionTaggedPost.php (55%) delete mode 100644 extensions/tags/src/TagGambit.php rename extensions/tags/src/{EloquentTagRepository.php => TagRepository.php} (57%) delete mode 100644 extensions/tags/src/TagRepositoryInterface.php delete mode 100644 extensions/tags/src/TagsServiceProvider.php diff --git a/extensions/tags/.editorconfig b/extensions/tags/.editorconfig new file mode 100644 index 000000000..5612a5e74 --- /dev/null +++ b/extensions/tags/.editorconfig @@ -0,0 +1,32 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.js] +indent_style = space +indent_size = 2 + +[*.{css,less}] +indent_style = space +indent_size = 2 + +[*.html] +indent_style = space +indent_size = 2 + +[*.{diff,md}] +trim_trailing_whitespace = false + +[*.php] +indent_style = space +indent_size = 4 diff --git a/extensions/tags/.eslintignore b/extensions/tags/.eslintignore new file mode 100644 index 000000000..86b7c8854 --- /dev/null +++ b/extensions/tags/.eslintignore @@ -0,0 +1,5 @@ +**/bower_components/**/* +**/node_modules/**/* +vendor/**/* +**/Gulpfile.js +**/dist/**/* diff --git a/extensions/tags/.eslintrc b/extensions/tags/.eslintrc new file mode 100644 index 000000000..9cebc759d --- /dev/null +++ b/extensions/tags/.eslintrc @@ -0,0 +1,171 @@ +{ + "parser": "babel-eslint", // https://github.com/babel/babel-eslint + "env": { // http://eslint.org/docs/user-guide/configuring.html#specifying-environments + "browser": true // browser global variables + }, + "ecmaFeatures": { + "arrowFunctions": true, + "blockBindings": true, + "classes": true, + "defaultParams": true, + "destructuring": true, + "forOf": true, + "generators": false, + "modules": true, + "objectLiteralComputedProperties": true, + "objectLiteralDuplicateProperties": false, + "objectLiteralShorthandMethods": true, + "objectLiteralShorthandProperties": true, + "spread": true, + "superInFunctions": true, + "templateStrings": true, + "jsx": true + }, + "globals": { + "m": true, + "app": true, + "$": true, + "moment": true + }, + "rules": { +/** + * Strict mode + */ + // babel inserts "use strict"; for us + "strict": [2, "never"], // http://eslint.org/docs/rules/strict + +/** + * ES6 + */ + "no-var": 2, // http://eslint.org/docs/rules/no-var + "prefer-const": 2, // http://eslint.org/docs/rules/prefer-const + +/** + * Variables + */ + "no-shadow": 2, // http://eslint.org/docs/rules/no-shadow + "no-shadow-restricted-names": 2, // http://eslint.org/docs/rules/no-shadow-restricted-names + "no-unused-vars": [2, { // http://eslint.org/docs/rules/no-unused-vars + "vars": "local", + "args": "after-used" + }], + "no-use-before-define": 2, // http://eslint.org/docs/rules/no-use-before-define + +/** + * Possible errors + */ + "comma-dangle": [2, "never"], // http://eslint.org/docs/rules/comma-dangle + "no-cond-assign": [2, "always"], // http://eslint.org/docs/rules/no-cond-assign + "no-console": 1, // http://eslint.org/docs/rules/no-console + "no-debugger": 1, // http://eslint.org/docs/rules/no-debugger + "no-alert": 1, // http://eslint.org/docs/rules/no-alert + "no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition + "no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys + "no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case + "no-empty": 2, // http://eslint.org/docs/rules/no-empty + "no-ex-assign": 2, // http://eslint.org/docs/rules/no-ex-assign + "no-extra-boolean-cast": 0, // http://eslint.org/docs/rules/no-extra-boolean-cast + "no-extra-semi": 2, // http://eslint.org/docs/rules/no-extra-semi + "no-func-assign": 2, // http://eslint.org/docs/rules/no-func-assign + "no-inner-declarations": 2, // http://eslint.org/docs/rules/no-inner-declarations + "no-invalid-regexp": 2, // http://eslint.org/docs/rules/no-invalid-regexp + "no-irregular-whitespace": 2, // http://eslint.org/docs/rules/no-irregular-whitespace + "no-obj-calls": 2, // http://eslint.org/docs/rules/no-obj-calls + "no-reserved-keys": 2, // http://eslint.org/docs/rules/no-reserved-keys + "no-sparse-arrays": 2, // http://eslint.org/docs/rules/no-sparse-arrays + "no-unreachable": 2, // http://eslint.org/docs/rules/no-unreachable + "use-isnan": 2, // http://eslint.org/docs/rules/use-isnan + "block-scoped-var": 2, // http://eslint.org/docs/rules/block-scoped-var + +/** + * Best practices + */ + "consistent-return": 2, // http://eslint.org/docs/rules/consistent-return + "curly": [2, "multi-line"], // http://eslint.org/docs/rules/curly + "default-case": 2, // http://eslint.org/docs/rules/default-case + "dot-notation": [2, { // http://eslint.org/docs/rules/dot-notation + "allowKeywords": true + }], + "eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq + "no-caller": 2, // http://eslint.org/docs/rules/no-caller + "no-else-return": 2, // http://eslint.org/docs/rules/no-else-return + "no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null + "no-eval": 2, // http://eslint.org/docs/rules/no-eval + "no-extend-native": 2, // http://eslint.org/docs/rules/no-extend-native + "no-extra-bind": 2, // http://eslint.org/docs/rules/no-extra-bind + "no-fallthrough": 2, // http://eslint.org/docs/rules/no-fallthrough + "no-floating-decimal": 2, // http://eslint.org/docs/rules/no-floating-decimal + "no-implied-eval": 2, // http://eslint.org/docs/rules/no-implied-eval + "no-lone-blocks": 2, // http://eslint.org/docs/rules/no-lone-blocks + "no-loop-func": 2, // http://eslint.org/docs/rules/no-loop-func + "no-multi-str": 2, // http://eslint.org/docs/rules/no-multi-str + "no-native-reassign": 2, // http://eslint.org/docs/rules/no-native-reassign + "no-new": 2, // http://eslint.org/docs/rules/no-new + "no-new-func": 2, // http://eslint.org/docs/rules/no-new-func + "no-new-wrappers": 2, // http://eslint.org/docs/rules/no-new-wrappers + "no-octal": 2, // http://eslint.org/docs/rules/no-octal + "no-octal-escape": 2, // http://eslint.org/docs/rules/no-octal-escape + "no-param-reassign": 2, // http://eslint.org/docs/rules/no-param-reassign + "no-proto": 2, // http://eslint.org/docs/rules/no-proto + "no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare + "no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign + "no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare + "no-sequences": 2, // http://eslint.org/docs/rules/no-sequences + "no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal + "no-with": 2, // http://eslint.org/docs/rules/no-with + "radix": 2, // http://eslint.org/docs/rules/radix + "vars-on-top": 2, // http://eslint.org/docs/rules/vars-on-top + "wrap-iife": [2, "any"], // http://eslint.org/docs/rules/wrap-iife + "yoda": 2, // http://eslint.org/docs/rules/yoda + +/** + * Style + */ + "indent": [2, 2], // http://eslint.org/docs/rules/indent + "brace-style": [2, // http://eslint.org/docs/rules/brace-style + "1tbs", { + "allowSingleLine": true + }], + "quotes": [ + 2, "single", "avoid-escape" // http://eslint.org/docs/rules/quotes + ], + "camelcase": [2, { // http://eslint.org/docs/rules/camelcase + "properties": "never" + }], + "comma-spacing": [2, { // http://eslint.org/docs/rules/comma-spacing + "before": false, + "after": true + }], + "comma-style": [2, "last"], // http://eslint.org/docs/rules/comma-style + "eol-last": 2, // http://eslint.org/docs/rules/eol-last + "func-names": 1, // http://eslint.org/docs/rules/func-names + "key-spacing": [2, { // http://eslint.org/docs/rules/key-spacing + "beforeColon": false, + "afterColon": true + }], + "new-cap": [2, { // http://eslint.org/docs/rules/new-cap + "newIsCap": true + }], + "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines + "max": 2 + }], + "no-new-object": 2, // http://eslint.org/docs/rules/no-new-object + "no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func + "no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces + "no-wrap-func": 2, // http://eslint.org/docs/rules/no-wrap-func + "no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle + "one-var": [2, "never"], // http://eslint.org/docs/rules/one-var + "padded-blocks": [2, "never"], // http://eslint.org/docs/rules/padded-blocks + "semi": [2, "always"], // http://eslint.org/docs/rules/semi + "semi-spacing": [2, { // http://eslint.org/docs/rules/semi-spacing + "before": false, + "after": true + }], + "space-after-keywords": 2, // http://eslint.org/docs/rules/space-after-keywords + "space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks + "space-before-function-paren": [2, "never"], // http://eslint.org/docs/rules/space-before-function-paren + "space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops + "space-return-throw-case": 2, // http://eslint.org/docs/rules/space-return-throw-case + "spaced-line-comment": 2, // http://eslint.org/docs/rules/spaced-line-comment + } +} diff --git a/extensions/tags/bootstrap.php b/extensions/tags/bootstrap.php index 04f029c04..4877c0f8f 100644 --- a/extensions/tags/bootstrap.php +++ b/extensions/tags/bootstrap.php @@ -1,9 +1,5 @@ app->register('Flarum\Tags\TagsServiceProvider'); +return 'Flarum\Tags\Extension'; diff --git a/extensions/tags/flarum.json b/extensions/tags/flarum.json index f4c30f9c7..71336cc9b 100644 --- a/extensions/tags/flarum.json +++ b/extensions/tags/flarum.json @@ -1,16 +1,21 @@ { - "name": "flarum-tags", + "name": "tags", "title": "Tags", "description": "Organise discussions into a heirarchy of tags and categories.", - "tags": [], + "keywords": ["discussions"], "version": "0.1.0", "author": { "name": "Toby Zerner", - "email": "toby.zerner@gmail.com" + "email": "toby@flarum.org", + "homepage": "http://tobyzerner.com" }, "license": "MIT", "require": { "php": ">=5.4.0", "flarum": ">0.1.0" + }, + "support": { + "source": "https://github.com/flarum/tags", + "issues": "https://github.com/flarum/tags/issues" } -} \ No newline at end of file +} diff --git a/extensions/tags/js/bootstrap.js b/extensions/tags/js/bootstrap.js deleted file mode 100644 index f6b326bc2..000000000 --- a/extensions/tags/js/bootstrap.js +++ /dev/null @@ -1,43 +0,0 @@ -import app from 'flarum/app'; -import Model from 'flarum/model'; -import Discussion from 'flarum/models/discussion'; -import IndexPage from 'flarum/components/index-page'; - -import Tag from 'flarum-tags/models/tag'; -import TagsPage from 'flarum-tags/components/tags-page'; -import DiscussionTaggedPost from 'flarum-tags/components/discussion-tagged-post'; -import addTagList from 'flarum-tags/add-tag-list'; -import addTagFilter from 'flarum-tags/add-tag-filter'; -import addTagLabels from 'flarum-tags/add-tag-labels'; -import addTagDiscussionControl from 'flarum-tags/add-tag-discussion-control'; -import addTagComposer from 'flarum-tags/add-tag-composer'; - -app.initializers.add('flarum-tags', function() { - // Register routes. - app.routes['tags'] = ['/tags', TagsPage.component()]; - app.routes['tag'] = ['/t/:tags', IndexPage.component()]; - - app.route.tag = function(tag) { - return app.route('tag', { tags: tag.slug() }); - }; - - // Register models. - app.store.models['tags'] = Tag; - Discussion.prototype.tags = Model.many('tags'); - Discussion.prototype.canTag = Model.prop('canTag'); - - app.postComponentRegistry['discussionTagged'] = DiscussionTaggedPost; - - // Add a list of tags to the index navigation. - addTagList(); - - // When a tag is selected, filter the discussion list by that tag. - addTagFilter(); - - // Add tags to the discussion list and discussion hero. - addTagLabels(); - - addTagDiscussionControl(); - - addTagComposer(); -}); diff --git a/extensions/tags/js/Gulpfile.js b/extensions/tags/js/forum/Gulpfile.js similarity index 61% rename from extensions/tags/js/Gulpfile.js rename to extensions/tags/js/forum/Gulpfile.js index db9c6a6d2..41637ae99 100644 --- a/extensions/tags/js/Gulpfile.js +++ b/extensions/tags/js/forum/Gulpfile.js @@ -1,5 +1,5 @@ var gulp = require('flarum-gulp'); gulp({ - modulePrefix: 'flarum-tags' + modulePrefix: 'tags' }); diff --git a/extensions/tags/js/package.json b/extensions/tags/js/forum/package.json similarity index 100% rename from extensions/tags/js/package.json rename to extensions/tags/js/forum/package.json diff --git a/extensions/tags/js/forum/src/addTagComposer.js b/extensions/tags/js/forum/src/addTagComposer.js new file mode 100644 index 000000000..03864a5ab --- /dev/null +++ b/extensions/tags/js/forum/src/addTagComposer.js @@ -0,0 +1,53 @@ +import { extend, override } from 'flarum/extend'; +import IndexPage from 'flarum/components/IndexPage'; +import DiscussionComposer from 'flarum/components/DiscussionComposer'; + +import TagDiscussionModal from 'tags/components/TagDiscussionModal'; +import tagsLabel from 'tags/helpers/tagsLabel'; + +export default function() { + override(IndexPage.prototype, 'composeNewDiscussion', function(original, deferred) { + const tag = app.store.getBy('tags', 'slug', this.params().tags); + + app.modal.show( + new TagDiscussionModal({ + selectedTags: tag ? [tag] : [], + onsubmit: tags => { + original(deferred).then(component => component.tags = tags); + } + }) + ); + + return deferred.promise; + }); + + // Add tag-selection abilities to the discussion composer. + DiscussionComposer.prototype.tags = []; + DiscussionComposer.prototype.chooseTags = function() { + app.modal.show( + new TagDiscussionModal({ + selectedTags: this.tags.slice(0), + onsubmit: tags => { + this.tags = tags; + this.$('textarea').focus(); + } + }) + ); + }; + + // Add a tag-selection menu to the discussion composer's header, after the + // title. + extend(DiscussionComposer.prototype, 'headerItems', function(items) { + items.add('tags', ( + + {tagsLabel(this.tags)} + + ), 10); + }); + + // Add the selected tags as data to submit to the server. + extend(DiscussionComposer.prototype, 'data', function(data) { + data.relationships = data.relationships || {}; + data.relationships.tags = this.tags; + }); +} diff --git a/extensions/tags/js/forum/src/addTagControl.js b/extensions/tags/js/forum/src/addTagControl.js new file mode 100644 index 000000000..adea93aed --- /dev/null +++ b/extensions/tags/js/forum/src/addTagControl.js @@ -0,0 +1,18 @@ +import { extend } from 'flarum/extend'; +import DiscussionControls from 'flarum/utils/DiscussionControls'; +import Button from 'flarum/components/Button'; + +import TagDiscussionModal from 'tags/components/TagDiscussionModal'; + +export default function() { + // Add a control allowing the discussion to be moved to another category. + extend(DiscussionControls, 'moderationControls', function(items, discussion) { + if (discussion.canTag()) { + items.add('tags', Button.component({ + children: app.trans('tags.edit_discussion_tags_link'), + icon: 'tag', + onclick: () => app.modal.show(new TagDiscussionModal({discussion})) + })); + } + }); +} diff --git a/extensions/tags/js/forum/src/addTagFilter.js b/extensions/tags/js/forum/src/addTagFilter.js new file mode 100644 index 000000000..7953ab27b --- /dev/null +++ b/extensions/tags/js/forum/src/addTagFilter.js @@ -0,0 +1,53 @@ +import { extend, override } from 'flarum/extend'; +import IndexPage from 'flarum/components/IndexPage'; +import DiscussionList from 'flarum/components/DiscussionList'; +import extract from 'flarum/utils/extract'; + +import TagHero from 'tags/components/TagHero'; + +export default function() { + IndexPage.prototype.currentTag = function() { + const slug = this.params().tags; + + if (slug) return app.store.getBy('tags', 'slug', slug); + }; + + // If currently viewing a tag, insert a tag hero at the top of the view. + override(IndexPage.prototype, 'hero', function(original) { + const tag = this.currentTag(); + + if (tag) return TagHero.component({tag}); + + return original(); + }); + + // If currently viewing a tag, restyle the 'new discussion' button to use + // the tag's color. + extend(IndexPage.prototype, 'sidebarItems', function(items) { + const tag = this.currentTag(); + + if (tag) { + const color = tag.color(); + + if (color) { + items.newDiscussion.content.props.style = {backgroundColor: color}; + } + } + }); + + // Add a parameter for the IndexPage to pass on to the DiscussionList that + // will let us filter discussions by tag. + extend(IndexPage.prototype, 'params', function(params) { + params.tags = m.route.param('tags'); + }); + + // Translate that parameter into a gambit appended to the search query. + extend(DiscussionList.prototype, 'requestParams', function(params) { + params.include.push('tags'); + + if (params.tags) { + params.filter = params.filter || {}; + params.filter.q = (params.filter.q || '') + ' tag:' + extract(params, 'tags'); + } + }); +} diff --git a/extensions/tags/js/forum/src/addTagLabels.js b/extensions/tags/js/forum/src/addTagLabels.js new file mode 100644 index 000000000..df3d2c9ad --- /dev/null +++ b/extensions/tags/js/forum/src/addTagLabels.js @@ -0,0 +1,46 @@ +import { extend } from 'flarum/extend'; +import DiscussionListItem from 'flarum/components/DiscussionListItem'; +import DiscussionPage from 'flarum/components/DiscussionPage'; +import DiscussionHero from 'flarum/components/DiscussionHero'; + +import tagsLabel from 'tags/helpers/tagsLabel'; +import sortTags from 'tags/utils/sortTags'; + +export default function() { + // Add tag labels to each discussion in the discussion list. + extend(DiscussionListItem.prototype, 'infoItems', function(items) { + const tags = this.props.discussion.tags(); + + if (tags && tags.length) { + items.add('tags', tagsLabel(tags), 10); + } + }); + + // Include a discussion's tags when fetching it. + extend(DiscussionPage.prototype, 'params', function(params) { + params.include.push('tags'); + }); + + // Restyle a discussion's hero to use its first tag's color. + extend(DiscussionHero.prototype, 'view', function(view) { + const tags = sortTags(this.props.discussion.tags()); + + if (tags && tags.length) { + const color = tags[0].color(); + if (color) { + view.attrs.style = {backgroundColor: color}; + view.attrs.className += ' DiscussionHero--colored'; + } + } + }); + + // Add a list of a discussion's tags to the discussion hero, displayed + // before the title. Put the title on its own line. + extend(DiscussionHero.prototype, 'items', function(items) { + const tags = this.props.discussion.tags(); + + if (tags && tags.length) { + items.add('tags', tagsLabel(tags, {link: true}), 5); + } + }); +} diff --git a/extensions/tags/js/forum/src/addTagList.js b/extensions/tags/js/forum/src/addTagList.js new file mode 100644 index 000000000..a91ac4fa1 --- /dev/null +++ b/extensions/tags/js/forum/src/addTagList.js @@ -0,0 +1,55 @@ +import { extend } from 'flarum/extend'; +import IndexPage from 'flarum/components/IndexPage'; +import Separator from 'flarum/components/Separator'; +import LinkButton from 'flarum/components/LinkButton'; + +import TagLinkButton from 'tags/components/TagLinkButton'; +import TagsPage from 'tags/components/TagsPage'; +import sortTags from 'tags/utils/sortTags'; + +export default function() { + // Add a link to the tags page, as well as a list of all the tags, + // to the index page's sidebar. + extend(IndexPage.prototype, 'navItems', function(items) { + items.add('tags', LinkButton.component({ + icon: 'th-large', + children: 'Tags', + href: app.route('tags') + }), -10); + + if (app.current instanceof TagsPage) return; + + items.add('separator', Separator.component(), -10); + + const params = this.stickyParams(); + const tags = app.store.all('tags'); + const currentTag = this.currentTag(); + + const addTag = tag => { + let active = currentTag === tag; + + if (!active && currentTag) { + active = currentTag.parent() === tag; + } + + items.add('tag' + tag.id(), TagLinkButton.component({tag, params, active}), -10); + }; + + sortTags(tags) + .filter(tag => tag.position() !== null && (!tag.isChild() || (currentTag && (tag.parent() === currentTag || tag.parent() === currentTag.parent())))) + .forEach(addTag); + + const more = tags + .filter(tag => tag.position() === null) + .sort((a, b) => b.discussionsCount() - a.discussionsCount()); + + more.splice(0, 3).forEach(addTag); + + if (more.length) { + items.add('moreTags', LinkButton.component({ + children: app.trans('tags.more'), + href: app.route('tags') + }), -10); + } + }); +} diff --git a/extensions/tags/js/forum/src/components/DiscussionTaggedPost.js b/extensions/tags/js/forum/src/components/DiscussionTaggedPost.js new file mode 100644 index 000000000..55aacbf3e --- /dev/null +++ b/extensions/tags/js/forum/src/components/DiscussionTaggedPost.js @@ -0,0 +1,48 @@ +import EventPost from 'flarum/components/EventPost'; +import punctuate from 'flarum/helpers/punctuate'; +import tagsLabel from 'tags/helpers/tagsLabel'; + +export default class DiscussionTaggedPost extends EventPost { + icon() { + return 'tag'; + } + + descriptionKey() { + return 'tags.discussion_tagged_post'; + } + + descriptionData() { + const post = this.props.post; + const oldTags = post.content()[0]; + const newTags = post.content()[1]; + + function diffTags(tags1, tags2) { + return tags1 + .filter(tag => tags2.indexOf(tag) === -1) + .map(id => app.store.getById('tags', id)); + } + + const added = diffTags(newTags, oldTags); + const removed = diffTags(oldTags, newTags); + const actions = []; + + if (added.length) { + actions.push(app.trans('tags.added_tags', { + tags: tagsLabel(added, {link: true}), + count: added + })); + } + + if (removed.length) { + actions.push(app.trans('tags.removed_tags', { + tags: tagsLabel(removed, {link: true}), + count: removed + })); + } + + return { + action: punctuate(actions), + count: added.length + removed.length + }; + } +} diff --git a/extensions/tags/js/forum/src/components/TagDiscussionModal.js b/extensions/tags/js/forum/src/components/TagDiscussionModal.js new file mode 100644 index 000000000..41dfdc960 --- /dev/null +++ b/extensions/tags/js/forum/src/components/TagDiscussionModal.js @@ -0,0 +1,284 @@ +import Modal from 'flarum/components/Modal'; +import DiscussionPage from 'flarum/components/DiscussionPage'; +import Button from 'flarum/components/Button'; +import highlight from 'flarum/helpers/highlight'; +import classList from 'flarum/utils/classList'; + +import tagLabel from 'tags/helpers/tagLabel'; +import tagIcon from 'tags/helpers/tagIcon'; +import sortTags from 'tags/utils/sortTags'; + +export default class TagDiscussionModal extends Modal { + constructor(...args) { + super(...args); + + this.tags = sortTags(app.store.all('tags').filter(tag => tag.canStartDiscussion())); + + this.selected = []; + this.filter = m.prop(''); + this.index = this.tags[0].id(); + this.focused = false; + + if (this.props.selectedTags) { + this.props.selectedTags.map(this.addTag.bind(this)); + } else if (this.props.discussion) { + this.props.discussion.tags().map(this.addTag.bind(this)); + } + } + + /** + * Add the given tag to the list of selected tags. + * + * @param {Tag} tag + */ + addTag(tag) { + if (!tag.canStartDiscussion()) return; + + // If this tag has a parent, we'll also need to add the parent tag to the + // selected list if it's not already in there. + const parent = tag.parent(); + if (parent) { + const index = this.selected.indexOf(parent); + if (index === -1) { + this.selected.push(parent); + } + } + + this.selected.push(tag); + } + + /** + * Remove the given tag from the list of selected tags. + * + * @param {Tag} tag + */ + removeTag(tag) { + const index = this.selected.indexOf(tag); + if (index !== -1) { + this.selected.splice(index, 1); + + // Look through the list of selected tags for any tags which have the tag + // we just removed as their parent. We'll need to remove them too. + this.selected + .filter(selected => selected.parent() && selected.parent() === tag) + .forEach(this.removeTag); + } + } + + className() { + return 'TagDiscussionModal'; + } + + title() { + return this.props.discussion + ? app.trans('tags.edit_discussion_tags_title', {title: {this.props.discussion.title()}}) + : app.trans('tags.tag_new_discussion_title'); + } + + content() { + let tags = this.tags; + const filter = this.filter().toLowerCase(); + + if (filter) { + tags = tags.filter(tag => tag.name().substr(0, filter.length).toLowerCase() === filter); + } + + if (tags.indexOf(this.index) === -1) { + this.index = tags[0]; + } + + return [ +
    +
    +
    +
    + + {this.selected.map(tag => + { + this.removeTag(tag); + this.onready(); + }}> + {tagLabel(tag)} + + )} + + this.focused = true} + onblur={() => this.focused = false}/> +
    +
    +
    + {Button.component({ + type: 'submit', + className: 'Button Button--primary', + disabled: !this.selected.length, + icon: 'check', + children: app.trans('tags.confirm') + })} +
    +
    +
    , + +
    +
      + {tags.map(tag => { + if (!filter && tag.parent() && this.selected.indexOf(tag.parent()) === -1) { + return ''; + } + + return ( +
    • this.index = tag} + onclick={this.toggleTag.bind(this, tag)} + > + {tagIcon(tag)} + + {highlight(tag.name(), filter)} + + {tag.description() + ? ( + + {tag.description()} + + ) : ''} +
    • + ); + })} +
    +
    + ]; + } + + toggleTag(tag) { + const index = this.selected.indexOf(tag); + + if (index !== -1) { + this.removeTag(tag); + } else { + this.addTag(tag); + } + + if (this.filter()) { + this.filter(''); + this.index = this.tags[0]; + } + + this.onready(); + } + + onkeydown(e) { + switch (e.which) { + case 40: + case 38: // Down/Up + e.preventDefault(); + this.setIndex(this.getCurrentNumericIndex() + (e.which === 40 ? 1 : -1), true); + break; + + case 13: // Return + e.preventDefault(); + if (e.metaKey || e.ctrlKey || this.selected.indexOf(this.index) !== -1) { + if (this.selected.length) { + this.$('form').submit(); + } + } else { + this.getItem(this.index)[0].dispatchEvent(new Event('click')); + } + break; + + case 8: // Backspace + if (e.target.selectionStart === 0 && e.target.selectionEnd === 0) { + e.preventDefault(); + this.selected.splice(this.selected.length - 1, 1); + } + break; + + default: + // no default + } + } + + selectableItems() { + return this.$('.TagDiscussionModal-list > li'); + } + + getCurrentNumericIndex() { + return this.selectableItems().index( + this.getItem(this.index) + ); + } + + getItem(index) { + return this.selectableItems().filter(`[data-index="${index.id()}"]`); + } + + setIndex(index, scrollToItem) { + const $items = this.selectableItems(); + const $dropdown = $items.parent(); + + if (index < 0) { + index = $items.length - 1; + } else if (index >= $items.length) { + index = 0; + } + + const $item = $items.eq(index); + + this.index = app.store.getById('tags', $item.attr('data-index')); + + m.redraw(); + + if (scrollToItem) { + const dropdownScroll = $dropdown.scrollTop(); + const dropdownTop = $dropdown.offset().top; + const dropdownBottom = dropdownTop + $dropdown.outerHeight(); + const itemTop = $item.offset().top; + const itemBottom = itemTop + $item.outerHeight(); + + let scrollTop; + if (itemTop < dropdownTop) { + scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10); + } else if (itemBottom > dropdownBottom) { + scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10); + } + + if (typeof scrollTop !== 'undefined') { + $dropdown.stop(true).animate({scrollTop}, 100); + } + } + } + + onsubmit(e) { + e.preventDefault(); + + const discussion = this.props.discussion; + const tags = this.selected; + + if (discussion) { + discussion.save({relationships: {tags}}) + .then(() => { + if (app.current instanceof DiscussionPage) { + app.current.stream.update(); + } + m.redraw(); + }); + } + + if (this.props.onsubmit) this.props.onsubmit(tags); + + app.modal.close(); + + m.redraw.strategy('none'); + } +} diff --git a/extensions/tags/js/forum/src/components/TagHero.js b/extensions/tags/js/forum/src/components/TagHero.js new file mode 100644 index 000000000..89e3f69e4 --- /dev/null +++ b/extensions/tags/js/forum/src/components/TagHero.js @@ -0,0 +1,20 @@ +import Component from 'flarum/Component'; + +export default class TagHero extends Component { + view() { + const tag = this.props.tag; + const color = tag.color(); + + return ( +
    +
    +
    +

    {tag.name()}

    +
    {tag.description()}
    +
    +
    +
    + ); + } +} diff --git a/extensions/tags/js/forum/src/components/TagLinkButton.js b/extensions/tags/js/forum/src/components/TagLinkButton.js new file mode 100644 index 000000000..46e0aab4f --- /dev/null +++ b/extensions/tags/js/forum/src/components/TagLinkButton.js @@ -0,0 +1,27 @@ +import LinkButton from 'flarum/components/LinkButton'; +import tagIcon from 'tags/helpers/tagIcon'; + +export default class TagLinkButton extends LinkButton { + view() { + const tag = this.props.tag; + const active = this.constructor.isActive(this.props); + const description = tag && tag.description(); + + return ( + + {tagIcon(tag, {className: 'Button-icon'})} + {this.props.children} + + ); + } + + static initProps(props) { + const tag = props.tag; + + props.params.tags = tag ? tag.slug() : 'untagged'; + props.href = app.route('tag', props.params); + props.children = tag ? tag.name() : app.trans('tags.untagged'); + } +} diff --git a/extensions/tags/js/forum/src/components/TagsPage.js b/extensions/tags/js/forum/src/components/TagsPage.js new file mode 100644 index 000000000..3e79b0a6f --- /dev/null +++ b/extensions/tags/js/forum/src/components/TagsPage.js @@ -0,0 +1,101 @@ +import Component from 'flarum/Component'; +import IndexPage from 'flarum/components/IndexPage'; +import listItems from 'flarum/helpers/listItems'; +import humanTime from 'flarum/helpers/humanTime'; + +import sortTags from 'tags/utils/sortTags'; + +export default class TagsPage extends Component { + constructor(...args) { + super(...args); + + this.tags = sortTags(app.store.all('tags').filter(tag => !tag.parent())); + + app.current = this; + app.history.push('tags'); + app.drawer.hide(); + app.modal.close(); + } + + view() { + const pinned = this.tags.filter(tag => tag.position() !== null); + const cloud = this.tags.filter(tag => tag.position() === null); + + return ( +
    + {IndexPage.prototype.hero()} +
    + + +
    + + + {cloud.length ? ( +
    +

    {app.trans('tags.tag_cloud_title')}

    +
    + {cloud.map(tag => { + const color = tag.color(); + + return [ + + {tag.name()} + , + ' ' + ]; + })} +
    +
    + ) : ''} +
    +
    +
    + ); + } + + config() { + IndexPage.prototype.config.apply(this, arguments); + } + + onunload() { + IndexPage.prototype.onunload.apply(this, arguments); + } +} diff --git a/extensions/tags/js/forum/src/helpers/tagIcon.js b/extensions/tags/js/forum/src/helpers/tagIcon.js new file mode 100644 index 000000000..31dea0b17 --- /dev/null +++ b/extensions/tags/js/forum/src/helpers/tagIcon.js @@ -0,0 +1,12 @@ +export default function tagIcon(tag, attrs = {}) { + attrs.className = 'icon TagIcon ' + (attrs.className || ''); + + if (tag) { + attrs.style = attrs.style || {}; + attrs.style.backgroundColor = tag.color(); + } else { + attrs.className += ' untagged'; + } + + return ; +} diff --git a/extensions/tags/js/src/helpers/tag-label.js b/extensions/tags/js/forum/src/helpers/tagLabel.js similarity index 50% rename from extensions/tags/js/src/helpers/tag-label.js rename to extensions/tags/js/forum/src/helpers/tagLabel.js index 11c401267..359bf0fde 100644 --- a/extensions/tags/js/src/helpers/tag-label.js +++ b/extensions/tags/js/forum/src/helpers/tagLabel.js @@ -1,17 +1,17 @@ -export default function tagsLabel(tag, attrs) { - attrs = attrs || {}; - attrs.style = attrs.style || {}; - attrs.className = attrs.className || ''; +import extract from 'flarum/utils/extract'; - var link = attrs.link; - delete attrs.link; +export default function tagLabel(tag, attrs = {}) { + attrs.style = attrs.style || {}; + attrs.className = 'TagLabel ' + (attrs.className || ''); + + const link = extract(attrs, 'link'); if (link) { attrs.href = app.route('tag', {tags: tag.slug()}); attrs.config = m.route; } if (tag) { - var color = tag.color(); + const color = tag.color(); if (color) { attrs.style.backgroundColor = attrs.style.color = color; attrs.className += ' colored'; @@ -24,5 +24,11 @@ export default function tagsLabel(tag, attrs) { attrs.className += ' untagged'; } - return m((link ? 'a' : 'span')+'.tag-label', attrs, m('span.tag-label-text', tag ? tag.name() : 'Untagged')); + return ( + m((link ? 'a' : 'span'), attrs, + + {tag ? tag.name() : app.trans('tags.untagged')} + + ) + ); } diff --git a/extensions/tags/js/forum/src/helpers/tagsLabel.js b/extensions/tags/js/forum/src/helpers/tagsLabel.js new file mode 100644 index 000000000..6eb768cf8 --- /dev/null +++ b/extensions/tags/js/forum/src/helpers/tagsLabel.js @@ -0,0 +1,22 @@ +import extract from 'flarum/utils/extract'; +import tagLabel from 'tags/helpers/tagLabel'; +import sortTags from 'tags/utils/sortTags'; + +export default function tagsLabel(tags, attrs = {}) { + const children = []; + const link = extract(attrs, 'link'); + + attrs.className = 'TagsLabel ' + (attrs.className || ''); + + if (tags) { + sortTags(tags).forEach(tag => { + if (tag || tags.length === 1) { + children.push(tagLabel(tag, {link})); + } + }); + } else { + children.push(tagLabel()); + } + + return {children}; +} diff --git a/extensions/tags/js/forum/src/main.js b/extensions/tags/js/forum/src/main.js new file mode 100644 index 000000000..907987a3e --- /dev/null +++ b/extensions/tags/js/forum/src/main.js @@ -0,0 +1,33 @@ +import Model from 'flarum/Model'; +import Discussion from 'flarum/models/Discussion'; +import IndexPage from 'flarum/components/IndexPage'; + +import Tag from 'tags/models/Tag'; +import TagsPage from 'tags/components/TagsPage'; +import DiscussionTaggedPost from 'tags/components/DiscussionTaggedPost'; + +import addTagList from 'tags/addTagList'; +import addTagFilter from 'tags/addTagFilter'; +import addTagLabels from 'tags/addTagLabels'; +import addTagControl from 'tags/addTagControl'; +import addTagComposer from 'tags/addTagComposer'; + +app.initializers.add('tags', function(app) { + app.routes.tags = {path: '/tags', component: TagsPage.component()}; + app.routes.tag = {path: '/t/:tags', component: IndexPage.component()}; + + app.route.tag = tag => app.route('tag', {tags: tag.slug()}); + + app.postComponents.discussionTagged = DiscussionTaggedPost; + + app.store.models.tags = Tag; + + Discussion.prototype.tags = Model.hasMany('tags'); + Discussion.prototype.canTag = Model.attribute('canTag'); + + addTagList(); + addTagFilter(); + addTagLabels(); + addTagControl(); + addTagComposer(); +}); diff --git a/extensions/tags/js/forum/src/models/Tag.js b/extensions/tags/js/forum/src/models/Tag.js new file mode 100644 index 000000000..7e69d31e7 --- /dev/null +++ b/extensions/tags/js/forum/src/models/Tag.js @@ -0,0 +1,24 @@ +import Model from 'flarum/Model'; +import mixin from 'flarum/utils/mixin'; + +export default class Tag extends mixin(Model, { + name: Model.attribute('name'), + slug: Model.attribute('slug'), + description: Model.attribute('description'), + + color: Model.attribute('color'), + backgroundUrl: Model.attribute('backgroundUrl'), + backgroundMode: Model.attribute('backgroundMode'), + iconUrl: Model.attribute('iconUrl'), + + position: Model.attribute('position'), + parent: Model.hasOne('parent'), + defaultSort: Model.attribute('defaultSort'), + isChild: Model.attribute('isChild'), + + discussionsCount: Model.attribute('discussionsCount'), + lastTime: Model.attribute('lastTime', Model.transformDate), + lastDiscussion: Model.hasOne('lastDiscussion'), + + canStartDiscussion: Model.attribute('canStartDiscussion') +}) {} diff --git a/extensions/tags/js/src/utils/sort-tags.js b/extensions/tags/js/forum/src/utils/sortTags.js similarity index 80% rename from extensions/tags/js/src/utils/sort-tags.js rename to extensions/tags/js/forum/src/utils/sortTags.js index c46c8e39d..c0e5d2191 100644 --- a/extensions/tags/js/src/utils/sort-tags.js +++ b/extensions/tags/js/forum/src/utils/sortTags.js @@ -1,10 +1,10 @@ export default function sortTags(tags) { return tags.slice(0).sort((a, b) => { - var aPos = a.position(); - var bPos = b.position(); + const aPos = a.position(); + const bPos = b.position(); - var aParent = a.parent(); - var bParent = b.parent(); + const aParent = a.parent(); + const bParent = b.parent(); if (aPos === null && bPos === null) { return b.discussionsCount() - a.discussionsCount(); @@ -22,4 +22,4 @@ export default function sortTags(tags) { return 0; }); -}; +} diff --git a/extensions/tags/js/src/add-tag-composer.js b/extensions/tags/js/src/add-tag-composer.js deleted file mode 100644 index ab2cd983f..000000000 --- a/extensions/tags/js/src/add-tag-composer.js +++ /dev/null @@ -1,54 +0,0 @@ -import { extend, override } from 'flarum/extension-utils'; -import IndexPage from 'flarum/components/index-page'; -import DiscussionComposer from 'flarum/components/discussion-composer'; -import icon from 'flarum/helpers/icon'; - -import TagDiscussionModal from 'flarum-tags/components/tag-discussion-modal'; -import tagsLabel from 'flarum-tags/helpers/tags-label'; - -export default function() { - override(IndexPage.prototype, 'composeNewDiscussion', function(original, deferred) { - var tag = app.store.getBy('tags', 'slug', this.params().tags); - - app.modal.show( - new TagDiscussionModal({ - selectedTags: tag ? [tag] : [], - onsubmit: tags => { - original(deferred).then(component => component.tags(tags)); - } - }) - ); - - return deferred.promise; - }); - - // Add tag-selection abilities to the discussion composer. - DiscussionComposer.prototype.tags = m.prop([]); - DiscussionComposer.prototype.chooseTags = function() { - app.modal.show( - new TagDiscussionModal({ - selectedTags: this.tags().slice(0), - onsubmit: tags => { - this.tags(tags); - this.$('textarea').focus(); - } - }) - ); - }; - - // Add a tag-selection menu to the discussion composer's header, after the - // title. - extend(DiscussionComposer.prototype, 'headerItems', function(items) { - var tags = this.tags(); - - items.add('tags', m('a[href=javascript:;][tabindex=-1].control-change-tags', {onclick: this.chooseTags.bind(this)}, [ - tagsLabel(tags) - ])); - }); - - // Add the selected tags as data to submit to the server. - extend(DiscussionComposer.prototype, 'data', function(data) { - data.links = data.links || {}; - data.links.tags = this.tags(); - }); -}; diff --git a/extensions/tags/js/src/add-tag-discussion-control.js b/extensions/tags/js/src/add-tag-discussion-control.js deleted file mode 100644 index 20257f2bf..000000000 --- a/extensions/tags/js/src/add-tag-discussion-control.js +++ /dev/null @@ -1,18 +0,0 @@ -import { extend } from 'flarum/extension-utils'; -import Discussion from 'flarum/models/discussion'; -import ActionButton from 'flarum/components/action-button'; - -import TagDiscussionModal from 'flarum-tags/components/tag-discussion-modal'; - -export default function() { - // Add a control allowing the discussion to be moved to another category. - extend(Discussion.prototype, 'moderationControls', function(items) { - if (this.canTag()) { - items.add('tags', ActionButton.component({ - label: 'Edit Tags', - icon: 'tag', - onclick: () => app.modal.show(new TagDiscussionModal({ discussion: this })) - })); - } - }); -}; diff --git a/extensions/tags/js/src/add-tag-filter.js b/extensions/tags/js/src/add-tag-filter.js deleted file mode 100644 index 8bc2265e2..000000000 --- a/extensions/tags/js/src/add-tag-filter.js +++ /dev/null @@ -1,50 +0,0 @@ -import { extend } from 'flarum/extension-utils'; -import IndexPage from 'flarum/components/index-page'; -import DiscussionList from 'flarum/components/discussion-list'; - -import TagHero from 'flarum-tags/components/tag-hero'; - -export default function() { - IndexPage.prototype.currentTag = function() { - var slug = this.params().tags; - if (slug) { - return app.store.getBy('tags', 'slug', slug); - } - }; - - // If currently viewing a tag, insert a tag hero at the top of the - // view. - extend(IndexPage.prototype, 'view', function(view) { - var tag = this.currentTag(); - if (tag) { - view.children[0] = TagHero.component({tag}); - } - }); - - // If currently viewing a tag, restyle the 'new discussion' button to use - // the tag's color. - extend(IndexPage.prototype, 'sidebarItems', function(items) { - var tag = this.currentTag(); - if (tag) { - var color = tag.color(); - if (color) { - items.newDiscussion.content.props.style = 'background-color: '+color; - } - } - }); - - // Add a parameter for the IndexPage to pass on to the DiscussionList that - // will let us filter discussions by tag. - extend(IndexPage.prototype, 'params', function(params) { - params.tags = m.route.param('tags'); - }); - - // Translate that parameter into a gambit appended to the search query. - extend(DiscussionList.prototype, 'params', function(params) { - params.include.push('tags'); - if (params.tags) { - params.q = (params.q || '')+' tag:'+params.tags; - delete params.tags; - } - }); -}; diff --git a/extensions/tags/js/src/add-tag-labels.js b/extensions/tags/js/src/add-tag-labels.js deleted file mode 100644 index f685a2498..000000000 --- a/extensions/tags/js/src/add-tag-labels.js +++ /dev/null @@ -1,45 +0,0 @@ -import { extend } from 'flarum/extension-utils'; -import DiscussionListItem from 'flarum/components/discussion-list-item'; -import DiscussionPage from 'flarum/components/discussion-page'; -import DiscussionHero from 'flarum/components/discussion-hero'; - -import tagsLabel from 'flarum-tags/helpers/tags-label'; -import sortTags from 'flarum-tags/utils/sort-tags'; - -export default function() { - // Add tag labels to each discussion in the discussion list. - extend(DiscussionListItem.prototype, 'infoItems', function(items) { - var tags = this.props.discussion.tags(); - if (tags && tags.length) { - items.add('tags', tagsLabel(tags), {first: true}); - } - }); - - // Include a discussion's tags when fetching it. - extend(DiscussionPage.prototype, 'params', function(params) { - params.include.push('tags'); - }); - - // Restyle a discussion's hero to use its first tag's color. - extend(DiscussionHero.prototype, 'view', function(view) { - var tags = sortTags(this.props.discussion.tags()); - if (tags && tags.length) { - var color = tags[0].color(); - if (color) { - view.attrs.style = 'background-color: '+color; - view.attrs.className += ' discussion-hero-colored'; - } - } - }); - - // Add a list of a discussion's tags to the discussion hero, displayed - // before the title. Put the title on its own line. - extend(DiscussionHero.prototype, 'items', function(items) { - var tags = this.props.discussion.tags(); - if (tags && tags.length) { - items.add('tags', tagsLabel(tags, {link: true}), {before: 'title'}); - - items.title.content.wrapperClass = 'block-item'; - } - }); -}; diff --git a/extensions/tags/js/src/add-tag-list.js b/extensions/tags/js/src/add-tag-list.js deleted file mode 100644 index 8f3c142ee..000000000 --- a/extensions/tags/js/src/add-tag-list.js +++ /dev/null @@ -1,51 +0,0 @@ -import { extend } from 'flarum/extension-utils'; -import IndexPage from 'flarum/components/index-page'; -import NavItem from 'flarum/components/nav-item'; -import Separator from 'flarum/components/separator'; - -import TagNavItem from 'flarum-tags/components/tag-nav-item'; -import TagsPage from 'flarum-tags/components/tags-page'; - -export default function() { - // Add a link to the tags page, as well as a list of all the tags, - // to the index page's sidebar. - extend(IndexPage.prototype, 'navItems', function(items) { - items.add('tags', NavItem.component({ - icon: 'th-large', - label: 'Tags', - href: app.route('tags'), - config: m.route - }), {last: true}); - - if (app.current instanceof TagsPage) return; - - items.add('separator', Separator.component(), {last: true}); - - var params = this.stickyParams(); - var tags = app.store.all('tags'); - - var addTag = tag => { - var currentTag = this.currentTag(); - var active = currentTag === tag; - if (!active && currentTag) { - currentTag = currentTag.parent(); - active = currentTag === tag; - } - items.add('tag'+tag.id(), TagNavItem.component({tag, params, active}), {last: true}); - } - - tags.filter(tag => tag.position() !== null && !tag.isChild()).sort((a, b) => a.position() - b.position()).forEach(addTag); - - var more = tags.filter(tag => tag.position() === null).sort((a, b) => b.discussionsCount() - a.discussionsCount()); - - more.splice(0, 3).forEach(addTag); - - if (more.length) { - items.add('moreTags', NavItem.component({ - label: 'More...', - href: app.route('tags'), - config: m.route - }), {last: true});; - } - }); -}; diff --git a/extensions/tags/js/src/components/discussion-tagged-post.js b/extensions/tags/js/src/components/discussion-tagged-post.js deleted file mode 100644 index 0bf37f2d0..000000000 --- a/extensions/tags/js/src/components/discussion-tagged-post.js +++ /dev/null @@ -1,25 +0,0 @@ -import EventPost from 'flarum/components/event-post'; -import tagsLabel from 'flarum-tags/helpers/tags-label'; - -export default class DiscussionTaggedPost extends EventPost { - view() { - var post = this.props.post; - var oldTags = post.content()[0]; - var newTags = post.content()[1]; - - var added = newTags.filter(tag => oldTags.indexOf(tag) === -1).map(id => app.store.getById('tags', id)); - var removed = oldTags.filter(tag => newTags.indexOf(tag) === -1).map(id => app.store.getById('tags', id)); - var total = added.concat(removed); - - var build = function(verb, tags, only) { - return tags.length ? [verb, ' ', only && tags.length == 1 ? 'the ' : '', tagsLabel(tags, {link: true})] : ''; - }; - - return super.view('tag', [ - build('added', added, !removed.length), - added.length && removed.length ? ' and ' : '', - build('removed', removed, !added.length), - total.length ? (total.length == 1 ? ' tag.' : ' tags.') : '' - ]); - } -} diff --git a/extensions/tags/js/src/components/tag-discussion-modal.js b/extensions/tags/js/src/components/tag-discussion-modal.js deleted file mode 100644 index 89fbf77e1..000000000 --- a/extensions/tags/js/src/components/tag-discussion-modal.js +++ /dev/null @@ -1,241 +0,0 @@ -import FormModal from 'flarum/components/form-modal'; -import DiscussionPage from 'flarum/components/discussion-page'; -import highlight from 'flarum/helpers/highlight'; -import icon from 'flarum/helpers/icon'; -import classList from 'flarum/utils/class-list'; - -import tagLabel from 'flarum-tags/helpers/tag-label'; -import tagIcon from 'flarum-tags/helpers/tag-icon'; -import sortTags from 'flarum-tags/utils/sort-tags'; - -export default class TagDiscussionModal extends FormModal { - constructor(props) { - super(props); - - this.tags = sortTags(app.store.all('tags').filter(tag => tag.canStartDiscussion())); - - this.selected = m.prop([]); - if (this.props.selectedTags) { - this.props.selectedTags.map(this.addTag.bind(this)); - } else if (this.props.discussion) { - this.props.discussion.tags().map(this.addTag.bind(this)); - } - - this.filter = m.prop(''); - - this.index = m.prop(this.tags[0].id()); - - this.focused = m.prop(false); - } - - addTag(tag) { - if (!tag.canStartDiscussion()) return; - - var selected = this.selected(); - var parent = tag.parent(); - if (parent) { - var index = selected.indexOf(parent); - if (index === -1) { - selected.push(parent); - } - } - selected.push(tag); - } - - removeTag(tag) { - var selected = this.selected(); - var index = selected.indexOf(tag); - selected.splice(index, 1); - selected.filter(selected => selected.parent() && selected.parent() === tag).forEach(child => { - var index = selected.indexOf(child); - selected.splice(index, 1); - }); - } - - view() { - var discussion = this.props.discussion; - var selected = this.selected(); - - var tags = this.tags; - var filter = this.filter().toLowerCase(); - - if (filter) { - tags = tags.filter(tag => tag.name().substr(0, filter.length).toLowerCase() === filter); - } - - if (tags.indexOf(this.index()) === -1) { - this.index(tags[0]); - } - - return super.view({ - className: 'tag-discussion-modal', - title: discussion - ? ['Edit Tags for ', m('em', discussion.title())] - : 'Start a Discussion About...', - body: [ - m('div.tags-form', [ - m('div.tags-input.form-control', {className: this.focused() ? 'focus' : ''}, [ - m('span.tags-input-selected', selected.map(tag => - m('span.remove-tag', {onclick: () => { - this.removeTag(tag); - this.ready(); - }}, tagLabel(tag)) - )), - m('input.form-control', { - placeholder: !selected.length ? 'Choose one or more topics' : '', - value: this.filter(), - oninput: m.withAttr('value', this.filter), - onkeydown: this.onkeydown.bind(this), - onfocus: () => this.focused(true), - onblur: () => this.focused(false) - }) - ]), - m('span.primary-control', - m('button[type=submit].btn.btn-primary', {disabled: !selected.length}, icon('check icon'), m('span.label', 'Confirm')) - ) - ]) - ], - footer: [ - m('ul.tags-select', tags.map(tag => - filter || !tag.parent() || selected.indexOf(tag.parent()) !== -1 - ? m('li', { - 'data-index': tag.id(), - className: classList({ - pinned: tag.position() !== null, - child: !!tag.parent(), - colored: !!tag.color(), - selected: selected.indexOf(tag) !== -1, - active: this.index() == tag - }), - style: { - color: tag.color() - }, - onmouseover: () => { - this.index(tag); - }, - onclick: () => { - var selected = this.selected(); - var index = selected.indexOf(tag); - if (index !== -1) { - this.removeTag(tag); - } else { - this.addTag(tag); - } - if (this.filter()) { - this.filter(''); - this.index(this.tags[0]); - } - this.ready(); - } - }, [ - tagIcon(tag), - m('span.name', highlight(tag.name(), filter)), - tag.description() ? m('span.description', {title: tag.description()}, tag.description()) : '' - ]) - : '' - )) - ] - }); - } - - onkeydown(e) { - switch (e.which) { - case 40: - case 38: // Down/Up - e.preventDefault(); - this.setIndex(this.getCurrentNumericIndex() + (e.which === 40 ? 1 : -1), true); - break; - - case 13: // Return - e.preventDefault(); - if (e.metaKey || e.ctrlKey || this.selected().indexOf(this.index()) !== -1) { - if (this.selected().length) { - this.$('form').submit(); - } - } else { - this.getItem(this.index())[0].dispatchEvent(new Event('click')); - } - break; - - case 8: // Backspace - if (e.target.selectionStart == 0 && e.target.selectionEnd == 0) { - e.preventDefault(); - var selected = this.selected(); - selected.splice(selected.length - 1, 1); - } - } - } - - selectableItems() { - return this.$('.tags-select > li'); - } - - getCurrentNumericIndex() { - return this.selectableItems().index( - this.getItem(this.index()) - ); - } - - getItem(index) { - var $items = this.selectableItems(); - return $items.filter('[data-index='+index.id()+']'); - } - - setIndex(index, scrollToItem) { - var $items = this.selectableItems(); - var $dropdown = $items.parent(); - - if (index < 0) { - index = $items.length - 1; - } else if (index >= $items.length) { - index = 0; - } - - var $item = $items.eq(index); - - this.index(app.store.getById('tags', $item.attr('data-index'))); - - m.redraw(); - - if (scrollToItem) { - var dropdownScroll = $dropdown.scrollTop(); - var dropdownTop = $dropdown.offset().top; - var dropdownBottom = dropdownTop + $dropdown.outerHeight(); - var itemTop = $item.offset().top; - var itemBottom = itemTop + $item.outerHeight(); - - var scrollTop; - if (itemTop < dropdownTop) { - scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top')); - } else if (itemBottom > dropdownBottom) { - scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom')); - } - - if (typeof scrollTop !== 'undefined') { - $dropdown.stop(true).animate({scrollTop}, 100); - } - } - } - - onsubmit(e) { - e.preventDefault(); - - var discussion = this.props.discussion; - var tags = this.selected(); - - if (discussion) { - discussion.save({links: {tags}}).then(discussion => { - if (app.current instanceof DiscussionPage) { - app.current.stream.sync(); - } - m.redraw(); - }); - } - - this.props.onsubmit && this.props.onsubmit(tags); - - app.modal.close(); - - m.redraw.strategy('none'); - } -} diff --git a/extensions/tags/js/src/components/tag-hero.js b/extensions/tags/js/src/components/tag-hero.js deleted file mode 100644 index e36b1b5ba..000000000 --- a/extensions/tags/js/src/components/tag-hero.js +++ /dev/null @@ -1,17 +0,0 @@ -import Component from 'flarum/component'; - -export default class TagHero extends Component { - view() { - var tag = this.props.tag; - var color = tag.color(); - - return m('header.hero.tag-hero', {style: color ? 'color: #fff; background-color: '+tag.color() : ''}, [ - m('div.container', [ - m('div.container-narrow', [ - m('h2', tag.name()), - m('div.subtitle', tag.description()) - ]) - ]) - ]); - } -} diff --git a/extensions/tags/js/src/components/tag-nav-item.js b/extensions/tags/js/src/components/tag-nav-item.js deleted file mode 100644 index 87262a9c3..000000000 --- a/extensions/tags/js/src/components/tag-nav-item.js +++ /dev/null @@ -1,44 +0,0 @@ -import NavItem from 'flarum/components/nav-item'; -import tagIcon from 'flarum-tags/helpers/tag-icon'; - -export default class TagNavItem extends NavItem { - view() { - var tag = this.props.tag; - var active = this.constructor.active(this.props); - var description = tag && tag.description(); - var children; - - if (active && tag) { - children = app.store.all('tags').filter(child => { - var parent = child.parent(); - return parent && parent.id() == tag.id(); - }); - } - - return m('li'+(active ? '.active' : ''), - m('a.has-icon', { - href: this.props.href, - config: m.route, - onclick: () => { - if (app.cache.discussionList) { - app.cache.discussionList.forceReload = true; - } - m.redraw.strategy('none'); - }, - style: (active && tag) ? 'color: '+tag.color() : '', - title: description || '' - }, [ - tagIcon(tag, {className: 'icon'}), - this.props.label - ]), - children && children.length ? m('ul.dropdown-menu', children.map(tag => TagNavItem.component({tag, params: this.props.params}))) : '' - ); - } - - static props(props) { - var tag = props.tag; - props.params.tags = tag ? tag.slug() : 'untagged'; - props.href = app.route('tag', props.params); - props.label = tag ? tag.name() : 'Untagged'; - } -} diff --git a/extensions/tags/js/src/components/tags-page.js b/extensions/tags/js/src/components/tags-page.js deleted file mode 100644 index 6c7ec39d1..000000000 --- a/extensions/tags/js/src/components/tags-page.js +++ /dev/null @@ -1,80 +0,0 @@ -import Component from 'flarum/component'; -import WelcomeHero from 'flarum/components/welcome-hero'; -import IndexPage from 'flarum/components/index-page'; -import icon from 'flarum/helpers/icon'; -import listItems from 'flarum/helpers/list-items'; -import abbreviateNumber from 'flarum/utils/abbreviate-number'; -import humanTime from 'flarum/helpers/human-time'; - -import sortTags from 'flarum-tags/utils/sort-tags'; - -export default class TagsPage extends Component { - constructor(props) { - super(props); - - this.tags = sortTags(app.store.all('tags').filter(tag => !tag.parent())); - - app.current = this; - app.history.push('tags'); - } - - view() { - var pinned = this.tags.filter(tag => tag.position() !== null); - var cloud = this.tags.filter(tag => tag.position() === null); - - return m('div.tags-area', {config: this.onload.bind(this)}, [ - IndexPage.prototype.hero(), - m('div.container', [ - m('nav.side-nav.index-nav', [ - m('ul', listItems(IndexPage.prototype.sidebarItems().toArray())) - ]), - m('div.offset-content.tags-content', [ - m('ul.tag-tiles', [ - pinned.map(tag => { - var lastDiscussion = tag.lastDiscussion(); - var children = app.store.all('tags').filter(child => { - var parent = child.parent(); - return parent && parent.id() == tag.id(); - }); - - return m('li.tag-tile', {className: tag.color() ? 'colored' : '', style: 'background-color: '+tag.color()}, [ - m('a.tag-info', {href: app.route.tag(tag), config: m.route}, [ - m('h3.name', tag.name()), - m('p.description', tag.description()), - children ? m('div.children', children.map(tag => - m('a', {href: app.route.tag(tag), config: function(element, isInitialized) { - if (isInitialized) return; - $(element).on('click', e => e.stopPropagation()); - m.route.apply(this, arguments); - }}, tag.name()) - )) : '' - ]), - lastDiscussion - ? m('a.last-discussion', { - href: app.route.discussion(lastDiscussion, lastDiscussion.lastPostNumber()), - config: m.route - }, [humanTime(lastDiscussion.lastTime()), m('span.title', lastDiscussion.title())]) - : m('span.last-discussion') - ]); - }) - ]), - cloud.length ? m('div.tag-cloud', [ - m('h4', 'Tags'), - m('div.tag-cloud-content', cloud.map(tag => [ - m('a', {href: app.route.tag(tag), config: m.route, style: tag.color() ? 'color: '+tag.color() : ''}, tag.name()), - ' ' - ])) - ]) : '' - ]) - ]) - ]); - } - - onload(element, isInitialized, context) { - IndexPage.prototype.onload.apply(this, arguments); - } - - onunload() { - IndexPage.prototype.onunload.apply(this); - } -} diff --git a/extensions/tags/js/src/helpers/tag-icon.js b/extensions/tags/js/src/helpers/tag-icon.js deleted file mode 100644 index 91f4de7c3..000000000 --- a/extensions/tags/js/src/helpers/tag-icon.js +++ /dev/null @@ -1,12 +0,0 @@ -export default function tagIcon(tag, attrs) { - attrs = attrs || {}; - - if (tag) { - attrs.style = attrs.style || {}; - attrs.style.backgroundColor = tag.color(); - } else { - attrs.className = (attrs.className || '')+' untagged'; - } - - return m('span.icon.tag-icon', attrs); -} diff --git a/extensions/tags/js/src/helpers/tags-label.js b/extensions/tags/js/src/helpers/tags-label.js deleted file mode 100644 index 2d7861d9c..000000000 --- a/extensions/tags/js/src/helpers/tags-label.js +++ /dev/null @@ -1,22 +0,0 @@ -import tagLabel from 'flarum-tags/helpers/tag-label'; -import sortTags from 'flarum-tags/utils/sort-tags'; - -export default function tagsLabel(tags, attrs) { - attrs = attrs || {}; - var children = []; - - var link = attrs.link; - delete attrs.link; - - if (tags) { - sortTags(tags).forEach(tag => { - if (tag || tags.length === 1) { - children.push(tagLabel(tag, {link})); - } - }); - } else { - children.push(tagLabel()); - } - - return m('span.tags-label', attrs, children); -} diff --git a/extensions/tags/js/src/models/tag.js b/extensions/tags/js/src/models/tag.js deleted file mode 100644 index fc8b075bd..000000000 --- a/extensions/tags/js/src/models/tag.js +++ /dev/null @@ -1,26 +0,0 @@ -import Model from 'flarum/model'; - -class Tag extends Model {} - -Tag.prototype.id = Model.prop('id'); -Tag.prototype.name = Model.prop('name'); -Tag.prototype.slug = Model.prop('slug'); -Tag.prototype.description = Model.prop('description'); - -Tag.prototype.color = Model.prop('color'); -Tag.prototype.backgroundUrl = Model.prop('backgroundUrl'); -Tag.prototype.backgroundMode = Model.prop('backgroundMode'); -Tag.prototype.iconUrl = Model.prop('iconUrl'); - -Tag.prototype.position = Model.prop('position'); -Tag.prototype.parent = Model.one('parent'); -Tag.prototype.defaultSort = Model.prop('defaultSort'); -Tag.prototype.isChild = Model.prop('isChild'); - -Tag.prototype.discussionsCount = Model.prop('discussionsCount'); -Tag.prototype.lastTime = Model.prop('lastTime', Model.date); -Tag.prototype.lastDiscussion = Model.one('lastDiscussion'); - -Tag.prototype.canStartDiscussion = Model.prop('canStartDiscussion'); - -export default Tag; diff --git a/extensions/tags/less/categories.less b/extensions/tags/less/categories.less deleted file mode 100644 index f917fec19..000000000 --- a/extensions/tags/less/categories.less +++ /dev/null @@ -1,236 +0,0 @@ -.category-label { - text-transform: uppercase; - font-size: 80%; - font-weight: 600; - display: inline-block; - padding: 0.1em 0.45em; - border-radius: 4px; - - &.uncategorized { - border: 1px dotted @fl-body-muted-color; - color: @fl-body-muted-color; - } - - & .category-label-text { - color: #fff !important; - } - - .discussion-summary & { - margin-right: 5px; - font-size: 11px; - } - - .discussion-hero & { - font-size: 14px; - background: #fff !important; - padding: 2px 6px; - - & .category-label-text { - color: inherit !important; - } - } - - .discussion-moved-post & { - margin: 0 2px; - } -} -.discussion-hero { - & .block-item { - margin-top: 10px; - } -} - -.category-icon { - border-radius: @border-radius-base; - width: 16px; - height: 16px; - display: inline-block; - vertical-align: -3px; - margin-left: 1px; - - &.uncategorized { - border: 1px dotted @fl-body-muted-color; - } -} - -.categories-area .container { - width: 100%; - max-width: none; - padding: 0; -} - -.control-change-category { - vertical-align: 1px; - margin: -10px 0; - - & .label { - margin: 0 2px 0 5px; - } - - .minimized & { - display: none; - } -} - -.modal-move-discussion { - & .modal-header { - padding: 20px; - - & h3 { - font-size: 16px; - } - } - & .modal-content { - overflow: hidden; - } - & .category-list { - background: @fl-body-secondary-color; - } - & .category-tile .title { - margin-bottom: 5px; - font-size: 18px; - } - & .category-tile .description { - margin-bottom: 0; - font-size: 13px; - } - & .count { - display: none; - } - & .category-tile { - float: left; - width: 50%; - height: 125px; - - & > a { - padding: 20px; - height: 100%; - } - } -} - - -.category-list { - margin: 0; - padding: 0; - list-style: none; - background: @fl-body-control-bg; - color: @fl-body-control-color; - overflow: hidden; -} - -@media @tablet { - .category-list-tiles { - & > li { - float: left; - width: 50%; - height: 175px; - - & > a { - height: 100%; - } - } - } -} -@media @tablet, @desktop, @desktop-hd { - .categories-forum-title { - display: none; - } -} -@media @desktop, @desktop-hd { - .category-list-tiles { - & > li { - float: left; - width: 33.333%; - height: 175px; - - & > a { - height: 100%; - } - } - } -} -.category-tile { - position: relative; - - &, & > a { - color: #fff; - } - & > a { - display: block; - padding: 25px; - transition: background 0.1s; - text-decoration: none; - } - & > a:hover { - background: rgba(0, 0, 0, 0.1); - } - & > a:active { - background: rgba(0, 0, 0, 0.2); - } - & .title { - font-size: 20px; - margin: 0 0 15px; - } - & .description { - font-size: 14px; - line-height: 1.5em; - color: rgba(255, 255, 255, 0.6); - } - & .count { - text-transform: uppercase; - font-weight: bold; - font-size: 11px; - color: rgba(255, 255, 255, 0.6); - } -} -.filter-tile a { - display: block; - padding: 15px 25px; - font-size: 18px; - color: @fl-body-control-color; -} -@media @tablet, @desktop, @desktop-hd { - .filter-tile { - & > a { - float: left; - width: 50%; - border-right: 1px solid #fff; - - &:first-child:last-child { - width: 100%; - border-right: 0; - } - } - & a { - display: block; - height: 100%; - padding: 20px; - font-size: 18px; - color: @fl-body-control-color; - text-align: center; - display: flex; - align-items: center; - justify-content: center; - } - } - .filter-list { - float: left; - margin: 0; - padding: 0; - list-style: none; - height: 100%; - display: table; - width: 50%; - table-layout: fixed; - - & > li { - display: table-row; - height: 1%; - - &:not(:last-child) a { - border-bottom: 1px solid #fff; - } - } - } -} diff --git a/extensions/tags/less/extension.less b/extensions/tags/less/extension.less deleted file mode 100644 index 90984629e..000000000 --- a/extensions/tags/less/extension.less +++ /dev/null @@ -1,391 +0,0 @@ -.tag-label { - font-size: 85%; - font-weight: 600; - display: inline-block; - padding: 0.2em 0.55em; - border-radius: @border-radius-base; - background: @fl-body-secondary-color; - color: @fl-body-muted-color; - - &.untagged { - background: transparent; - border: 1px dotted @fl-body-muted-color; - color: @fl-body-muted-color; - } - - &.colored { - & .tag-label-text { - color: #fff !important; - } - } - - .discussion-hero .tags-label & { - background: transparent; - border-radius: 4px !important; - - &.colored { - margin-right: 5px; - background: #fff !important; - color: @fl-body-muted-color; - - & .tag-label-text { - color: inherit !important; - } - } - } -} -.discussion-hero-colored { - &, & a { - color: #fff; - } -} -.tags-label { - .discussion-summary & { - margin-right: 4px; - } - .discussion-tagged-post & { - margin: 0 2px; - } - - & .tag-label { - border-radius: 0; - margin-right: 1px; - - &:first-child { - border-radius: @border-radius-base 0 0 @border-radius-base; - } - &:last-child { - border-radius: 0 @border-radius-base @border-radius-base 0; - } - &:first-child:last-child { - border-radius: @border-radius-base; - } - } -} - -// @todo give all
  • s a class in core, get rid of block-item -.discussion-hero { - & .block-item { - margin-top: 15px; - } -} - -.tag-icon { - border-radius: @border-radius-base; - width: 16px; - height: 16px; - display: inline-block; - vertical-align: -3px; - margin-left: 1px; - background: @fl-body-secondary-color; - - &.untagged { - border: 1px dotted @fl-body-muted-color; - background: transparent; - } -} -.side-nav .dropdown-menu > li > .dropdown-menu { - margin-bottom: 10px; - - & .tag-icon { - display: none; - } - & > li > a { - padding-top: 4px; - padding-bottom: 4px; - margin-left: 10px; - } -} - -.tag-discussion-modal { - & .modal-header { - background: @fl-body-secondary-color; - padding: 20px 20px 0; - - & h3 { - text-align: left; - color: @fl-body-muted-color; - font-size: 16px; - } - - @media @phone { - padding: 0; - } - } - & .modal-body { - padding: 20px; - - @media @phone { - padding: 15px; - } - } - & .modal-footer { - padding: 1px 0 0; - text-align: left; - } -} -@media @tablet, @desktop, @desktop-hd { - .tags-form { - padding-right: 100px; - overflow: hidden; - - & .tags-input { - float: left; - } - & .primary-control { - margin-right: -100px; - float: right; - width: 85px; - } - } -} -.tags-input { - padding-top: 0; - padding-bottom: 0; - overflow: hidden; - white-space: nowrap; - - & input { - display: inline; - outline: none; - margin-top: -2px; - border: 0 !important; - padding: 0; - width: 100%; - margin-right: -100%; - } - & .remove-tag { - cursor: not-allowed; - } -} -.tags-input-selected { - & .tag-label { - margin-right: 5px; - } -} - -.tags-select { - padding: 0; - margin: 0; - list-style: none; - overflow: auto; - max-height: 50vh; - - @media @phone { - max-height: none; - } - - & > li { - padding: 7px 20px; - overflow: hidden; - text-overflow: ellipsis; - cursor: pointer; - white-space: nowrap; - - &.pinned:not(.child) { - padding-top: 10px; - padding-bottom: 10px; - - & .name { - font-size: 16px; - } - } - &.colored { - &.selected .tag-icon:before { - color: #fff; - } - } - &.active { - background: @fl-body-secondary-color; - } - & .name { - display: inline-block; - width: 150px; - margin-right: 10px; - margin-left: 10px; - - @media @phone { - width: auto; - } - } - & .description { - color: @fl-body-muted-color; - font-size: 12px; - - @media @phone { - display: none; - } - } - &.selected { - & .tag-icon { - position: relative; - - &:before { - .fa(); - content: @fa-var-check; - color: @fl-body-muted-color; - position: absolute; - font-size: 14px; - width: 100%; - text-align: center; - padding-top: 1px; - } - } - } - & mark { - font-weight: bold; - background: none; - box-shadow: none; - color: inherit; - } - } -} - - -.tag-tiles { - list-style-type: none; - padding: 0; - margin: 0; - overflow: hidden; - - @media @phone { - margin: -15px -15px 0; - } - - & > li { - height: 200px; - margin-bottom: 1px; - overflow: hidden; - - @media @tablet, @desktop, @desktop-hd { - float: left; - width: ~"calc(50% - 1px)"; - margin-right: 1px; - &:first-child { - border-top-left-radius: @border-radius-base; - } - &:nth-child(2) { - border-top-right-radius: @border-radius-base; - } - &:nth-last-child(2):nth-child(even), &:last-child { - border-bottom-right-radius: @border-radius-base; - } - &:nth-last-child(2):nth-child(odd), &:last-child:nth-child(odd) { - border-bottom-left-radius: @border-radius-base; - } - } - } -} -.tag-tile { - position: relative; - background: @fl-body-secondary-color; - - &, & a { - color: @fl-body-muted-color; - } - &.colored { - &, & a { - color: #fff; - } - } - & .tag-info, & .last-discussion { - padding: 20px; - text-decoration: none; - display: block; - position: absolute; - left: 0; - right: 0; - } - & > a { - transition: background 0.2s; - - &:hover { - background: fade(#000, 5%); - } - &:active { - background: fade(#000, 15%); - } - } - & .tag-info { - top: 0; - bottom: 45px; - padding-right: 20px; - - & .name { - font-size: 20px; - margin: 0 0 10px; - font-weight: normal; - } - & .description { - font-size: 14px; - opacity: 0.5; - margin: 0 0 10px; - } - & .children { - text-transform: uppercase; - font-weight: 600; - font-size: 12px; - - & > a { - margin-right: 15px; - } - } - } - & .last-discussion { - bottom: 0; - height: 45px; - padding-top: 12px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - line-height: 21px; - opacity: 0.5; - - &, &:hover, &:active { - background: fade(#000, 10%); - } - - & .title { - margin-right: 10px; - } - &:hover .title { - text-decoration: underline; - } - & time { - text-transform: uppercase; - font-size: 11px; - font-weight: bold; - float: right; - } - } -} -.tag-cloud { - margin-top: 50px; - text-align: center; - - & h4 { - font-size: 12px; - font-weight: bold; - color: @fl-body-muted-color; - text-transform: uppercase; - margin-bottom: 15px; - - &:before { - .fa(); - content: @fa-var-tags; - margin-right: 5px; - font-size: 14px; - } - } -} -.tag-cloud-content { - font-size: 14px; - line-height: 1.7; - - &, & a { - color: @fl-body-muted-color; - } - & a { - margin: 0 6px; - } -} diff --git a/extensions/tags/less/forum/TagCloud.less b/extensions/tags/less/forum/TagCloud.less new file mode 100644 index 000000000..d9e3c2477 --- /dev/null +++ b/extensions/tags/less/forum/TagCloud.less @@ -0,0 +1,29 @@ +.TagCloud { + margin-top: 50px; + text-align: center; +} +.TagCloud-title { + font-size: 12px; + font-weight: bold; + color: @muted-color; + text-transform: uppercase; + margin-bottom: 15px; + + &:before { + .fa(); + content: @fa-var-tags; + margin-right: 5px; + font-size: 14px; + } +} +.TagCloud-content { + font-size: 14px; + line-height: 1.7; + + &, a { + color: @muted-color; + } + a { + margin: 0 6px; + } +} diff --git a/extensions/tags/less/forum/TagDiscussionModal.less b/extensions/tags/less/forum/TagDiscussionModal.less new file mode 100644 index 000000000..e3df739ba --- /dev/null +++ b/extensions/tags/less/forum/TagDiscussionModal.less @@ -0,0 +1,145 @@ +.TagDiscussionModal { + @media @tablet-up { + .Modal-header { + background: @control-bg; + padding: 20px 20px 0; + + & h3 { + text-align: left; + color: @control-color; + font-size: 16px; + } + } + } + .Modal-body { + padding: 20px; + + @media @phone { + padding: 15px; + } + } + .Modal-footer { + padding: 1px 0 0; + text-align: left; + } +} + +@media @tablet, @desktop, @desktop-hd { + .TagDiscussionModal-form { + display: table; + width: 100%; + } + .TagDiscussionModal-form-input { + display: table-cell; + width: 100%; + } + .TagDiscussionModal-form-submit { + display: table-cell; + padding-left: 15px; + } +} +.TagsInput { + padding-top: 0; + padding-bottom: 0; + overflow: hidden; + white-space: nowrap; + + input { + display: inline; + outline: none; + margin-top: -2px; + border: 0 !important; + padding: 0; + width: 100%; + margin-right: -100%; + } +} +.TagsInput-tag { + cursor: not-allowed; +} +.TagsInput-selected { + .TagLabel { + margin-right: 5px; + } +} + +.SelectTagList { + padding: 0; + margin: 0; + list-style: none; + overflow: auto; + max-height: 50vh; + + @media @phone { + max-height: none; + } + + > li { + padding: 7px 20px; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + &.pinned:not(.child) { + padding-top: 10px; + padding-bottom: 10px; + + .SelectTagListItem-name { + font-size: 16px; + } + } + &.child { + padding-left: 48px; + } + &.active { + background: @control-bg; + } + &.selected { + .TagIcon { + position: relative; + + &:before { + .fa(); + content: @fa-var-check; + color: @muted-color; + position: absolute; + font-size: 14px; + width: 100%; + text-align: center; + padding-top: 1px; + } + } + &.colored .TagIcon:before { + color: #fff; + } + } + } +} +.SelectTagListItem-name { + display: inline-block; + width: 150px; + margin-right: 10px; + margin-left: 10px; + + @media @phone { + width: auto; + } +} +.SelectTagListItem-description { + color: @muted-color; + font-size: 12px; + width: 370px; + display: inline-block; + vertical-align: top; + margin-top: 3px; + + @media @phone { + display: none; + } +} +.SelectTagListItem mark { + font-weight: bold; + background: none; + box-shadow: none; + color: inherit; +} diff --git a/extensions/tags/less/forum/TagIcon.less b/extensions/tags/less/forum/TagIcon.less new file mode 100644 index 000000000..a2a3a0481 --- /dev/null +++ b/extensions/tags/less/forum/TagIcon.less @@ -0,0 +1,14 @@ +.TagIcon { + border-radius: @border-radius; + width: 16px; + height: 16px; + display: inline-block; + vertical-align: -3px; + margin-left: 1px; + background: @control-bg; + + &.untagged { + border: 1px dotted @muted-color; + background: transparent; + } +} diff --git a/extensions/tags/less/forum/TagLabel.less b/extensions/tags/less/forum/TagLabel.less new file mode 100644 index 000000000..ef7701300 --- /dev/null +++ b/extensions/tags/less/forum/TagLabel.less @@ -0,0 +1,61 @@ +.TagLabel { + font-size: 85%; + font-weight: 600; + display: inline-block; + padding: 0.1em 0.5em; + border-radius: @border-radius; + background: @control-bg; + color: @control-color; + + &.untagged { + background: transparent; + border: 1px dotted @muted-color; + color: @muted-color; + } + + &.colored { + .TagLabel-text { + color: #fff !important; + } + } + + .DiscussionHero .TagsLabel & { + background: transparent; + border-radius: @border-radius !important; + font-size: 14px; + + &.colored { + margin-right: 5px; + background: #fff !important; + color: @muted-color; + + .TagLabel-text { + color: inherit !important; + } + } + } +} +.DiscussionHero--colored { + &, a { + color: #fff; + } +} +.TagsLabel { + .DiscussionTaggedPost & { + margin: 0 2px; + } + + .TagLabel { + border-radius: 0; + + &:first-child { + border-radius: @border-radius 0 0 @border-radius; + } + &:last-child { + border-radius: 0 @border-radius @border-radius 0; + } + &:first-child:last-child { + border-radius: @border-radius; + } + } +} diff --git a/extensions/tags/less/forum/TagTiles.less b/extensions/tags/less/forum/TagTiles.less new file mode 100644 index 000000000..7976be1de --- /dev/null +++ b/extensions/tags/less/forum/TagTiles.less @@ -0,0 +1,115 @@ +.TagTiles { + list-style-type: none; + padding: 0; + margin: 0; + overflow: hidden; + + @media @phone { + margin: -15px -15px 0; + } + + > li { + height: 200px; + overflow: hidden; + + @media @tablet-up { + float: left; + width: 50%; + + &:first-child { + border-top-left-radius: @border-radius; + } + &:nth-child(2) { + border-top-right-radius: @border-radius; + } + &:nth-last-child(2):nth-child(even), &:last-child { + border-bottom-right-radius: @border-radius; + } + &:nth-last-child(2):nth-child(odd), &:last-child:nth-child(odd) { + border-bottom-left-radius: @border-radius; + } + } + } +} + +.TagTile { + position: relative; + background: @control-bg; + + &, a { + color: @control-color; + } + &.colored { + &, a { + color: #fff; + } + } +} +.TagTile-info, .TagTile-lastDiscussion { + padding: 20px; + text-decoration: none !important; + display: block; + position: absolute; + left: 0; + right: 0; +} +.TagTile-info { + top: 0; + bottom: 45px; + padding-right: 20px; + transition: background 0.2s; + + &:hover { + background: fade(#000, 5%); + } + &:active { + background: fade(#000, 15%); + } +} +.TagTile-name { + font-size: 20px; + margin: 0 0 10px; + font-weight: normal; +} +.TagTile-description { + font-size: 14px; + opacity: 0.5; + margin: 0 0 10px; +} +.TagTile-children { + text-transform: uppercase; + font-weight: 600; + font-size: 12px; + + a { + margin-right: 15px; + } +} +.TagTile-lastDiscussion { + bottom: 0; + height: 45px; + padding-top: 12px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + line-height: 21px; + opacity: 0.5; + + &, &:hover, &:active { + background: fade(#000, 10%); + } + + &:hover .TagTile-lastDiscussion-title { + text-decoration: underline; + } + + time { + text-transform: uppercase; + font-size: 11px; + font-weight: bold; + float: right; + } +} +.TagTile-lastDiscussion-title { + margin-right: 10px; +} diff --git a/extensions/tags/less/forum/extension.less b/extensions/tags/less/forum/extension.less new file mode 100644 index 000000000..66bd06050 --- /dev/null +++ b/extensions/tags/less/forum/extension.less @@ -0,0 +1,37 @@ +@import "TagCloud.less"; +@import "TagDiscussionModal.less"; +@import "TagIcon.less"; +@import "TagLabel.less"; +@import "TagTiles.less"; + +.DiscussionHero { + .item-title { + display: block; + margin-top: 15px; + } +} +.TagLinkButton.child { + @media @tablet-up { + padding-top: 4px; + padding-bottom: 4px; + } + margin-left: 10px; + + .TagIcon { + display: none; + } +} +.DiscussionComposer-changeTags { + margin-right: 15px; + vertical-align: 2px; +} +.DiscussionListItem-info > .item-tags { + margin-right: 4px; +} +@media @tablet-up { + .IndexPage .DiscussionListItem-info > .item-tags { + float: right; + margin-top: -14px; + margin-right: 0; + } +} diff --git a/extensions/tags/locale/en.yml b/extensions/tags/locale/en.yml new file mode 100644 index 000000000..d7329a9ab --- /dev/null +++ b/extensions/tags/locale/en.yml @@ -0,0 +1,13 @@ +tags: + discussion_tagged_post: + one: "{username} {action} tag." + other: "{username} {action} tags." + added_tags: "added the {tags}" + removed_tags: "removed the {tags}" + tag_new_discussion_title: Start a Discussion About... + edit_discussion_tags_title: "Edit Tags for {title}" + edit_discussion_tags_link: Edit Tags + discussion_tags_placeholder: Choose one or more topics + confirm: Confirm + more: More... + tag_cloud_title: Tags diff --git a/extensions/tags/src/Events/DiscussionWasTagged.php b/extensions/tags/src/Events/DiscussionWasTagged.php index 33c96bd2f..727a4a458 100644 --- a/extensions/tags/src/Events/DiscussionWasTagged.php +++ b/extensions/tags/src/Events/DiscussionWasTagged.php @@ -1,17 +1,17 @@ subscribe('Flarum\Tags\Listeners\AddClientAssets'); + $events->subscribe('Flarum\Tags\Listeners\AddModelRelationship'); + $events->subscribe('Flarum\Tags\Listeners\ConfigureDiscussionPermissions'); + $events->subscribe('Flarum\Tags\Listeners\ConfigureTagPermissions'); + $events->subscribe('Flarum\Tags\Listeners\AddApiAttributes'); + $events->subscribe('Flarum\Tags\Listeners\PersistData'); + $events->subscribe('Flarum\Tags\Listeners\LogDiscussionTagged'); + $events->subscribe('Flarum\Tags\Listeners\UpdateTagMetadata'); + $events->subscribe('Flarum\Tags\Listeners\AddTagGambit'); + } +} diff --git a/extensions/tags/src/Gambits/TagGambit.php b/extensions/tags/src/Gambits/TagGambit.php new file mode 100644 index 000000000..b8b52361a --- /dev/null +++ b/extensions/tags/src/Gambits/TagGambit.php @@ -0,0 +1,49 @@ +tags = $tags; + } + + protected function conditions(Search $search, array $matches, $negate) + { + $slugs = explode(',', trim($matches[1], '"')); + + // TODO: implement $negate + $search->getQuery()->where(function ($query) use ($slugs) { + foreach ($slugs as $slug) { + if ($slug === 'untagged') { + $query->orWhereNotExists(function ($query) { + $query->select(app('flarum.db')->raw(1)) + ->from('discussions_tags') + ->whereRaw('discussion_id = discussions.id'); + }); + } else { + $id = $this->tags->getIdForSlug($slug); + + $query->orWhereExists(function ($query) use ($id) { + $query->select(app('flarum.db')->raw(1)) + ->from('discussions_tags') + ->whereRaw('discussion_id = discussions.id AND tag_id = ?', [$id]); + }); + } + } + }); + } +} diff --git a/extensions/tags/src/Handlers/DiscussionTaggedNotifier.php b/extensions/tags/src/Handlers/DiscussionTaggedNotifier.php deleted file mode 100755 index 2c915d548..000000000 --- a/extensions/tags/src/Handlers/DiscussionTaggedNotifier.php +++ /dev/null @@ -1,31 +0,0 @@ -listen('Flarum\Tags\Events\DiscussionWasTagged', __CLASS__.'@whenDiscussionWasTagged'); - } - - public function whenDiscussionWasTagged(DiscussionWasTagged $event) - { - $post = DiscussionTaggedPost::reply( - $event->discussion->id, - $event->user->id, - array_pluck($event->oldTags, 'id'), - $event->discussion->tags()->lists('id') - ); - - $post = $event->discussion->addPost($post); - } -} diff --git a/extensions/tags/src/Handlers/TagLoader.php b/extensions/tags/src/Handlers/TagLoader.php deleted file mode 100755 index b11d3a440..000000000 --- a/extensions/tags/src/Handlers/TagLoader.php +++ /dev/null @@ -1,25 +0,0 @@ -listen('Flarum\Api\Events\WillSerializeData', __CLASS__.'@whenWillSerializeData'); - } - - public function whenWillSerializeData(WillSerializeData $event) - { - if ($event->action instanceof ForumShowAction) { - $forum = $event->data; - - $query = Tag::whereVisibleTo($event->request->actor->getUser()); - - $forum->tags = $query->with('lastDiscussion')->get(); - $forum->tags_ids = $forum->tags->lists('id'); - } - } -} diff --git a/extensions/tags/src/Listeners/AddApiAttributes.php b/extensions/tags/src/Listeners/AddApiAttributes.php new file mode 100755 index 000000000..198ac8a9d --- /dev/null +++ b/extensions/tags/src/Listeners/AddApiAttributes.php @@ -0,0 +1,72 @@ +listen(ApiRelationship::class, __CLASS__.'@addTagsRelationship'); + $events->listen(WillSerializeData::class, __CLASS__.'@loadTagsRelationship'); + $events->listen(BuildApiAction::class, __CLASS__.'@includeTagsRelationship'); + $events->listen(ApiAttributes::class, __CLASS__.'@addAttributes'); + } + + public function addTagsRelationship(ApiRelationship $event) + { + if ($event->serializer instanceof ForumSerializer && + $event->relationship === 'tags') { + return $event->serializer->hasMany('Flarum\Tags\TagSerializer', 'tags'); + } + + if ($event->serializer instanceof DiscussionSerializer && + $event->relationship === 'tags') { + return $event->serializer->hasMany('Flarum\Tags\TagSerializer', 'tags'); + } + } + + public function loadTagsRelationship(WillSerializeData $event) + { + // Expose the complete tag list to clients by adding it as a + // relationship to the /api/forum endpoint. Since the Forum model + // doesn't actually have a tags relationship, we will manually load and + // assign the tags data to it using an event listener. + if ($event->action instanceof Forum\ShowAction) { + $forum = $event->data; + + $query = Tag::whereVisibleTo($event->request->actor); + + $forum->tags = $query->with('lastDiscussion')->get(); + $forum->tags_ids = $forum->tags->lists('id'); + } + } + + public function includeTagsRelationship(BuildApiAction $event) + { + if ($event->action instanceof Forum\ShowAction) { + $event->addInclude('tags'); + $event->addInclude('tags.lastDiscussion'); + $event->addLink('tags.parent'); + } + + if ($event->action instanceof Discussions\IndexAction || + $event->action instanceof Discussions\ShowAction) { + $event->addInclude('tags'); + } + } + + public function addAttributes(ApiAttributes $event) + { + if ($event->serializer instanceof DiscussionSerializer) { + $event->attributes['canTag'] = $event->model->can($event->actor, 'tag'); + } + } +} diff --git a/extensions/tags/src/Listeners/AddClientAssets.php b/extensions/tags/src/Listeners/AddClientAssets.php new file mode 100755 index 000000000..9eb24ee8b --- /dev/null +++ b/extensions/tags/src/Listeners/AddClientAssets.php @@ -0,0 +1,50 @@ +listen(RegisterLocales::class, __CLASS__.'@addLocale'); + $events->listen(BuildClientView::class, __CLASS__.'@addAssets'); + $events->listen(RegisterForumRoutes::class, __CLASS__.'@addRoutes'); + } + + public function addLocale(RegisterLocales $event) + { + $event->addTranslations('en', __DIR__.'/../../locale/en.yml'); + } + + public function addAssets(BuildClientView $event) + { + $event->forumAssets([ + __DIR__.'/../../js/forum/dist/extension.js', + __DIR__.'/../../less/forum/extension.less' + ]); + + $event->forumBootstrapper('tags/main'); + + $event->forumTranslations([ + 'tags.discussion_tagged_post', + 'tags.added_tags', + 'tags.removed_tags', + 'tags.tag_new_discussion_title', + 'tags.edit_discussion_tags_title', + 'tags.edit_discussion_tags_link', + 'tags.discussion_tags_placeholder', + 'tags.confirm', + 'tags.more', + 'tags.tag_cloud_title' + ]); + } + + public function addRoutes(RegisterForumRoutes $event) + { + $event->get('/t/{slug}', 'tags.forum.tag'); + $event->get('/tags', 'tags.forum.tags'); + } +} diff --git a/extensions/tags/src/Listeners/AddModelRelationship.php b/extensions/tags/src/Listeners/AddModelRelationship.php new file mode 100755 index 000000000..f80741e7b --- /dev/null +++ b/extensions/tags/src/Listeners/AddModelRelationship.php @@ -0,0 +1,21 @@ +listen(ModelRelationship::class, __CLASS__.'@addTagsRelationship'); + } + + public function addTagsRelationship(ModelRelationship $event) + { + if ($event->model instanceof Discussion && + $event->relationship === 'tags') { + return $event->model->belongsToMany('Flarum\Tags\Tag', 'discussions_tags', null, null, 'tags'); + } + } +} diff --git a/extensions/tags/src/Listeners/AddTagGambit.php b/extensions/tags/src/Listeners/AddTagGambit.php new file mode 100755 index 000000000..9a06affef --- /dev/null +++ b/extensions/tags/src/Listeners/AddTagGambit.php @@ -0,0 +1,17 @@ +listen(RegisterDiscussionGambits::class, __CLASS__.'@registerTagGambit'); + } + + public function registerTagGambit(RegisterDiscussionGambits $event) + { + $event->gambits->add('Flarum\Tags\Gambits\TagGambit'); + } +} diff --git a/extensions/tags/src/Listeners/ConfigureDiscussionPermissions.php b/extensions/tags/src/Listeners/ConfigureDiscussionPermissions.php new file mode 100755 index 000000000..00ac21d88 --- /dev/null +++ b/extensions/tags/src/Listeners/ConfigureDiscussionPermissions.php @@ -0,0 +1,59 @@ +listen(ScopeModelVisibility::class, __CLASS__.'@scopeDiscussionVisibility'); + $events->listen(ModelAllow::class, __CLASS__.'@allowDiscussionPermissions'); + } + + public function scopeDiscussionVisibility(ScopeModelVisibility $event) + { + // Hide discussions which have tags that the user is not allowed to see. + if ($event->model instanceof Discussion) { + $event->query->whereNotExists(function ($query) use ($event) { + return $query->select(app('flarum.db')->raw(1)) + ->from('discussions_tags') + ->whereIn('tag_id', Tag::getNotVisibleTo($event->actor)) + ->whereRaw('discussion_id = discussions.id'); + }); + } + } + + public function allowDiscussionPermissions(ModelAllow $event) + { + // Wrap all discussion permission checks with some logic pertaining to + // the discussion's tags. If the discussion has a tag that has been + // restricted, and the user has this permission for that tag, then they + // are allowed. If the discussion only has tags that have been + // restricted, then the user *must* have permission for at least one of + // them. + if ($event->model instanceof Discussion) { + $tags = $event->model->tags; + + if (count($tags)) { + $restricted = true; + + foreach ($tags as $tag) { + if ($tag->is_restricted) { + if ($event->actor->hasPermission('tag' . $tag->id . '.discussion.' . $event->action)) { + return true; + } + } else { + $restricted = false; + } + } + + if ($restricted) { + return false; + } + } + } + } +} diff --git a/extensions/tags/src/Listeners/ConfigureTagPermissions.php b/extensions/tags/src/Listeners/ConfigureTagPermissions.php new file mode 100755 index 000000000..404857bb4 --- /dev/null +++ b/extensions/tags/src/Listeners/ConfigureTagPermissions.php @@ -0,0 +1,31 @@ +listen(ScopeModelVisibility::class, __CLASS__.'@scopeTagVisibility'); + $events->listen(ModelAllow::class, __CLASS__.'@allowStartDiscussion'); + } + + public function scopeTagVisibility(ScopeModelVisibility $event) + { + if ($event->model instanceof Tag) { + $event->query->whereNotIn('id', Tag::getNotVisibleTo($event->actor)); + } + } + + public function allowStartDiscussion(ModelAllow $event) + { + if ($event->model instanceof Tag) { + if (! $event->model->is_restricted || + $event->actor->hasPermission('tag' . $event->model->id . '.startDiscussion')) { + return true; + } + } + } +} diff --git a/extensions/tags/src/Listeners/LogDiscussionTagged.php b/extensions/tags/src/Listeners/LogDiscussionTagged.php new file mode 100755 index 000000000..1d8fa0f9e --- /dev/null +++ b/extensions/tags/src/Listeners/LogDiscussionTagged.php @@ -0,0 +1,32 @@ +listen(RegisterPostTypes::class, __CLASS__.'@registerPostType'); + $events->listen(DiscussionWasTagged::class, __CLASS__.'@whenDiscussionWasTagged'); + } + + public function registerPostType(RegisterPostTypes $event) + { + $event->register(DiscussionTaggedPost::class); + } + + public function whenDiscussionWasTagged(DiscussionWasTagged $event) + { + $post = DiscussionTaggedPost::reply( + $event->discussion->id, + $event->user->id, + array_pluck($event->oldTags, 'id'), + $event->discussion->tags()->lists('id') + ); + + $event->discussion->mergePost($post); + } +} diff --git a/extensions/tags/src/Handlers/TagSaver.php b/extensions/tags/src/Listeners/PersistData.php similarity index 58% rename from extensions/tags/src/Handlers/TagSaver.php rename to extensions/tags/src/Listeners/PersistData.php index c20c76afa..320021dc8 100755 --- a/extensions/tags/src/Handlers/TagSaver.php +++ b/extensions/tags/src/Listeners/PersistData.php @@ -1,24 +1,24 @@ -listen('Flarum\Core\Events\DiscussionWillBeSaved', __CLASS__.'@whenDiscussionWillBeSaved'); + $events->listen(DiscussionWillBeSaved::class, __CLASS__.'@whenDiscussionWillBeSaved'); } public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event) { - if (isset($event->command->data['links']['tags']['linkage'])) { + if (isset($event->data['relationships']['tags']['data'])) { $discussion = $event->discussion; - $user = $event->command->user; - $linkage = (array) $event->command->data['links']['tags']['linkage']; + $actor = $event->actor; + $linkage = (array) $event->data['relationships']['tags']['data']; $newTagIds = []; foreach ($linkage as $link) { @@ -27,7 +27,7 @@ class TagSaver $newTags = Tag::whereIn('id', $newTagIds); foreach ($newTags as $tag) { - if (! $tag->can($user, 'startDiscussion')) { + if (! $tag->can($actor, 'startDiscussion')) { throw new PermissionDeniedException; } } @@ -41,18 +41,13 @@ class TagSaver if ($oldTagIds == $newTagIds) { return; } + + $discussion->raise(new DiscussionWasTagged($discussion, $actor, $oldTags->all())); } - // @todo is there a better (safer) way to do this? - // maybe store some info on the discussion model and then use the - // DiscussionWasTagged event to actually save the data? Discussion::saved(function ($discussion) use ($newTagIds) { $discussion->tags()->sync($newTagIds); }); - - if ($discussion->exists) { - $discussion->raise(new DiscussionWasTagged($discussion, $user, $oldTags->all())); - } } } } diff --git a/extensions/tags/src/Handlers/TagMetadataUpdater.php b/extensions/tags/src/Listeners/UpdateTagMetadata.php similarity index 60% rename from extensions/tags/src/Handlers/TagMetadataUpdater.php rename to extensions/tags/src/Listeners/UpdateTagMetadata.php index 91682fea4..ef00f3c41 100755 --- a/extensions/tags/src/Handlers/TagMetadataUpdater.php +++ b/extensions/tags/src/Listeners/UpdateTagMetadata.php @@ -1,28 +1,28 @@ -listen('Flarum\Core\Events\DiscussionWasStarted', __CLASS__.'@whenDiscussionWasStarted'); - $events->listen('Flarum\Tags\Events\DiscussionWasTagged', __CLASS__.'@whenDiscussionWasTagged'); - $events->listen('Flarum\Core\Events\DiscussionWasDeleted', __CLASS__.'@whenDiscussionWasDeleted'); + $events->listen(DiscussionWasStarted::class, __CLASS__.'@whenDiscussionWasStarted'); + $events->listen(DiscussionWasTagged::class, __CLASS__.'@whenDiscussionWasTagged'); + $events->listen(DiscussionWasDeleted::class, __CLASS__.'@whenDiscussionWasDeleted'); - $events->listen('Flarum\Core\Events\PostWasPosted', __CLASS__.'@whenPostWasPosted'); - $events->listen('Flarum\Core\Events\PostWasDeleted', __CLASS__.'@whenPostWasDeleted'); - $events->listen('Flarum\Core\Events\PostWasHidden', __CLASS__.'@whenPostWasHidden'); - $events->listen('Flarum\Core\Events\PostWasRestored', __CLASS__.'@whenPostWasRestored'); + $events->listen(PostWasPosted::class, __CLASS__.'@whenPostWasPosted'); + $events->listen(PostWasDeleted::class, __CLASS__.'@whenPostWasDeleted'); + $events->listen(PostWasHidden::class, __CLASS__.'@whenPostWasHidden'); + $events->listen(PostWasRestored::class, __CLASS__.'@whenPostWasRestored'); } public function whenDiscussionWasStarted(DiscussionWasStarted $event) @@ -69,7 +69,7 @@ class TagMetadataUpdater protected function updateTags($discussion, $delta = 0, $tags = null) { if (! $tags) { - $tags = $discussion->getRelation('tags'); + $tags = $discussion->tags; } foreach ($tags as $tag) { diff --git a/extensions/tags/src/DiscussionTaggedPost.php b/extensions/tags/src/Posts/DiscussionTaggedPost.php similarity index 55% rename from extensions/tags/src/DiscussionTaggedPost.php rename to extensions/tags/src/Posts/DiscussionTaggedPost.php index 987f4676f..d7626d799 100755 --- a/extensions/tags/src/DiscussionTaggedPost.php +++ b/extensions/tags/src/Posts/DiscussionTaggedPost.php @@ -1,37 +1,34 @@ -user_id === $previous->user_id) { + // If the previous post is another 'discussion tagged' post, and it's + // by the same user, then we can merge this post into it. If we find + // that we've in fact reverted the tag changes, delete it. Otherwise, + // update its content. + if ($previous instanceof static && $this->user_id === $previous->user_id) { if ($previous->content[0] == $this->content[1]) { - return; - } + $previous->delete(); + } else { + $previous->content = static::buildContent($previous->content[0], $this->content[1]); + $previous->time = $this->time; - $previous->content = static::buildContent($previous->content[0], $this->content[1]); - $previous->time = $this->time; + $previous->save(); + } return $previous; } + $this->save(); + return $this; } diff --git a/extensions/tags/src/Tag.php b/extensions/tags/src/Tag.php index 4183fe703..6da62021a 100644 --- a/extensions/tags/src/Tag.php +++ b/extensions/tags/src/Tag.php @@ -1,15 +1,10 @@ belongsTo('Flarum\Core\Models\Discussion', 'last_discussion_id'); + return $this->belongsTo('Flarum\Core\Discussions\Discussion', 'last_discussion_id'); } public function discussions() { - return $this->belongsToMany('Flarum\Core\Models\Discussion', 'discussions_tags'); + return $this->belongsToMany('Flarum\Core\Discussions\Discussion', 'discussions_tags'); } /** @@ -36,7 +31,7 @@ class Tag extends Model */ public function refreshLastDiscussion() { - if ($lastDiscussion = $this->discussions()->orderBy('last_time', 'desc')->first()) { + if ($lastDiscussion = $this->discussions()->latest('last_time')->first()) { $this->setLastDiscussion($lastDiscussion); } @@ -46,7 +41,7 @@ class Tag extends Model /** * Set the tag's last discussion details. * - * @param \Flarum\Core\Models\Discussion $discussion + * @param Discussion $discussion * @return $this */ public function setLastDiscussion(Discussion $discussion) @@ -60,13 +55,15 @@ class Tag extends Model public static function getNotVisibleTo($user) { static $tags; + if (! $tags) { $tags = static::all(); } $ids = []; + foreach ($tags as $tag) { - if ($tag->is_restricted && ! $user->hasPermission('tag'.$tag->id.'.view')) { + if ($tag->is_restricted && ! $user->hasPermission('tag' . $tag->id . '.view')) { $ids[] = $tag->id; } } diff --git a/extensions/tags/src/TagGambit.php b/extensions/tags/src/TagGambit.php deleted file mode 100644 index 69e618201..000000000 --- a/extensions/tags/src/TagGambit.php +++ /dev/null @@ -1,64 +0,0 @@ -tags = $tags; - } - - /** - * Apply conditions to the searcher, given matches from the gambit's - * regex. - * - * @param array $matches The matches from the gambit's regex. - * @param \Flarum\Core\Search\SearcherInterface $searcher - * @return void - */ - protected function conditions(SearcherInterface $searcher, array $matches, $negate) - { - $slugs = explode(',', trim($matches[1], '"')); - - // TODO: implement $negate - $searcher->getQuery()->where(function ($query) use ($slugs) { - foreach ($slugs as $slug) { - if ($slug === 'untagged') { - $query->orWhereNotExists(function ($query) { - $query->select(app('db')->raw(1)) - ->from('discussions_tags') - ->whereRaw('discussion_id = discussions.id'); - }); - } else { - $id = $this->tags->getIdForSlug($slug); - - $query->orWhereExists(function ($query) use ($id) { - $query->select(app('db')->raw(1)) - ->from('discussions_tags') - ->whereRaw('discussion_id = discussions.id AND tag_id = ?', [$id]); - }); - } - } - }); - } -} diff --git a/extensions/tags/src/EloquentTagRepository.php b/extensions/tags/src/TagRepository.php similarity index 57% rename from extensions/tags/src/EloquentTagRepository.php rename to extensions/tags/src/TagRepository.php index d11d73385..572a44fb1 100644 --- a/extensions/tags/src/EloquentTagRepository.php +++ b/extensions/tags/src/TagRepository.php @@ -4,47 +4,47 @@ use Illuminate\Database\Eloquent\Builder; use Flarum\Core\Models\User; use Flarum\Tags\Tag; -class EloquentTagRepository implements TagRepositoryInterface +class TagRepository { /** * Find all tags, optionally making sure they are visible to a * certain user. * - * @param \Flarum\Core\Models\User|null $user + * @param User|null $user * @return \Illuminate\Database\Eloquent\Collection */ public function find(User $user = null) { $query = Tag::newQuery(); - return $this->scopeVisibleForUser($query, $user)->get(); + return $this->scopeVisibleTo($query, $user)->get(); } /** * Get the ID of a tag with the given slug. * * @param string $slug - * @param \Flarum\Core\Models\User|null $user + * @param User|null $user * @return integer */ public function getIdForSlug($slug, User $user = null) { $query = Tag::where('slug', 'like', $slug); - return $this->scopeVisibleForUser($query, $user)->pluck('id'); + return $this->scopeVisibleTo($query, $user)->pluck('id'); } /** * Scope a query to only include records that are visible to a user. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Flarum\Core\Models\User $user - * @return \Illuminate\Database\Eloquent\Builder + * @param Builder $query + * @param User $user + * @return Builder */ - protected function scopeVisibleForUser(Builder $query, User $user = null) + protected function scopeVisibleTo(Builder $query, User $user = null) { if ($user !== null) { - $query->whereCan($user, 'view'); + $query->whereVisibleTo($user); } return $query; diff --git a/extensions/tags/src/TagRepositoryInterface.php b/extensions/tags/src/TagRepositoryInterface.php deleted file mode 100644 index 3e74b1ed5..000000000 --- a/extensions/tags/src/TagRepositoryInterface.php +++ /dev/null @@ -1,24 +0,0 @@ -actor->getUser(); - - $attributes = [ - 'name' => $tag->name, - 'description' => $tag->description, - 'slug' => $tag->slug, - 'color' => $tag->color, - 'backgroundUrl' => $tag->background_path, - 'backgroundMode' => $tag->background_mode, - 'iconUrl' => $tag->icon_path, - 'discussionsCount' => (int) $tag->discussions_count, - 'position' => $tag->position === null ? null : (int) $tag->position, - 'defaultSort' => $tag->default_sort, - 'isChild' => (bool) $tag->parent_id, - 'lastTime' => $tag->last_time ? $tag->last_time->toRFC3339String() : null, - 'canStartDiscussion' => $tag->can($user, 'startDiscussion') + return [ + 'name' => $tag->name, + 'description' => $tag->description, + 'slug' => $tag->slug, + 'color' => $tag->color, + 'backgroundUrl' => $tag->background_path, + 'backgroundMode' => $tag->background_mode, + 'iconUrl' => $tag->icon_path, + 'discussionsCount' => (int) $tag->discussions_count, + 'position' => $tag->position === null ? null : (int) $tag->position, + 'defaultSort' => $tag->default_sort, + 'isChild' => (bool) $tag->parent_id, + 'lastTime' => $tag->last_time ? $tag->last_time->toRFC3339String() : null, + 'canStartDiscussion' => $tag->can($this->actor, 'startDiscussion') ]; - - return $this->extendAttributes($tag, $attributes); } protected function parent() diff --git a/extensions/tags/src/TagsServiceProvider.php b/extensions/tags/src/TagsServiceProvider.php deleted file mode 100644 index f8a8b2f39..000000000 --- a/extensions/tags/src/TagsServiceProvider.php +++ /dev/null @@ -1,138 +0,0 @@ -app->bind( - 'Flarum\Tags\TagRepositoryInterface', - 'Flarum\Tags\EloquentTagRepository' - ); - } - - /** - * Bootstrap the application events. - * - * @return void - */ - public function boot() - { - $this->extend([ - (new Extend\ForumClient()) - ->assets([ - __DIR__.'/../js/dist/extension.js', - __DIR__.'/../less/extension.less' - ]) - ->route('get', '/t/{slug}', 'flarum-tags.forum.tag') - ->route('get', '/tags', 'flarum-tags.forum.tags'), - - (new Extend\Model('Flarum\Tags\Tag')) - // Hide tags that the user doesn't have permission to see. - ->scopeVisible(function ($query, User $user) { - $query->whereNotIn('id', Tag::getNotVisibleTo($user)); - }) - - // Allow the user to start discussions in tags which aren't - // restricted, or for which the user has explicitly been granted - // permission. - ->allow('startDiscussion', function (Tag $tag, User $user) { - if (! $tag->is_restricted || $user->hasPermission('tag'.$tag->id.'.startDiscussion')) { - return true; - } - }), - - // Expose the complete tag list to clients by adding it as a - // relationship to the /api/forum endpoint. Since the Forum model - // doesn't actually have a tags relationship, we will manually - // load and assign the tags data to it using an event listener. - (new Extend\ApiSerializer('Flarum\Api\Serializers\ForumSerializer')) - ->hasMany('tags', 'Flarum\Tags\TagSerializer'), - - (new Extend\ApiAction('Flarum\Api\Actions\Forum\ShowAction')) - ->addInclude('tags') - ->addInclude('tags.lastDiscussion') - ->addLink('tags.parent'), - - new Extend\EventSubscriber('Flarum\Tags\Handlers\TagLoader'), - - // Extend the Discussion model and API: add the tags relationship - // and modify permissions. - (new Extend\Model('Flarum\Core\Models\Discussion')) - ->belongsToMany('tags', 'Flarum\Tags\Tag', 'discussions_tags') - - // Hide discussions which have tags that the user is not allowed - // to see. - ->scopeVisible(function ($query, User $user) { - $query->whereNotExists(function ($query) use ($user) { - return $query->select(app('db')->raw(1)) - ->from('discussions_tags') - ->whereIn('tag_id', Tag::getNotVisibleTo($user)) - ->whereRaw('discussion_id = discussions.id'); - }); - }) - - // Wrap all discussion permission checks with some logic - // pertaining to the discussion's tags. If the discussion has a - // tag that has been restricted, and the user has this - // permission for that tag, then they are allowed. If the - // discussion only has tags that have been restricted, then the - // user *must* have permission for at least one of them. - ->allow('*', function (Discussion $discussion, User $user, $action) { - $tags = $discussion->getRelation('tags'); - - if (count($tags)) { - $restricted = true; - - foreach ($tags as $tag) { - if ($tag->is_restricted) { - if ($user->hasPermission('tag'.$tag->id.'.discussion.'.$action)) { - return true; - } - } else { - $restricted = false; - } - } - - if ($restricted) { - return false; - } - } - }), - - (new Extend\ApiSerializer('Flarum\Api\Serializers\DiscussionBasicSerializer')) - ->hasMany('tags', 'Flarum\Tags\TagSerializer') - ->attributes(function (&$attributes, $discussion, $user) { - $attributes['canTag'] = $discussion->can($user, 'tag'); - }), - - (new Extend\ApiAction([ - 'Flarum\Api\Actions\Discussions\IndexAction', - 'Flarum\Api\Actions\Discussions\ShowAction' - ])) - ->addInclude('tags'), - - // Add an event subscriber so that tags data is persisted when - // saving a discussion. - new Extend\EventSubscriber('Flarum\Tags\Handlers\TagSaver'), - new Extend\EventSubscriber('Flarum\Tags\Handlers\TagMetadataUpdater'), - - // Add a gambit that allows filtering discussions by tag(s). - new Extend\DiscussionGambit('Flarum\Tags\TagGambit'), - - // Add a new post type which indicates when a discussion's tags were - // changed. - new Extend\PostType('Flarum\Tags\DiscussionTaggedPost'), - new Extend\EventSubscriber('Flarum\Tags\Handlers\DiscussionTaggedNotifier') - ]); - } -} From 3192ef4fe3c2d98664d5f6f4ce731cf9ae888d3c Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 24 Jul 2015 10:05:57 +0930 Subject: [PATCH 075/554] Make tags look better in dark mode --- extensions/tags/less/forum/TagLabel.less | 6 +++--- extensions/tags/less/forum/TagTiles.less | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/tags/less/forum/TagLabel.less b/extensions/tags/less/forum/TagLabel.less index ef7701300..5719fcbea 100644 --- a/extensions/tags/less/forum/TagLabel.less +++ b/extensions/tags/less/forum/TagLabel.less @@ -15,7 +15,7 @@ &.colored { .TagLabel-text { - color: #fff !important; + color: @body-bg !important; } } @@ -26,7 +26,7 @@ &.colored { margin-right: 5px; - background: #fff !important; + background: @body-bg !important; color: @muted-color; .TagLabel-text { @@ -37,7 +37,7 @@ } .DiscussionHero--colored { &, a { - color: #fff; + color: @body-bg; } } .TagsLabel { diff --git a/extensions/tags/less/forum/TagTiles.less b/extensions/tags/less/forum/TagTiles.less index 33028e01c..017dea4fb 100644 --- a/extensions/tags/less/forum/TagTiles.less +++ b/extensions/tags/less/forum/TagTiles.less @@ -41,7 +41,7 @@ } &.colored { &, a { - color: #fff; + color: @body-bg; } } } From d8cb3c7605afaf47d4169ab017eb5f820bcada94 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 27 Jul 2015 11:54:52 +0930 Subject: [PATCH 076/554] PERF: avoid reinstantiation of event subscribers --- extensions/tags/src/Listeners/AddApiAttributes.php | 8 ++++---- extensions/tags/src/Listeners/AddClientAssets.php | 6 +++--- .../tags/src/Listeners/AddModelRelationship.php | 2 +- extensions/tags/src/Listeners/AddTagGambit.php | 2 +- .../Listeners/ConfigureDiscussionPermissions.php | 4 ++-- .../tags/src/Listeners/ConfigureTagPermissions.php | 4 ++-- .../tags/src/Listeners/LogDiscussionTagged.php | 4 ++-- extensions/tags/src/Listeners/PersistData.php | 2 +- .../tags/src/Listeners/UpdateTagMetadata.php | 14 +++++++------- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/extensions/tags/src/Listeners/AddApiAttributes.php b/extensions/tags/src/Listeners/AddApiAttributes.php index 198ac8a9d..9935eedf4 100755 --- a/extensions/tags/src/Listeners/AddApiAttributes.php +++ b/extensions/tags/src/Listeners/AddApiAttributes.php @@ -14,10 +14,10 @@ class AddApiAttributes { public function subscribe($events) { - $events->listen(ApiRelationship::class, __CLASS__.'@addTagsRelationship'); - $events->listen(WillSerializeData::class, __CLASS__.'@loadTagsRelationship'); - $events->listen(BuildApiAction::class, __CLASS__.'@includeTagsRelationship'); - $events->listen(ApiAttributes::class, __CLASS__.'@addAttributes'); + $events->listen(ApiRelationship::class, [$this, 'addTagsRelationship']); + $events->listen(WillSerializeData::class, [$this, 'loadTagsRelationship']); + $events->listen(BuildApiAction::class, [$this, 'includeTagsRelationship']); + $events->listen(ApiAttributes::class, [$this, 'addAttributes']); } public function addTagsRelationship(ApiRelationship $event) diff --git a/extensions/tags/src/Listeners/AddClientAssets.php b/extensions/tags/src/Listeners/AddClientAssets.php index 9eb24ee8b..904d97e50 100755 --- a/extensions/tags/src/Listeners/AddClientAssets.php +++ b/extensions/tags/src/Listeners/AddClientAssets.php @@ -9,9 +9,9 @@ class AddClientAssets { public function subscribe(Dispatcher $events) { - $events->listen(RegisterLocales::class, __CLASS__.'@addLocale'); - $events->listen(BuildClientView::class, __CLASS__.'@addAssets'); - $events->listen(RegisterForumRoutes::class, __CLASS__.'@addRoutes'); + $events->listen(RegisterLocales::class, [$this, 'addLocale']); + $events->listen(BuildClientView::class, [$this, 'addAssets']); + $events->listen(RegisterForumRoutes::class, [$this, 'addRoutes']); } public function addLocale(RegisterLocales $event) diff --git a/extensions/tags/src/Listeners/AddModelRelationship.php b/extensions/tags/src/Listeners/AddModelRelationship.php index f80741e7b..89af0180b 100755 --- a/extensions/tags/src/Listeners/AddModelRelationship.php +++ b/extensions/tags/src/Listeners/AddModelRelationship.php @@ -8,7 +8,7 @@ class AddModelRelationship { public function subscribe($events) { - $events->listen(ModelRelationship::class, __CLASS__.'@addTagsRelationship'); + $events->listen(ModelRelationship::class, [$this, 'addTagsRelationship']); } public function addTagsRelationship(ModelRelationship $event) diff --git a/extensions/tags/src/Listeners/AddTagGambit.php b/extensions/tags/src/Listeners/AddTagGambit.php index 9a06affef..80081af59 100755 --- a/extensions/tags/src/Listeners/AddTagGambit.php +++ b/extensions/tags/src/Listeners/AddTagGambit.php @@ -7,7 +7,7 @@ class AddTagGambit { public function subscribe(Dispatcher $events) { - $events->listen(RegisterDiscussionGambits::class, __CLASS__.'@registerTagGambit'); + $events->listen(RegisterDiscussionGambits::class, [$this, 'registerTagGambit']); } public function registerTagGambit(RegisterDiscussionGambits $event) diff --git a/extensions/tags/src/Listeners/ConfigureDiscussionPermissions.php b/extensions/tags/src/Listeners/ConfigureDiscussionPermissions.php index 00ac21d88..821aecb8e 100755 --- a/extensions/tags/src/Listeners/ConfigureDiscussionPermissions.php +++ b/extensions/tags/src/Listeners/ConfigureDiscussionPermissions.php @@ -9,8 +9,8 @@ class ConfigureDiscussionPermissions { public function subscribe($events) { - $events->listen(ScopeModelVisibility::class, __CLASS__.'@scopeDiscussionVisibility'); - $events->listen(ModelAllow::class, __CLASS__.'@allowDiscussionPermissions'); + $events->listen(ScopeModelVisibility::class, [$this, 'scopeDiscussionVisibility']); + $events->listen(ModelAllow::class, [$this, 'allowDiscussionPermissions']); } public function scopeDiscussionVisibility(ScopeModelVisibility $event) diff --git a/extensions/tags/src/Listeners/ConfigureTagPermissions.php b/extensions/tags/src/Listeners/ConfigureTagPermissions.php index 404857bb4..3db35e129 100755 --- a/extensions/tags/src/Listeners/ConfigureTagPermissions.php +++ b/extensions/tags/src/Listeners/ConfigureTagPermissions.php @@ -8,8 +8,8 @@ class ConfigureTagPermissions { public function subscribe($events) { - $events->listen(ScopeModelVisibility::class, __CLASS__.'@scopeTagVisibility'); - $events->listen(ModelAllow::class, __CLASS__.'@allowStartDiscussion'); + $events->listen(ScopeModelVisibility::class, [$this, 'scopeTagVisibility']); + $events->listen(ModelAllow::class, [$this, 'allowStartDiscussion']); } public function scopeTagVisibility(ScopeModelVisibility $event) diff --git a/extensions/tags/src/Listeners/LogDiscussionTagged.php b/extensions/tags/src/Listeners/LogDiscussionTagged.php index 1d8fa0f9e..e44951eee 100755 --- a/extensions/tags/src/Listeners/LogDiscussionTagged.php +++ b/extensions/tags/src/Listeners/LogDiscussionTagged.php @@ -9,8 +9,8 @@ class LogDiscussionTagged { public function subscribe(Dispatcher $events) { - $events->listen(RegisterPostTypes::class, __CLASS__.'@registerPostType'); - $events->listen(DiscussionWasTagged::class, __CLASS__.'@whenDiscussionWasTagged'); + $events->listen(RegisterPostTypes::class, [$this, 'registerPostType']); + $events->listen(DiscussionWasTagged::class, [$this, 'whenDiscussionWasTagged']); } public function registerPostType(RegisterPostTypes $event) diff --git a/extensions/tags/src/Listeners/PersistData.php b/extensions/tags/src/Listeners/PersistData.php index 320021dc8..6099b1f19 100755 --- a/extensions/tags/src/Listeners/PersistData.php +++ b/extensions/tags/src/Listeners/PersistData.php @@ -10,7 +10,7 @@ class PersistData { public function subscribe($events) { - $events->listen(DiscussionWillBeSaved::class, __CLASS__.'@whenDiscussionWillBeSaved'); + $events->listen(DiscussionWillBeSaved::class, [$this, 'whenDiscussionWillBeSaved']); } public function whenDiscussionWillBeSaved(DiscussionWillBeSaved $event) diff --git a/extensions/tags/src/Listeners/UpdateTagMetadata.php b/extensions/tags/src/Listeners/UpdateTagMetadata.php index ef00f3c41..a26343de6 100755 --- a/extensions/tags/src/Listeners/UpdateTagMetadata.php +++ b/extensions/tags/src/Listeners/UpdateTagMetadata.php @@ -15,14 +15,14 @@ class UpdateTagMetadata { public function subscribe($events) { - $events->listen(DiscussionWasStarted::class, __CLASS__.'@whenDiscussionWasStarted'); - $events->listen(DiscussionWasTagged::class, __CLASS__.'@whenDiscussionWasTagged'); - $events->listen(DiscussionWasDeleted::class, __CLASS__.'@whenDiscussionWasDeleted'); + $events->listen(DiscussionWasStarted::class, [$this, 'whenDiscussionWasStarted']); + $events->listen(DiscussionWasTagged::class, [$this, 'whenDiscussionWasTagged']); + $events->listen(DiscussionWasDeleted::class, [$this, 'whenDiscussionWasDeleted']); - $events->listen(PostWasPosted::class, __CLASS__.'@whenPostWasPosted'); - $events->listen(PostWasDeleted::class, __CLASS__.'@whenPostWasDeleted'); - $events->listen(PostWasHidden::class, __CLASS__.'@whenPostWasHidden'); - $events->listen(PostWasRestored::class, __CLASS__.'@whenPostWasRestored'); + $events->listen(PostWasPosted::class, [$this, 'whenPostWasPosted']); + $events->listen(PostWasDeleted::class, [$this, 'whenPostWasDeleted']); + $events->listen(PostWasHidden::class, [$this, 'whenPostWasHidden']); + $events->listen(PostWasRestored::class, [$this, 'whenPostWasRestored']); } public function whenDiscussionWasStarted(DiscussionWasStarted $event) From 8a1e4b2f30bdef03c83bc2bf41b2b761b6962e70 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 27 Jul 2015 14:19:57 +0930 Subject: [PATCH 077/554] Update gulp module configuration --- extensions/tags/js/forum/Gulpfile.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/tags/js/forum/Gulpfile.js b/extensions/tags/js/forum/Gulpfile.js index 41637ae99..c28a504c2 100644 --- a/extensions/tags/js/forum/Gulpfile.js +++ b/extensions/tags/js/forum/Gulpfile.js @@ -1,5 +1,7 @@ var gulp = require('flarum-gulp'); gulp({ - modulePrefix: 'tags' + modules: { + 'tags': 'src/**/*.js' + } }); From 2bbfc0b25f423c8f1379c4d894bb2325346e7cdd Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Mon, 27 Jul 2015 16:17:07 +0930 Subject: [PATCH 078/554] Fix tag filter --- extensions/tags/js/forum/src/addTagFilter.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/extensions/tags/js/forum/src/addTagFilter.js b/extensions/tags/js/forum/src/addTagFilter.js index 7953ab27b..653688fe0 100644 --- a/extensions/tags/js/forum/src/addTagFilter.js +++ b/extensions/tags/js/forum/src/addTagFilter.js @@ -1,7 +1,6 @@ import { extend, override } from 'flarum/extend'; import IndexPage from 'flarum/components/IndexPage'; import DiscussionList from 'flarum/components/DiscussionList'; -import extract from 'flarum/utils/extract'; import TagHero from 'tags/components/TagHero'; @@ -45,9 +44,8 @@ export default function() { extend(DiscussionList.prototype, 'requestParams', function(params) { params.include.push('tags'); - if (params.tags) { - params.filter = params.filter || {}; - params.filter.q = (params.filter.q || '') + ' tag:' + extract(params, 'tags'); + if (this.props.params.tags) { + params.filter.q = (params.filter.q || '') + ' tag:' + this.props.params.tags; } }); } From 51558e2b3bbe615197ca787857e703006d070f4d Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Tue, 28 Jul 2015 13:35:47 +0930 Subject: [PATCH 079/554] Change modal dialog title. closes flarum/core#179 --- extensions/tags/less/forum/TagDiscussionModal.less | 3 +++ extensions/tags/locale/en.yml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/tags/less/forum/TagDiscussionModal.less b/extensions/tags/less/forum/TagDiscussionModal.less index e3df739ba..e5ba8bd0f 100644 --- a/extensions/tags/less/forum/TagDiscussionModal.less +++ b/extensions/tags/less/forum/TagDiscussionModal.less @@ -88,6 +88,9 @@ font-size: 16px; } } + &.pinned + li:not(.pinned) { + border-top: 2px solid @control-bg; + } &.child { padding-left: 48px; } diff --git a/extensions/tags/locale/en.yml b/extensions/tags/locale/en.yml index d7329a9ab..0073c4b39 100644 --- a/extensions/tags/locale/en.yml +++ b/extensions/tags/locale/en.yml @@ -4,7 +4,7 @@ tags: other: "{username} {action} tags." added_tags: "added the {tags}" removed_tags: "removed the {tags}" - tag_new_discussion_title: Start a Discussion About... + tag_new_discussion_title: Choose Tags for Your Discussion edit_discussion_tags_title: "Edit Tags for {title}" edit_discussion_tags_link: Edit Tags discussion_tags_placeholder: Choose one or more topics From 5ba4934105497c04161daa4825a06d960adc530d Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 29 Jul 2015 09:27:00 +0930 Subject: [PATCH 080/554] Prevent crash when rendering deleted tag --- extensions/tags/js/forum/src/helpers/tagLabel.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/extensions/tags/js/forum/src/helpers/tagLabel.js b/extensions/tags/js/forum/src/helpers/tagLabel.js index 359bf0fde..ebdd5679c 100644 --- a/extensions/tags/js/forum/src/helpers/tagLabel.js +++ b/extensions/tags/js/forum/src/helpers/tagLabel.js @@ -5,10 +5,6 @@ export default function tagLabel(tag, attrs = {}) { attrs.className = 'TagLabel ' + (attrs.className || ''); const link = extract(attrs, 'link'); - if (link) { - attrs.href = app.route('tag', {tags: tag.slug()}); - attrs.config = m.route; - } if (tag) { const color = tag.color(); @@ -19,6 +15,8 @@ export default function tagLabel(tag, attrs = {}) { if (link) { attrs.title = tag.description() || ''; + attrs.href = app.route('tag', {tags: tag.slug()}); + attrs.config = m.route; } } else { attrs.className += ' untagged'; From d63b44222756469a47bec9806e4b4866c40d6741 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Wed, 29 Jul 2015 09:29:50 +0930 Subject: [PATCH 081/554] Add translation for deleted tags --- extensions/tags/js/forum/src/helpers/tagLabel.js | 2 +- extensions/tags/locale/en.yml | 1 + extensions/tags/src/Listeners/AddClientAssets.php | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/tags/js/forum/src/helpers/tagLabel.js b/extensions/tags/js/forum/src/helpers/tagLabel.js index ebdd5679c..f60c5e37f 100644 --- a/extensions/tags/js/forum/src/helpers/tagLabel.js +++ b/extensions/tags/js/forum/src/helpers/tagLabel.js @@ -25,7 +25,7 @@ export default function tagLabel(tag, attrs = {}) { return ( m((link ? 'a' : 'span'), attrs, - {tag ? tag.name() : app.trans('tags.untagged')} + {tag ? tag.name() : app.trans('tags.deleted')} ) ); diff --git a/extensions/tags/locale/en.yml b/extensions/tags/locale/en.yml index 0073c4b39..d536396d3 100644 --- a/extensions/tags/locale/en.yml +++ b/extensions/tags/locale/en.yml @@ -11,3 +11,4 @@ tags: confirm: Confirm more: More... tag_cloud_title: Tags + deleted: Deleted diff --git a/extensions/tags/src/Listeners/AddClientAssets.php b/extensions/tags/src/Listeners/AddClientAssets.php index 904d97e50..34f368cfa 100755 --- a/extensions/tags/src/Listeners/AddClientAssets.php +++ b/extensions/tags/src/Listeners/AddClientAssets.php @@ -38,7 +38,8 @@ class AddClientAssets 'tags.discussion_tags_placeholder', 'tags.confirm', 'tags.more', - 'tags.tag_cloud_title' + 'tags.tag_cloud_title', + 'tags.deleted' ]); } From d0f9115deacef7687f80d24a103a6ccc60b5d777 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 31 Jul 2015 20:19:34 +0930 Subject: [PATCH 082/554] Extend admin permissions page to allow restriction by tag Also fix a couple of bugs: - Tag sorting algorithm bug in Safari - Ensure subtag is removed when parent is removed --- extensions/tags/js/admin/Gulpfile.js | 10 +++ extensions/tags/js/admin/package.json | 7 +++ extensions/tags/js/admin/src/main.js | 63 +++++++++++++++++++ extensions/tags/js/forum/Gulpfile.js | 5 +- .../src/components/TagDiscussionModal.js | 4 +- .../js/{forum/src => lib}/helpers/tagIcon.js | 0 .../js/{forum/src => lib}/helpers/tagLabel.js | 0 .../{forum/src => lib}/helpers/tagsLabel.js | 0 .../tags/js/{forum/src => lib}/models/Tag.js | 1 + .../js/{forum/src => lib}/utils/sortTags.js | 2 +- extensions/tags/less/admin/extension.less | 2 + extensions/tags/less/forum/extension.less | 5 +- .../tags/less/{forum => lib}/TagIcon.less | 0 .../tags/less/{forum => lib}/TagLabel.less | 1 + .../tags/src/{ => Api}/TagSerializer.php | 12 +++- extensions/tags/src/Api/UpdateAction.php | 40 ++++++++++++ extensions/tags/src/Commands/EditTag.php | 40 ++++++++++++ .../tags/src/Commands/EditTagHandler.php | 45 +++++++++++++ .../tags/src/Listeners/AddApiAttributes.php | 11 +++- .../tags/src/Listeners/AddClientAssets.php | 7 +++ extensions/tags/src/Tag.php | 5 ++ extensions/tags/src/TagRepository.php | 21 ++++++- 22 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 extensions/tags/js/admin/Gulpfile.js create mode 100644 extensions/tags/js/admin/package.json create mode 100644 extensions/tags/js/admin/src/main.js rename extensions/tags/js/{forum/src => lib}/helpers/tagIcon.js (100%) rename extensions/tags/js/{forum/src => lib}/helpers/tagLabel.js (100%) rename extensions/tags/js/{forum/src => lib}/helpers/tagsLabel.js (100%) rename extensions/tags/js/{forum/src => lib}/models/Tag.js (94%) rename extensions/tags/js/{forum/src => lib}/utils/sortTags.js (90%) create mode 100644 extensions/tags/less/admin/extension.less rename extensions/tags/less/{forum => lib}/TagIcon.less (100%) rename extensions/tags/less/{forum => lib}/TagLabel.less (97%) rename extensions/tags/src/{ => Api}/TagSerializer.php (81%) create mode 100644 extensions/tags/src/Api/UpdateAction.php create mode 100644 extensions/tags/src/Commands/EditTag.php create mode 100644 extensions/tags/src/Commands/EditTagHandler.php diff --git a/extensions/tags/js/admin/Gulpfile.js b/extensions/tags/js/admin/Gulpfile.js new file mode 100644 index 000000000..0a9f1ed7f --- /dev/null +++ b/extensions/tags/js/admin/Gulpfile.js @@ -0,0 +1,10 @@ +var gulp = require('flarum-gulp'); + +gulp({ + modules: { + 'tags': [ + '../lib/**/*.js', + 'src/**/*.js' + ] + } +}); diff --git a/extensions/tags/js/admin/package.json b/extensions/tags/js/admin/package.json new file mode 100644 index 000000000..3e0ef919d --- /dev/null +++ b/extensions/tags/js/admin/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "devDependencies": { + "gulp": "^3.8.11", + "flarum-gulp": "git+https://github.com/flarum/gulp.git" + } +} diff --git a/extensions/tags/js/admin/src/main.js b/extensions/tags/js/admin/src/main.js new file mode 100644 index 000000000..a7e6dc064 --- /dev/null +++ b/extensions/tags/js/admin/src/main.js @@ -0,0 +1,63 @@ +import { extend } from 'flarum/extend'; +import PermissionGrid from 'flarum/components/PermissionGrid'; +import PermissionDropdown from 'flarum/components/PermissionDropdown'; +import Dropdown from 'flarum/components/Dropdown'; +import Button from 'flarum/components/Button'; + +import Tag from 'tags/models/Tag'; +import tagLabel from 'tags/helpers/tagLabel'; +import tagIcon from 'tags/helpers/tagIcon'; +import sortTags from 'tags/utils/sortTags'; + +app.initializers.add('tags', app => { + app.store.models.tags = Tag; + + extend(PermissionGrid.prototype, 'scopeItems', items => { + sortTags(app.store.all('tags')) + .filter(tag => tag.isRestricted()) + .forEach(tag => items.add('tag' + tag.id(), { + label: tagLabel(tag), + onremove: () => tag.save({isRestricted: false}), + render: item => { + if (item.permission) { + let permission; + + if (item.permission === 'forum.view') { + permission = 'view'; + } else if (item.permission === 'forum.startDiscussion') { + permission = 'startDiscussion'; + } else if (item.permission.indexOf('discussion.') === 0) { + permission = item.permission; + } + + if (permission) { + const props = Object.assign({}, item); + props.permission = 'tag' + tag.id() + '.' + permission; + + return PermissionDropdown.component(props); + } + } + + return ''; + } + })); + }); + + extend(PermissionGrid.prototype, 'scopeControlItems', items => { + const tags = sortTags(app.store.all('tags').filter(tag => !tag.isRestricted())); + + if (tags.length) { + items.add('tag', Dropdown.component({ + buttonClassName: 'Button Button--text', + label: 'Restrict by Tag', + icon: 'plus', + caretIcon: null, + children: tags.map(tag => Button.component({ + icon: true, + children: [tagIcon(tag, {className: 'Button-icon'}), ' ', tag.name()], + onclick: () => tag.save({isRestricted: true}) + })) + })); + } + }); +}); diff --git a/extensions/tags/js/forum/Gulpfile.js b/extensions/tags/js/forum/Gulpfile.js index c28a504c2..0a9f1ed7f 100644 --- a/extensions/tags/js/forum/Gulpfile.js +++ b/extensions/tags/js/forum/Gulpfile.js @@ -2,6 +2,9 @@ var gulp = require('flarum-gulp'); gulp({ modules: { - 'tags': 'src/**/*.js' + 'tags': [ + '../lib/**/*.js', + 'src/**/*.js' + ] } }); diff --git a/extensions/tags/js/forum/src/components/TagDiscussionModal.js b/extensions/tags/js/forum/src/components/TagDiscussionModal.js index 41dfdc960..090811a1f 100644 --- a/extensions/tags/js/forum/src/components/TagDiscussionModal.js +++ b/extensions/tags/js/forum/src/components/TagDiscussionModal.js @@ -60,8 +60,8 @@ export default class TagDiscussionModal extends Modal { // Look through the list of selected tags for any tags which have the tag // we just removed as their parent. We'll need to remove them too. this.selected - .filter(selected => selected.parent() && selected.parent() === tag) - .forEach(this.removeTag); + .filter(selected => selected.parent() === tag) + .forEach(this.removeTag.bind(this)); } } diff --git a/extensions/tags/js/forum/src/helpers/tagIcon.js b/extensions/tags/js/lib/helpers/tagIcon.js similarity index 100% rename from extensions/tags/js/forum/src/helpers/tagIcon.js rename to extensions/tags/js/lib/helpers/tagIcon.js diff --git a/extensions/tags/js/forum/src/helpers/tagLabel.js b/extensions/tags/js/lib/helpers/tagLabel.js similarity index 100% rename from extensions/tags/js/forum/src/helpers/tagLabel.js rename to extensions/tags/js/lib/helpers/tagLabel.js diff --git a/extensions/tags/js/forum/src/helpers/tagsLabel.js b/extensions/tags/js/lib/helpers/tagsLabel.js similarity index 100% rename from extensions/tags/js/forum/src/helpers/tagsLabel.js rename to extensions/tags/js/lib/helpers/tagsLabel.js diff --git a/extensions/tags/js/forum/src/models/Tag.js b/extensions/tags/js/lib/models/Tag.js similarity index 94% rename from extensions/tags/js/forum/src/models/Tag.js rename to extensions/tags/js/lib/models/Tag.js index 7e69d31e7..0cfcb74a7 100644 --- a/extensions/tags/js/forum/src/models/Tag.js +++ b/extensions/tags/js/lib/models/Tag.js @@ -20,5 +20,6 @@ export default class Tag extends mixin(Model, { lastTime: Model.attribute('lastTime', Model.transformDate), lastDiscussion: Model.hasOne('lastDiscussion'), + isRestricted: Model.attribute('isRestricted'), canStartDiscussion: Model.attribute('canStartDiscussion') }) {} diff --git a/extensions/tags/js/forum/src/utils/sortTags.js b/extensions/tags/js/lib/utils/sortTags.js similarity index 90% rename from extensions/tags/js/forum/src/utils/sortTags.js rename to extensions/tags/js/lib/utils/sortTags.js index c0e5d2191..1a499f1d3 100644 --- a/extensions/tags/js/forum/src/utils/sortTags.js +++ b/extensions/tags/js/lib/utils/sortTags.js @@ -15,7 +15,7 @@ export default function sortTags(tags) { } else if (aParent === bParent) { return aPos - bPos; } else if (aParent) { - return aParent === b ? -1 : aParent.position() - bPos; + return aParent === b ? 1 : aParent.position() - bPos; } else if (bParent) { return bParent === a ? -1 : aPos - bParent.position(); } diff --git a/extensions/tags/less/admin/extension.less b/extensions/tags/less/admin/extension.less new file mode 100644 index 000000000..70f2babd2 --- /dev/null +++ b/extensions/tags/less/admin/extension.less @@ -0,0 +1,2 @@ +@import "../lib/TagLabel.less"; +@import "../lib/TagIcon.less"; diff --git a/extensions/tags/less/forum/extension.less b/extensions/tags/less/forum/extension.less index 66bd06050..f925014f2 100644 --- a/extensions/tags/less/forum/extension.less +++ b/extensions/tags/less/forum/extension.less @@ -1,7 +1,8 @@ +@import "../lib/TagLabel.less"; +@import "../lib/TagIcon.less"; + @import "TagCloud.less"; @import "TagDiscussionModal.less"; -@import "TagIcon.less"; -@import "TagLabel.less"; @import "TagTiles.less"; .DiscussionHero { diff --git a/extensions/tags/less/forum/TagIcon.less b/extensions/tags/less/lib/TagIcon.less similarity index 100% rename from extensions/tags/less/forum/TagIcon.less rename to extensions/tags/less/lib/TagIcon.less diff --git a/extensions/tags/less/forum/TagLabel.less b/extensions/tags/less/lib/TagLabel.less similarity index 97% rename from extensions/tags/less/forum/TagLabel.less rename to extensions/tags/less/lib/TagLabel.less index 5719fcbea..8136e127e 100644 --- a/extensions/tags/less/forum/TagLabel.less +++ b/extensions/tags/less/lib/TagLabel.less @@ -6,6 +6,7 @@ border-radius: @border-radius; background: @control-bg; color: @control-color; + text-transform: none; &.untagged { background: transparent; diff --git a/extensions/tags/src/TagSerializer.php b/extensions/tags/src/Api/TagSerializer.php similarity index 81% rename from extensions/tags/src/TagSerializer.php rename to extensions/tags/src/Api/TagSerializer.php index 44b0a5f0c..24db8da42 100644 --- a/extensions/tags/src/TagSerializer.php +++ b/extensions/tags/src/Api/TagSerializer.php @@ -1,4 +1,4 @@ - $tag->name, 'description' => $tag->description, 'slug' => $tag->slug, @@ -23,11 +23,17 @@ class TagSerializer extends Serializer 'lastTime' => $tag->last_time ? $tag->last_time->toRFC3339String() : null, 'canStartDiscussion' => $tag->can($this->actor, 'startDiscussion') ]; + + if ($this->actor->isAdmin()) { + $attributes['isRestricted'] = (bool) $tag->is_restricted; + } + + return $attributes; } protected function parent() { - return $this->hasOne('Flarum\Tags\TagSerializer'); + return $this->hasOne('Flarum\Tags\Api\TagSerializer'); } protected function lastDiscussion() diff --git a/extensions/tags/src/Api/UpdateAction.php b/extensions/tags/src/Api/UpdateAction.php new file mode 100644 index 000000000..f3f402e8f --- /dev/null +++ b/extensions/tags/src/Api/UpdateAction.php @@ -0,0 +1,40 @@ +bus = $bus; + } + + /** + * @param JsonApiRequest $request + * @param Document $document + * @return \Flarum\Core\Tags\Tag + */ + protected function data(JsonApiRequest $request, Document $document) + { + return $this->bus->dispatch( + new EditTag($request->get('id'), $request->actor, $request->get('data')) + ); + } +} diff --git a/extensions/tags/src/Commands/EditTag.php b/extensions/tags/src/Commands/EditTag.php new file mode 100644 index 000000000..6407fead3 --- /dev/null +++ b/extensions/tags/src/Commands/EditTag.php @@ -0,0 +1,40 @@ +tagId = $tagId; + $this->actor = $actor; + $this->data = $data; + } +} diff --git a/extensions/tags/src/Commands/EditTagHandler.php b/extensions/tags/src/Commands/EditTagHandler.php new file mode 100644 index 000000000..1254bff58 --- /dev/null +++ b/extensions/tags/src/Commands/EditTagHandler.php @@ -0,0 +1,45 @@ +tags = $tags; + } + + /** + * @param EditTag $command + * @return Tag + * @throws \Flarum\Core\Exceptions\PermissionDeniedException + */ + public function handle(EditTag $command) + { + $actor = $command->actor; + $data = $command->data; + + $tag = $this->tags->findOrFail($command->tagId, $actor); + + $tag->assertCan($actor, 'edit'); + + $attributes = array_get($data, 'attributes', []); + + if (isset($attributes['isRestricted'])) { + $tag->is_restricted = (bool) $attributes['isRestricted']; + } + + $tag->save(); + + return $tag; + } +} diff --git a/extensions/tags/src/Listeners/AddApiAttributes.php b/extensions/tags/src/Listeners/AddApiAttributes.php index 9935eedf4..104827eca 100755 --- a/extensions/tags/src/Listeners/AddApiAttributes.php +++ b/extensions/tags/src/Listeners/AddApiAttributes.php @@ -4,6 +4,7 @@ use Flarum\Events\ApiRelationship; use Flarum\Events\WillSerializeData; use Flarum\Events\BuildApiAction; use Flarum\Events\ApiAttributes; +use Flarum\Events\RegisterApiRoutes; use Flarum\Api\Actions\Forum; use Flarum\Api\Actions\Discussions; use Flarum\Api\Serializers\ForumSerializer; @@ -18,18 +19,19 @@ class AddApiAttributes $events->listen(WillSerializeData::class, [$this, 'loadTagsRelationship']); $events->listen(BuildApiAction::class, [$this, 'includeTagsRelationship']); $events->listen(ApiAttributes::class, [$this, 'addAttributes']); + $events->listen(RegisterApiRoutes::class, [$this, 'addRoutes']); } public function addTagsRelationship(ApiRelationship $event) { if ($event->serializer instanceof ForumSerializer && $event->relationship === 'tags') { - return $event->serializer->hasMany('Flarum\Tags\TagSerializer', 'tags'); + return $event->serializer->hasMany('Flarum\Tags\Api\TagSerializer', 'tags'); } if ($event->serializer instanceof DiscussionSerializer && $event->relationship === 'tags') { - return $event->serializer->hasMany('Flarum\Tags\TagSerializer', 'tags'); + return $event->serializer->hasMany('Flarum\Tags\Api\TagSerializer', 'tags'); } } @@ -69,4 +71,9 @@ class AddApiAttributes $event->attributes['canTag'] = $event->model->can($event->actor, 'tag'); } } + + public function addRoutes(RegisterApiRoutes $event) + { + $event->patch('/tags/{id}', 'tags.update', 'Flarum\Tags\Api\UpdateAction'); + } } diff --git a/extensions/tags/src/Listeners/AddClientAssets.php b/extensions/tags/src/Listeners/AddClientAssets.php index 34f368cfa..242c28fb4 100755 --- a/extensions/tags/src/Listeners/AddClientAssets.php +++ b/extensions/tags/src/Listeners/AddClientAssets.php @@ -41,6 +41,13 @@ class AddClientAssets 'tags.tag_cloud_title', 'tags.deleted' ]); + + $event->adminAssets([ + __DIR__.'/../../js/admin/dist/extension.js', + __DIR__.'/../../less/admin/extension.less' + ]); + + $event->adminBootstrapper('tags/main'); } public function addRoutes(RegisterForumRoutes $event) diff --git a/extensions/tags/src/Tag.php b/extensions/tags/src/Tag.php index 6da62021a..4c63a4415 100644 --- a/extensions/tags/src/Tag.php +++ b/extensions/tags/src/Tag.php @@ -2,9 +2,14 @@ use Flarum\Core\Model; use Flarum\Core\Discussions\Discussion; +use Flarum\Core\Support\VisibleScope; +use Flarum\Core\Support\Locked; class Tag extends Model { + use VisibleScope; + use Locked; + protected $table = 'tags'; protected $dates = ['last_time']; diff --git a/extensions/tags/src/TagRepository.php b/extensions/tags/src/TagRepository.php index 572a44fb1..1093387ab 100644 --- a/extensions/tags/src/TagRepository.php +++ b/extensions/tags/src/TagRepository.php @@ -1,11 +1,28 @@ scopeVisibleTo($query, $actor)->firstOrFail(); + } + /** * Find all tags, optionally making sure they are visible to a * certain user. @@ -13,7 +30,7 @@ class TagRepository * @param User|null $user * @return \Illuminate\Database\Eloquent\Collection */ - public function find(User $user = null) + public function all(User $user = null) { $query = Tag::newQuery(); From 30bfc343ae71bcf1b844c589450e040cacc1708d Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Sun, 2 Aug 2015 17:28:36 +0930 Subject: [PATCH 083/554] Add API endpoints and admin page to manage tags --- extensions/tags/js/admin/Gulpfile.js | 3 + extensions/tags/js/admin/src/addTagsPane.js | 18 +++ .../js/admin/src/addTagsPermissionScope.js | 60 +++++++ .../js/admin/src/components/EditTagModal.js | 106 +++++++++++++ .../admin/src/components/TagSettingsModal.js | 107 +++++++++++++ .../tags/js/admin/src/components/TagsPage.js | 149 ++++++++++++++++++ extensions/tags/js/admin/src/main.js | 61 +------ .../tags/js/forum/src/components/TagsPage.js | 1 + extensions/tags/less/admin/EditTagModal.less | 8 + .../tags/less/admin/TagSettingsModal.less | 16 ++ extensions/tags/less/admin/TagsPage.less | 90 +++++++++++ extensions/tags/less/admin/extension.less | 4 + extensions/tags/src/Api/CreateAction.php | 40 +++++ extensions/tags/src/Api/DeleteAction.php | 34 ++++ extensions/tags/src/Api/OrderAction.php | 41 +++++ extensions/tags/src/Commands/CreateTag.php | 30 ++++ .../tags/src/Commands/CreateTagHandler.php | 44 ++++++ extensions/tags/src/Commands/DeleteTag.php | 42 +++++ .../tags/src/Commands/DeleteTagHandler.php | 38 +++++ .../tags/src/Commands/EditTagHandler.php | 16 ++ extensions/tags/src/Extension.php | 2 + .../tags/src/Listeners/AddApiAttributes.php | 3 + extensions/tags/src/Tag.php | 45 ++++++ 23 files changed, 901 insertions(+), 57 deletions(-) create mode 100644 extensions/tags/js/admin/src/addTagsPane.js create mode 100644 extensions/tags/js/admin/src/addTagsPermissionScope.js create mode 100644 extensions/tags/js/admin/src/components/EditTagModal.js create mode 100644 extensions/tags/js/admin/src/components/TagSettingsModal.js create mode 100644 extensions/tags/js/admin/src/components/TagsPage.js create mode 100644 extensions/tags/less/admin/EditTagModal.less create mode 100644 extensions/tags/less/admin/TagSettingsModal.less create mode 100644 extensions/tags/less/admin/TagsPage.less create mode 100644 extensions/tags/src/Api/CreateAction.php create mode 100644 extensions/tags/src/Api/DeleteAction.php create mode 100644 extensions/tags/src/Api/OrderAction.php create mode 100644 extensions/tags/src/Commands/CreateTag.php create mode 100644 extensions/tags/src/Commands/CreateTagHandler.php create mode 100644 extensions/tags/src/Commands/DeleteTag.php create mode 100644 extensions/tags/src/Commands/DeleteTagHandler.php diff --git a/extensions/tags/js/admin/Gulpfile.js b/extensions/tags/js/admin/Gulpfile.js index 0a9f1ed7f..53a9f303b 100644 --- a/extensions/tags/js/admin/Gulpfile.js +++ b/extensions/tags/js/admin/Gulpfile.js @@ -1,6 +1,9 @@ var gulp = require('flarum-gulp'); gulp({ + files: [ + 'bower_components/html.sortable/dist/html.sortable.js' + ], modules: { 'tags': [ '../lib/**/*.js', diff --git a/extensions/tags/js/admin/src/addTagsPane.js b/extensions/tags/js/admin/src/addTagsPane.js new file mode 100644 index 000000000..f3674cc81 --- /dev/null +++ b/extensions/tags/js/admin/src/addTagsPane.js @@ -0,0 +1,18 @@ +import { extend } from 'flarum/extend'; +import AdminNav from 'flarum/components/AdminNav'; +import AdminLinkButton from 'flarum/components/AdminLinkButton'; + +import TagsPage from 'tags/components/TagsPage'; + +export default function() { + app.routes.tags = {path: '/tags', component: TagsPage.component()}; + + extend(AdminNav.prototype, 'items', items => { + items.add('tags', AdminLinkButton.component({ + href: app.route('tags'), + icon: 'tags', + children: 'Tags', + description: 'Manage the list of tags available to organise discussions with.' + })); + }); +} diff --git a/extensions/tags/js/admin/src/addTagsPermissionScope.js b/extensions/tags/js/admin/src/addTagsPermissionScope.js new file mode 100644 index 000000000..b79dfd506 --- /dev/null +++ b/extensions/tags/js/admin/src/addTagsPermissionScope.js @@ -0,0 +1,60 @@ +import { extend } from 'flarum/extend'; +import PermissionGrid from 'flarum/components/PermissionGrid'; +import PermissionDropdown from 'flarum/components/PermissionDropdown'; +import Dropdown from 'flarum/components/Dropdown'; +import Button from 'flarum/components/Button'; + +import tagLabel from 'tags/helpers/tagLabel'; +import tagIcon from 'tags/helpers/tagIcon'; +import sortTags from 'tags/utils/sortTags'; + +export default function() { + extend(PermissionGrid.prototype, 'scopeItems', items => { + sortTags(app.store.all('tags')) + .filter(tag => tag.isRestricted()) + .forEach(tag => items.add('tag' + tag.id(), { + label: tagLabel(tag), + onremove: () => tag.save({isRestricted: false}), + render: item => { + if (item.permission) { + let permission; + + if (item.permission === 'forum.view') { + permission = 'view'; + } else if (item.permission === 'forum.startDiscussion') { + permission = 'startDiscussion'; + } else if (item.permission.indexOf('discussion.') === 0) { + permission = item.permission; + } + + if (permission) { + const props = Object.assign({}, item); + props.permission = 'tag' + tag.id() + '.' + permission; + + return PermissionDropdown.component(props); + } + } + + return ''; + } + })); + }); + + extend(PermissionGrid.prototype, 'scopeControlItems', items => { + const tags = sortTags(app.store.all('tags').filter(tag => !tag.isRestricted())); + + if (tags.length) { + items.add('tag', Dropdown.component({ + buttonClassName: 'Button Button--text', + label: 'Restrict by Tag', + icon: 'plus', + caretIcon: null, + children: tags.map(tag => Button.component({ + icon: true, + children: [tagIcon(tag, {className: 'Button-icon'}), ' ', tag.name()], + onclick: () => tag.save({isRestricted: true}) + })) + })); + } + }); +} diff --git a/extensions/tags/js/admin/src/components/EditTagModal.js b/extensions/tags/js/admin/src/components/EditTagModal.js new file mode 100644 index 000000000..97d6fa3ad --- /dev/null +++ b/extensions/tags/js/admin/src/components/EditTagModal.js @@ -0,0 +1,106 @@ +import Modal from 'flarum/components/Modal'; +import Button from 'flarum/components/Button'; +import { slug } from 'flarum/utils/string'; + +import tagLabel from 'tags/helpers/tagLabel'; + +/** + * The `EditTagModal` component shows a modal dialog which allows the user + * to create or edit a tag. + */ +export default class EditTagModal extends Modal { + constructor(...args) { + super(...args); + + this.tag = this.props.tag || app.store.createRecord('tags'); + + this.name = m.prop(this.tag.name() || ''); + this.slug = m.prop(this.tag.slug() || ''); + this.description = m.prop(this.tag.description() || ''); + this.color = m.prop(this.tag.color() || ''); + } + + className() { + return 'EditTagModal Modal--small'; + } + + title() { + return this.name() + ? tagLabel({ + name: this.name, + color: this.color + }) + : 'Create Tag'; + } + + content() { + return ( +
    +
    +
    + + { + this.name(e.target.value); + this.slug(slug(e.target.value)); + }}/> +
    + +
    + + +
    + +
    + +