Update for new extension API; implement i10n

This commit is contained in:
Toby Zerner 2015-07-22 10:15:25 +09:30
parent 7d38f0880e
commit 180b87c71e
67 changed files with 1890 additions and 1777 deletions

View File

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

View File

@ -0,0 +1,5 @@
**/bower_components/**/*
**/node_modules/**/*
vendor/**/*
**/Gulpfile.js
**/dist/**/*

171
extensions/tags/.eslintrc Normal file
View File

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

View File

@ -1,9 +1,5 @@
<?php
// Require the extension's composer autoload file. This will enable all of our
// classes in the src directory to be autoloaded.
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\Tags\TagsServiceProvider');
return 'Flarum\Tags\Extension';

View File

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

View File

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

View File

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

View File

@ -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', (
<a className="DiscussionComposer-changeTags" onclick={this.chooseTags.bind(this)}>
{tagsLabel(this.tags)}
</a>
), 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;
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: <em>{this.props.discussion.title()}</em>})
: 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 [
<div className="Modal-body">
<div className="TagDiscussionModal-form">
<div className="TagDiscussionModal-form-input">
<div className={'TagsInput FormControl ' + (this.focused ? 'focus' : '')}>
<span className="TagsInput-selected">
{this.selected.map(tag =>
<span className="TagsInput-tag" onclick={() => {
this.removeTag(tag);
this.onready();
}}>
{tagLabel(tag)}
</span>
)}
</span>
<input className="FormControl"
placeholder={!this.selected.length ? app.trans('tags.discussion_tags_placeholder') : ''}
value={this.filter()}
oninput={m.withAttr('value', this.filter)}
onkeydown={this.onkeydown.bind(this)}
onfocus={() => this.focused = true}
onblur={() => this.focused = false}/>
</div>
</div>
<div className="TagDiscussionModal-form-submit App-primaryControl">
{Button.component({
type: 'submit',
className: 'Button Button--primary',
disabled: !this.selected.length,
icon: 'check',
children: app.trans('tags.confirm')
})}
</div>
</div>
</div>,
<div className="Modal-footer">
<ul className="TagDiscussionModal-list SelectTagList">
{tags.map(tag => {
if (!filter && tag.parent() && this.selected.indexOf(tag.parent()) === -1) {
return '';
}
return (
<li data-index={tag.id()}
className={classList({
pinned: tag.position() !== null,
child: !!tag.parent(),
colored: !!tag.color(),
selected: this.selected.indexOf(tag) !== -1,
active: this.index === tag
})}
style={{color: tag.color()}}
onmouseover={() => this.index = tag}
onclick={this.toggleTag.bind(this, tag)}
>
{tagIcon(tag)}
<span className="SelectTagListItem-name">
{highlight(tag.name(), filter)}
</span>
{tag.description()
? (
<span className="SelectTagListItem-description">
{tag.description()}
</span>
) : ''}
</li>
);
})}
</ul>
</div>
];
}
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');
}
}

View File

@ -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 (
<header className="Hero TagHero"
style={color ? {color: '#fff', backgroundColor: color} : ''}>
<div className="container">
<div className="containerNarrow">
<h2 className="Hero-title">{tag.name()}</h2>
<div className="Hero-subtitle">{tag.description()}</div>
</div>
</div>
</header>
);
}
}

View File

@ -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 (
<a className={'TagLinkButton hasIcon ' + (tag.isChild() ? 'child' : '')} href={this.props.href} config={m.route}
style={active && tag ? {color: tag.color()} : ''}
title={description || ''}>
{tagIcon(tag, {className: 'Button-icon'})}
{this.props.children}
</a>
);
}
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');
}
}

View File

