From c9a03d9d8a1ffdb14eedbe95cbcae76b2315b508 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 11 Jun 2015 18:34:48 +0930 Subject: [PATCH] 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' + ); + } +}