Rename extension to Tags. Allow multiple tags per discussion.

WIP!
This commit is contained in:
Toby Zerner 2015-06-11 18:34:48 +09:30
parent f569d00314
commit c9a03d9d8a
35 changed files with 663 additions and 465 deletions

View File

@ -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');

View File

@ -1,7 +1,7 @@
{
"autoload": {
"psr-4": {
"Flarum\\Categories\\": "src/"
"Flarum\\Tags\\": "src/"
}
}
}

View File

@ -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"
}
}
}

View File

@ -1,5 +1,5 @@
var gulp = require('flarum-gulp');
gulp({
modulePrefix: 'flarum-categories'
modulePrefix: 'flarum-tags'
});

View File

@ -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();
});

View File

@ -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;
}
});
};

View File

@ -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';
}
});
};

View File

@ -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});;
}
});
};

View File

@ -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())
])
])
]);
}
}

View File

@ -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';
}
}

View File

@ -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())
])
])
]);
}
}

View File

@ -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';
}
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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'));
}

View File

@ -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);
}

View File

@ -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'));
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -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 <li>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;
}
}

View File

@ -1,35 +0,0 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateCategoriesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
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();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::drop('categories');
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateTagsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('tags', function (Blueprint $table) {
$table->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');
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateUsersTagsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('users_tags', function (Blueprint $table) {
$table->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');
}
}

View File

@ -1,8 +0,0 @@
<?php namespace Flarum\Categories;
use Flarum\Core\Models\Model;
class Category extends Model
{
protected $table = 'categories';
}

View File

@ -1,54 +0,0 @@
<?php namespace Flarum\Categories;
use Flarum\Core\Repositories\UserRepositoryInterface as UserRepository;
use Flarum\Core\Search\SearcherInterface;
use Flarum\Core\Search\GambitAbstract;
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)
{
$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);
}
}
});
}
}

View File

@ -1,33 +0,0 @@
<?php namespace Flarum\Categories;
use Flarum\Api\Serializers\BaseSerializer;
class CategorySerializer extends BaseSerializer
{
/**
* The resource type.
*
* @var string
*/
protected $type = 'categories';
/**
* Serialize category attributes to be exposed in the API.
*
* @param \Flarum\Categories\Category $category
* @return array
*/
protected function attributes($category)
{
$attributes = [
'title' => $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);
}
}

View File

@ -1,13 +1,13 @@
<?php namespace Flarum\Categories;
<?php namespace Flarum\Tags;
use Illuminate\Database\Eloquent\Builder;
use Flarum\Core\Models\User;
use Flarum\Categories\Category;
use Flarum\Tags\Tag;
class EloquentCategoryRepository implements CategoryRepositoryInterface
class EloquentTagRepository implements TagRepositoryInterface
{
/**
* Find all categories, optionally making sure they are visible to a
* Find all tags, optionally making sure they are visible to a
* certain user.
*
* @param \Flarum\Core\Models\User|null $user
@ -15,13 +15,13 @@ class EloquentCategoryRepository implements CategoryRepositoryInterface
*/
public function find(User $user = null)
{
$query = Category::newQuery();
$query = Tag::newQuery();
return $this->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');
}

View File

@ -1,10 +1,10 @@
<?php namespace Flarum\Categories\Handlers;
<?php namespace Flarum\Tags\Handlers;
use Flarum\Categories\Category;
use Flarum\Categories\CategorySerializer;
use Flarum\Tags\Tag;
use Flarum\Tags\TagSerializer;
use Flarum\Forum\Events\RenderView;
class CategoryPreloader
class TagPreloader
{
public function subscribe($events)
{
@ -13,7 +13,7 @@ class CategoryPreloader
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());
$serializer = new TagSerializer($event->action->actor, null, ['parent']);
$event->view->data = array_merge($event->view->data, $serializer->collection(Tag::orderBy('position')->get())->toArray());
}
}

View File

@ -0,0 +1,8 @@
<?php namespace Flarum\Tags;
use Flarum\Core\Models\Model;
class Tag extends Model
{
protected $table = 'tags';
}

View File

@ -0,0 +1,63 @@
<?php namespace Flarum\Tags;
use Flarum\Core\Repositories\UserRepositoryInterface as UserRepository;
use Flarum\Core\Search\SearcherInterface;
use Flarum\Core\Search\GambitAbstract;
class TagGambit extends GambitAbstract
{
/**
* The gambit's regex pattern.
*
* @var string
*/
protected $pattern = 'tag:(.+)';
/**
* @var \Flarum\Tags\TagRepositoryInterface
*/
protected $tags;
/**
* Instantiate the gambit.
*
* @param \Flarum\Tags\TagRepositoryInterface $categories
*/
public function __construct(TagRepositoryInterface $tags)
{
$this->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]);
});
}
}
});
}
}

View File

@ -1,11 +1,11 @@
<?php namespace Flarum\Categories;
<?php namespace Flarum\Tags;
use Flarum\Core\Models\User;
interface CategoryRepositoryInterface
interface TagRepositoryInterface
{
/**
* Find all categories, optionally making sure they are visible to a
* Find all tags, optionally making sure they are visible to a
* certain user.
*
* @param \Flarum\Core\Models\User|null $user
@ -14,7 +14,7 @@ interface CategoryRepositoryInterface
public function find(User $user = null);
/**
* 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

View File

@ -0,0 +1,42 @@
<?php namespace Flarum\Tags;
use Flarum\Api\Serializers\BaseSerializer;
class TagSerializer extends BaseSerializer
{
/**
* The resource type.
*
* @var string
*/
protected $type = 'tags';
/**
* Serialize tag attributes to be exposed in the API.
*
* @param \Flarum\Tags\Tag $tag
* @return array
*/
protected function attributes($tag)
{
$attributes = [
'name' => $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');
}
}

View File

@ -0,0 +1,64 @@
<?php namespace Flarum\Tags;
use Flarum\Support\ServiceProvider;
use Flarum\Extend\ForumAssets;
use Flarum\Extend\EventSubscribers;
use Flarum\Extend\Relationship;
use Flarum\Extend\SerializeRelationship;
use Flarum\Extend\ApiInclude;
use Flarum\Extend\Permission;
use Flarum\Extend\DiscussionGambit;
class TagsServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application events.
*
* @return void
*/
public function boot()
{
$this->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'
);
}
}