@ -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 (
<div className="TagsPage">
{IndexPage.prototype.hero()}
<div className="container">
<nav className="TagsPage-nav IndexPage-nav sideNav" config={IndexPage.prototype.affixSidebar}>
<ul>{listItems(IndexPage.prototype.sidebarItems().toArray())}</ul>
</nav>
<div className="TagsPage-content sideNavOffset">
<ul className="TagTiles">
{pinned.map(tag => {
const lastDiscussion = tag.lastDiscussion();
const children = app.store.all('tags').filter(child => child.parent() === tag);
return (
<li className={'TagTile ' + (tag.color() ? 'colored' : '')}
style={{backgroundColor: tag.color()}}>
<a className="TagTile-info" href={app.route.tag(tag)} config={m.route}>
<h3 className="TagTile-name">{tag.name()}</h3>
<p className="TagTile-description">{tag.description()}</p>
{children
? (
<div className="TagTile-children">
{children.map(child =>
<a href={app.route.tag(child)} config={m.route} onclick={e => e.stopPropagation()}>
{child.name()}
</a>
)}
</div>
) : ''}
</a>
{lastDiscussion
? (
<a className="TagTile-lastDiscussion"
href={app.route.discussion(lastDiscussion, lastDiscussion.lastPostNumber())}
config={m.route}>
{humanTime(lastDiscussion.lastTime())}
<span className="TagTile-lastDiscussion-title">{lastDiscussion.title()}</span>
</a>
) : (
<span className="TagTile-lastDiscussion"/>
)}
</li>
);
})}
</ul>
{cloud.length ? (
<div className="TagCloud">
<h4 className="TagCloud-title">{app.trans('tags.tag_cloud_title')}</h4>
<div className="TagCloud-content">
{cloud.map(tag => {
const color = tag.color();
return [
<a href={app.route.tag(tag)} config={m.route} style={color ? {color} : ''}>
{tag.name()}
</a>,
' '
];
})}
</div>
</div>
) : ''}
</div>
</div>
</div>
);
}
config() {
IndexPage.prototype.config.apply(this, arguments);
}
onunload() {
IndexPage.prototype.onunload.apply(this, arguments);
}
}

View File

@ -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 <span {...attrs}/>;
}

View File

@ -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,
<span className="TagLabel-text">
{tag ? tag.name() : app.trans('tags.untagged')}
</span>
)
);
}

View File

@ -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 <span {...attrs}>{children}</span>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,17 @@
<?php namespace Flarum\Tags\Events;
use Flarum\Core\Models\Discussion;
use Flarum\Core\Models\User;
use Flarum\Core\Discussions\Discussion;
use Flarum\Core\Users\User;
class DiscussionWasTagged
{
/**
* @var \Flarum\Core\Models\Discussion
* @var Discussion
*/
public $discussion;
/**
* @var \Flarum\Core\Models\User
* @var User
*/
public $user;
@ -21,9 +21,9 @@ class DiscussionWasTagged
public $oldTags;
/**
* @param \Flarum\Core\Models\Discussion $discussion
* @param \Flarum\Core\Models\User $user
* @param \Flarum\Categories\Category $oldCategory
* @param Discussion $discussion
* @param User $user
* @param \Flarum\Tags\Tag[] $oldTags
*/
public function __construct(Discussion $discussion, User $user, array $oldTags)
{

View File

@ -0,0 +1,20 @@
<?php namespace Flarum\Tags;
use Flarum\Support\Extension as BaseExtension;
use Illuminate\Events\Dispatcher;
class Extension extends BaseExtension
{
public function boot(Dispatcher $events)
{
$events->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');
}
}

View File

@ -0,0 +1,49 @@
<?php namespace Flarum\Tags\Gambits;
use Flarum\Tags\TagRepository;
use Flarum\Core\Search\Search;
use Flarum\Core\Search\RegexGambit;
class TagGambit extends RegexGambit
{
protected $pattern = 'tag:(.+)';
/**
* @var \Flarum\Tags\TagRepository
*/
protected $tags;
/**
* @param \Flarum\Tags\TagRepository $tags
*/
public function __construct(TagRepository $tags)
{
$this->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]);
});
}
}
});
}
}

View File

@ -1,31 +0,0 @@
<?php namespace Flarum\Tags\Handlers;
use Flarum\Tags\DiscussionTaggedPost;
use Flarum\Tags\Events\DiscussionWasTagged;
use Flarum\Core\Notifications\NotificationSyncer;
use Illuminate\Contracts\Events\Dispatcher;
class DiscussionTaggedNotifier
{
/**
* Register the listeners for the subscriber.
*
* @param \Illuminate\Contracts\Events\Dispatcher $events
*/
public function subscribe(Dispatcher $events)
{
$events->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);
}
}

View File

@ -1,25 +0,0 @@
<?php namespace Flarum\Tags\Handlers;
use Flarum\Api\Events\WillSerializeData;
use Flarum\Api\Actions\Forum\ShowAction as ForumShowAction;
use Flarum\Tags\Tag;
class TagLoader
{
public function subscribe($events)
{
$events->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');
}
}
}

View File

@ -0,0 +1,72 @@
<?php namespace Flarum\Tags\Listeners;
use Flarum\Events\ApiRelationship;
use Flarum\Events\WillSerializeData;
use Flarum\Events\BuildApiAction;
use Flarum\Events\ApiAttributes;
use Flarum\Api\Actions\Forum;
use Flarum\Api\Actions\Discussions;
use Flarum\Api\Serializers\ForumSerializer;
use Flarum\Api\Serializers\DiscussionSerializer;
use Flarum\Tags\Tag;
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');
}
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');
}
}
}

View File

@ -0,0 +1,50 @@
<?php namespace Flarum\Tags\Listeners;
use Flarum\Events\RegisterLocales;
use Flarum\Events\BuildClientView;
use Flarum\Events\RegisterForumRoutes;
use Illuminate\Contracts\Events\Dispatcher;
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');
}
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');
}
}

View File

@ -0,0 +1,21 @@
<?php namespace Flarum\Tags\Listeners;
use Flarum\Events\ModelRelationship;
use Flarum\Core\Discussions\Discussion;
use Flarum\Tags\Tag;
class AddModelRelationship
{
public function subscribe($events)
{
$events->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');
}
}
}

View File

@ -0,0 +1,17 @@
<?php namespace Flarum\Tags\Listeners;
use Flarum\Events\RegisterDiscussionGambits;
use Illuminate\Contracts\Events\Dispatcher;
class AddTagGambit
{
public function subscribe(Dispatcher $events)
{
$events->listen(RegisterDiscussionGambits::class, __CLASS__.'@registerTagGambit');
}
public function registerTagGambit(RegisterDiscussionGambits $event)
{
$event->gambits->add('Flarum\Tags\Gambits\TagGambit');
}
}

View File

@ -0,0 +1,59 @@
<?php namespace Flarum\Tags\Listeners;
use Flarum\Events\ScopeModelVisibility;
use Flarum\Events\ModelAllow;
use Flarum\Core\Discussions\Discussion;
use Flarum\Tags\Tag;
class ConfigureDiscussionPermissions
{
public function subscribe($events)
{
$events->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;
}
}
}
}
}

View File

@ -0,0 +1,31 @@
<?php namespace Flarum\Tags\Listeners;
use Flarum\Events\ScopeModelVisibility;
use Flarum\Events\ModelAllow;
use Flarum\Tags\Tag;
class ConfigureTagPermissions
{
public function subscribe($events)
{
$events->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;
}
}
}
}

View File

@ -0,0 +1,32 @@
<?php namespace Flarum\Tags\Listeners;
use Flarum\Events\RegisterPostTypes;
use Flarum\Tags\Posts\DiscussionTaggedPost;
use Flarum\Tags\Events\DiscussionWasTagged;
use Illuminate\Contracts\Events\Dispatcher;
class LogDiscussionTagged
{
public function subscribe(Dispatcher $events)
{
$events->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);
}
}

View File

@ -1,24 +1,24 @@
<?php namespace Flarum\Tags\Handlers;
<?php namespace Flarum\Tags\Listeners;
use Flarum\Tags\Tag;
use Flarum\Tags\Events\DiscussionWasTagged;
use Flarum\Core\Events\DiscussionWillBeSaved;
use Flarum\Core\Models\Discussion;
use Flarum\Events\DiscussionWillBeSaved;
use Flarum\Core\Discussions\Discussion;
use Flarum\Core\Exceptions\PermissionDeniedException;
class TagSaver
class PersistData
{
public function subscribe($events)
{
$events->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()));
}
}
}
}

View File

@ -1,28 +1,28 @@
<?php namespace Flarum\Tags\Handlers;
<?php namespace Flarum\Tags\Listeners;
use Flarum\Tags\Tag;
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;
use Flarum\Events\DiscussionWasStarted;
use Flarum\Events\DiscussionWasDeleted;
use Flarum\Core\Discussions\Discussion;
use Flarum\Core\Posts\Post;
use Flarum\Events\PostWasPosted;
use Flarum\Events\PostWasDeleted;
use Flarum\Events\PostWasHidden;
use Flarum\Events\PostWasRestored;
class TagMetadataUpdater
class UpdateTagMetadata
{
public function subscribe($events)
{
$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(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) {

View File

@ -1,37 +1,34 @@
<?php namespace Flarum\Tags;
<?php namespace Flarum\Tags\Posts;
use Flarum\Core\Models\Model;
use Flarum\Core\Models\EventPost;
use Flarum\Core\Posts\Post;
use Flarum\Core\Posts\EventPost;
use Flarum\Core\Posts\MergeablePost;
class DiscussionTaggedPost extends EventPost
class DiscussionTaggedPost extends EventPost implements MergeablePost
{
/**
* The type of post this is, to be stored in the posts table.
*
* @var string
*/
public static $type = 'discussionTagged';
/**
* Merge the post into another post of the same type.
*
* @param \Flarum\Core\Models\Model $previous
* @return \Flarum\Core\Models\Model|null The final model, or null if the
* previous post was deleted.
*/
protected function mergeInto(Model $previous)
public function saveAfter(Post $previous)
{
if ($this->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;
}

View File

@ -1,15 +1,10 @@
<?php namespace Flarum\Tags;
use Flarum\Core\Models\Model;
use Flarum\Core\Models\Discussion;
use Flarum\Core\Support\Locked;
use Flarum\Core\Support\VisibleScope;
use Flarum\Core\Model;
use Flarum\Core\Discussions\Discussion;
class Tag extends Model
{
use Locked;
use VisibleScope;
protected $table = 'tags';
protected $dates = ['last_time'];
@ -21,12 +16,12 @@ class Tag extends Model
public function lastDiscussion()
{
return $this->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;
}
}

View File

@ -1,64 +0,0 @@
<?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
*/
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]);
});
}
}
});
}
}

View File

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

View File

@ -1,24 +0,0 @@
<?php namespace Flarum\Tags;
use Flarum\Core\Models\User;
interface TagRepositoryInterface
{
/**
* Find all tags, optionally making sure they are visible to a
* certain user.
*
* @param \Flarum\Core\Models\User|null $user
* @return \Illuminate\Database\Eloquent\Collection
*/
public function find(User $user = null);
/**
* Get the ID of a tag with the given slug.
*
* @param string $slug
* @param \Flarum\Core\Models\User|null $user
* @return integer
*/
public function getIdForSlug($slug, User $user = null);
}

View File

@ -1,43 +1,28 @@
<?php namespace Flarum\Tags;
use Flarum\Api\Serializers\BaseSerializer;
use Flarum\Api\Serializers\Serializer;
class TagSerializer extends BaseSerializer
class TagSerializer extends Serializer
{
/**
* 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)
protected function getDefaultAttributes($tag)
{
$user = $this->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()

View File

@ -1,138 +0,0 @@
<?php namespace Flarum\Tags;
use Flarum\Support\ServiceProvider;
use Flarum\Extend;
use Flarum\Core\Models\Discussion;
use Flarum\Core\Models\User;
class TagsServiceProvider extends ServiceProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->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')
]);
}
}