mirror of
https://github.com/flarum/core.git
synced 2025-08-08 09:26:34 +02:00
Add API endpoints and admin page to manage tags
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
var gulp = require('flarum-gulp');
|
var gulp = require('flarum-gulp');
|
||||||
|
|
||||||
gulp({
|
gulp({
|
||||||
|
files: [
|
||||||
|
'bower_components/html.sortable/dist/html.sortable.js'
|
||||||
|
],
|
||||||
modules: {
|
modules: {
|
||||||
'tags': [
|
'tags': [
|
||||||
'../lib/**/*.js',
|
'../lib/**/*.js',
|
||||||
|
18
extensions/tags/js/admin/src/addTagsPane.js
Normal file
18
extensions/tags/js/admin/src/addTagsPane.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { extend } from 'flarum/extend';
|
||||||
|
import AdminNav from 'flarum/components/AdminNav';
|
||||||
|
import AdminLinkButton from 'flarum/components/AdminLinkButton';
|
||||||
|
|
||||||
|
import TagsPage from 'tags/components/TagsPage';
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
app.routes.tags = {path: '/tags', component: TagsPage.component()};
|
||||||
|
|
||||||
|
extend(AdminNav.prototype, 'items', items => {
|
||||||
|
items.add('tags', AdminLinkButton.component({
|
||||||
|
href: app.route('tags'),
|
||||||
|
icon: 'tags',
|
||||||
|
children: 'Tags',
|
||||||
|
description: 'Manage the list of tags available to organise discussions with.'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
60
extensions/tags/js/admin/src/addTagsPermissionScope.js
Normal file
60
extensions/tags/js/admin/src/addTagsPermissionScope.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { extend } from 'flarum/extend';
|
||||||
|
import PermissionGrid from 'flarum/components/PermissionGrid';
|
||||||
|
import PermissionDropdown from 'flarum/components/PermissionDropdown';
|
||||||
|
import Dropdown from 'flarum/components/Dropdown';
|
||||||
|
import Button from 'flarum/components/Button';
|
||||||
|
|
||||||
|
import tagLabel from 'tags/helpers/tagLabel';
|
||||||
|
import tagIcon from 'tags/helpers/tagIcon';
|
||||||
|
import sortTags from 'tags/utils/sortTags';
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
extend(PermissionGrid.prototype, 'scopeItems', items => {
|
||||||
|
sortTags(app.store.all('tags'))
|
||||||
|
.filter(tag => tag.isRestricted())
|
||||||
|
.forEach(tag => items.add('tag' + tag.id(), {
|
||||||
|
label: tagLabel(tag),
|
||||||
|
onremove: () => tag.save({isRestricted: false}),
|
||||||
|
render: item => {
|
||||||
|
if (item.permission) {
|
||||||
|
let permission;
|
||||||
|
|
||||||
|
if (item.permission === 'forum.view') {
|
||||||
|
permission = 'view';
|
||||||
|
} else if (item.permission === 'forum.startDiscussion') {
|
||||||
|
permission = 'startDiscussion';
|
||||||
|
} else if (item.permission.indexOf('discussion.') === 0) {
|
||||||
|
permission = item.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission) {
|
||||||
|
const props = Object.assign({}, item);
|
||||||
|
props.permission = 'tag' + tag.id() + '.' + permission;
|
||||||
|
|
||||||
|
return PermissionDropdown.component(props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
extend(PermissionGrid.prototype, 'scopeControlItems', items => {
|
||||||
|
const tags = sortTags(app.store.all('tags').filter(tag => !tag.isRestricted()));
|
||||||
|
|
||||||
|
if (tags.length) {
|
||||||
|
items.add('tag', Dropdown.component({
|
||||||
|
buttonClassName: 'Button Button--text',
|
||||||
|
label: 'Restrict by Tag',
|
||||||
|
icon: 'plus',
|
||||||
|
caretIcon: null,
|
||||||
|
children: tags.map(tag => Button.component({
|
||||||
|
icon: true,
|
||||||
|
children: [tagIcon(tag, {className: 'Button-icon'}), ' ', tag.name()],
|
||||||
|
onclick: () => tag.save({isRestricted: true})
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
106
extensions/tags/js/admin/src/components/EditTagModal.js
Normal file
106
extensions/tags/js/admin/src/components/EditTagModal.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import Modal from 'flarum/components/Modal';
|
||||||
|
import Button from 'flarum/components/Button';
|
||||||
|
import { slug } from 'flarum/utils/string';
|
||||||
|
|
||||||
|
import tagLabel from 'tags/helpers/tagLabel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `EditTagModal` component shows a modal dialog which allows the user
|
||||||
|
* to create or edit a tag.
|
||||||
|
*/
|
||||||
|
export default class EditTagModal extends Modal {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
|
||||||
|
this.tag = this.props.tag || app.store.createRecord('tags');
|
||||||
|
|
||||||
|
this.name = m.prop(this.tag.name() || '');
|
||||||
|
this.slug = m.prop(this.tag.slug() || '');
|
||||||
|
this.description = m.prop(this.tag.description() || '');
|
||||||
|
this.color = m.prop(this.tag.color() || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
className() {
|
||||||
|
return 'EditTagModal Modal--small';
|
||||||
|
}
|
||||||
|
|
||||||
|
title() {
|
||||||
|
return this.name()
|
||||||
|
? tagLabel({
|
||||||
|
name: this.name,
|
||||||
|
color: this.color
|
||||||
|
})
|
||||||
|
: 'Create Tag';
|
||||||
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return (
|
||||||
|
<div className="Modal-body">
|
||||||
|
<div className="Form">
|
||||||
|
<div className="Form-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input className="FormControl" placeholder="Name" value={this.name()} oninput={e => {
|
||||||
|
this.name(e.target.value);
|
||||||
|
this.slug(slug(e.target.value));
|
||||||
|
}}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="Form-group">
|
||||||
|
<label>Slug</label>
|
||||||
|
<input className="FormControl" value={this.slug()} oninput={m.withAttr('value', this.slug)}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="Form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea className="FormControl" value={this.description()} oninput={m.withAttr('value', this.description)}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="Form-group">
|
||||||
|
<label>Color</label>
|
||||||
|
<input className="FormControl" placeholder="#aaaaaa" value={this.color()} oninput={m.withAttr('value', this.color)}/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="Form-group">
|
||||||
|
{Button.component({
|
||||||
|
type: 'submit',
|
||||||
|
className: 'Button Button--primary EditTagModal-save',
|
||||||
|
loading: this._loading,
|
||||||
|
children: 'Save Changes'
|
||||||
|
})}
|
||||||
|
{this.tag.exists ? (
|
||||||
|
<button type="button" className="Button EditTagModal-delete" onclick={this.delete.bind(this)}>
|
||||||
|
Delete Tag
|
||||||
|
</button>
|
||||||
|
) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onsubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this._loading = true;
|
||||||
|
|
||||||
|
this.tag.save({
|
||||||
|
name: this.name(),
|
||||||
|
slug: this.slug(),
|
||||||
|
description: this.description(),
|
||||||
|
color: this.color()
|
||||||
|
}).then(
|
||||||
|
() => this.hide(),
|
||||||
|
() => {
|
||||||
|
this._loading = false;
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete() {
|
||||||
|
if (confirm('Are you sure you want to delete this tag? The tag\'s discussions will NOT be deleted.')) {
|
||||||
|
this.tag.delete().then(() => m.redraw());
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
107
extensions/tags/js/admin/src/components/TagSettingsModal.js
Normal file
107
extensions/tags/js/admin/src/components/TagSettingsModal.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import Modal from 'flarum/components/Modal';
|
||||||
|
import Button from 'flarum/components/Button';
|
||||||
|
import saveConfig from 'flarum/utils/saveConfig';
|
||||||
|
|
||||||
|
export default class TagSettingsModal extends Modal {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
|
||||||
|
this.minPrimaryTags = m.prop(app.config['tags.min_primary_tags'] || 0);
|
||||||
|
this.maxPrimaryTags = m.prop(app.config['tags.max_primary_tags'] || 0);
|
||||||
|
this.minSecondaryTags = m.prop(app.config['tags.min_secondary_tags'] || 0);
|
||||||
|
this.maxSecondaryTags = m.prop(app.config['tags.max_secondary_tags'] || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMinTags(minTags, maxTags, value) {
|
||||||
|
minTags(value);
|
||||||
|
maxTags(Math.max(value, maxTags()));
|
||||||
|
}
|
||||||
|
|
||||||
|
className() {
|
||||||
|
return 'TagSettingsModal Modal--small';
|
||||||
|
}
|
||||||
|
|
||||||
|
title() {
|
||||||
|
return 'Tag Settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return (
|
||||||
|
<div className="Modal-body">
|
||||||
|
<div className="Form">
|
||||||
|
<div className="Form-group">
|
||||||
|
<label>Required Number of Primary Tags</label>
|
||||||
|
<div className="helpText">
|
||||||
|
Enter the minimum and maximum number of primary tags that may be applied to a discussion.
|
||||||
|
</div>
|
||||||
|
<div className="TagSettingsModal-rangeInput">
|
||||||
|
<input className="FormControl"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={this.minPrimaryTags()}
|
||||||
|
oninput={m.withAttr('value', this.setMinTags.bind(this, this.minPrimaryTags, this.maxPrimaryTags))}
|
||||||
|
/>
|
||||||
|
{' to '}
|
||||||
|
<input className="FormControl"
|
||||||
|
type="number"
|
||||||
|
min={this.minPrimaryTags()}
|
||||||
|
value={this.maxPrimaryTags()}
|
||||||
|
oninput={m.withAttr('value', this.maxPrimaryTags)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="Form-group">
|
||||||
|
<label>Required Number of Secondary Tags</label>
|
||||||
|
<div className="helpText">
|
||||||
|
Enter the minimum and maximum number of secondary tags that may be applied to a discussion.
|
||||||
|
</div>
|
||||||
|
<div className="TagSettingsModal-rangeInput">
|
||||||
|
<input className="FormControl"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={this.minSecondaryTags()}
|
||||||
|
oninput={m.withAttr('value', this.setMinTags.bind(this, this.minSecondaryTags, this.maxSecondaryTags))}
|
||||||
|
/>
|
||||||
|
{' to '}
|
||||||
|
<input className="FormControl"
|
||||||
|
type="number"
|
||||||
|
min={this.minSecondaryTags()}
|
||||||
|
value={this.maxSecondaryTags()}
|
||||||
|
oninput={m.withAttr('value', this.maxSecondaryTags)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="Form-group">
|
||||||
|
{Button.component({
|
||||||
|
type: 'submit',
|
||||||
|
className: 'Button Button--primary TagSettingsModal-save',
|
||||||
|
loading: this.loading,
|
||||||
|
children: 'Save Changes'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onsubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
saveConfig({
|
||||||
|
'tags.min_primary_tags': this.minPrimaryTags(),
|
||||||
|
'tags.max_primary_tags': this.maxPrimaryTags(),
|
||||||
|
'tags.min_secondary_tags': this.minSecondaryTags(),
|
||||||
|
'tags.max_secondary_tags': this.maxSecondaryTags()
|
||||||
|
}).then(
|
||||||
|
() => this.hide(),
|
||||||
|
() => {
|
||||||
|
this.loading = false;
|
||||||
|
m.redraw();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
149
extensions/tags/js/admin/src/components/TagsPage.js
Normal file
149
extensions/tags/js/admin/src/components/TagsPage.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import Component from 'flarum/Component';
|
||||||
|
import Button from 'flarum/components/Button';
|
||||||
|
|
||||||
|
import EditTagModal from 'tags/components/EditTagModal';
|
||||||
|
import TagSettingsModal from 'tags/components/TagSettingsModal';
|
||||||
|
import tagIcon from 'tags/helpers/tagIcon';
|
||||||
|
import sortTags from 'tags/utils/sortTags';
|
||||||
|
|
||||||
|
function tagItem(tag) {
|
||||||
|
return (
|
||||||
|
<li data-id={tag.id()} style={{color: tag.color()}}>
|
||||||
|
<div className="TagListItem-info">
|
||||||
|
{tagIcon(tag)}
|
||||||
|
<span className="TagListItem-name">{tag.name()}</span>
|
||||||
|
{Button.component({
|
||||||
|
className: 'Button Button--link',
|
||||||
|
icon: 'pencil',
|
||||||
|
onclick: () => app.modal.show(new EditTagModal({tag}))
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!tag.isChild() && tag.position() !== null ? (
|
||||||
|
<ol className="TagListItem-children">
|
||||||
|
{sortTags(app.store.all('tags'))
|
||||||
|
.filter(child => child.parent() === tag)
|
||||||
|
.map(tagItem)}
|
||||||
|
</ol>
|
||||||
|
) : ''}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class TagsPage extends Component {
|
||||||
|
view() {
|
||||||
|
return (
|
||||||
|
<div className="TagsPage">
|
||||||
|
<div className="TagsPage-header">
|
||||||
|
<div className="container">
|
||||||
|
<p>
|
||||||
|
Tags are used to categorize discussions. Primary tags are like traditional forum categories: They can be arranged in a two-level hierarchy. Secondary tags do not have hierarchy or order, and are useful for micro-categorization.
|
||||||
|
</p>
|
||||||
|
{Button.component({
|
||||||
|
className: 'Button Button--primary',
|
||||||
|
icon: 'plus',
|
||||||
|
children: 'Create Tag',
|
||||||
|
onclick: () => app.modal.show(new EditTagModal())
|
||||||
|
})}
|
||||||
|
{Button.component({
|
||||||
|
className: 'Button',
|
||||||
|
children: 'Settings',
|
||||||
|
onclick: () => app.modal.show(new TagSettingsModal())
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="TagsPage-list">
|
||||||
|
<div className="container">
|
||||||
|
<div className="TagGroup">
|
||||||
|
<label>Primary Tags</label>
|
||||||
|
<ol className="TagList TagList--primary">
|
||||||
|
{sortTags(app.store.all('tags'))
|
||||||
|
.filter(tag => tag.position() !== null && !tag.isChild())
|
||||||
|
.map(tagItem)}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="TagGroup">
|
||||||
|
<label>Secondary Tags</label>
|
||||||
|
<ul className="TagList">
|
||||||
|
{app.store.all('tags')
|
||||||
|
.filter(tag => tag.position() === null)
|
||||||
|
.sort((a, b) => a.name().localeCompare(b.name()))
|
||||||
|
.map(tagItem)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
config(isInitialized) {
|
||||||
|
if (isInitialized) return;
|
||||||
|
|
||||||
|
this.$('ol, ul')
|
||||||
|
.sortable({connectWith: 'primary'})
|
||||||
|
.on('sortupdate', (e, ui) => {
|
||||||
|
// If we've moved a tag from 'primary' to 'secondary', then we'll update
|
||||||
|
// its attributes in our local store so that when we redraw the change
|
||||||
|
// will be made.
|
||||||
|
if (ui.startparent.is('ol') && ui.endparent.is('ul')) {
|
||||||
|
app.store.getById('tags', ui.item.data('id')).pushData({
|
||||||
|
attributes: {
|
||||||
|
position: null,
|
||||||
|
isChild: false
|
||||||
|
},
|
||||||
|
relationships: {parent: null}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct an array of primary tag IDs and their children, in the same
|
||||||
|
// order that they have been arranged in.
|
||||||
|
const order = this.$('.TagList--primary > li')
|
||||||
|
.map(function() {
|
||||||
|
return {
|
||||||
|
id: $(this).data('id'),
|
||||||
|
children: $(this).find('li')
|
||||||
|
.map(function() {
|
||||||
|
return $(this).data('id');
|
||||||
|
}).get()
|
||||||
|
};
|
||||||
|
}).get();
|
||||||
|
|
||||||
|
// Now that we have an accurate representation of the order which the
|
||||||
|
// primary tags are in, we will update the tag attributes in our local
|
||||||
|
// store to reflect this order.
|
||||||
|
order.forEach((tag, i) => {
|
||||||
|
const parent = app.store.getById('tags', tag.id);
|
||||||
|
parent.pushData({
|
||||||
|
attributes: {
|
||||||
|
position: i,
|
||||||
|
isChild: false
|
||||||
|
},
|
||||||
|
relationships: {parent: null}
|
||||||
|
});
|
||||||
|
|
||||||
|
tag.children.forEach((child, j) => {
|
||||||
|
app.store.getById('tags', child).pushData({
|
||||||
|
attributes: {
|
||||||
|
position: j,
|
||||||
|
isChild: true
|
||||||
|
},
|
||||||
|
relationships: {parent}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.request({
|
||||||
|
url: app.forum.attribute('apiUrl') + '/tags/order',
|
||||||
|
method: 'POST',
|
||||||
|
data: {order}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A diff redraw won't work here, because sortable has mucked around
|
||||||
|
// with the DOM which will confuse Mithril's diffing algorithm. Instead
|
||||||
|
// we force a full reconstruction of the DOM.
|
||||||
|
m.redraw.strategy('all');
|
||||||
|
m.redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -1,63 +1,10 @@
|
|||||||
import { extend } from 'flarum/extend';
|
|
||||||
import PermissionGrid from 'flarum/components/PermissionGrid';
|
|
||||||
import PermissionDropdown from 'flarum/components/PermissionDropdown';
|
|
||||||
import Dropdown from 'flarum/components/Dropdown';
|
|
||||||
import Button from 'flarum/components/Button';
|
|
||||||
|
|
||||||
import Tag from 'tags/models/Tag';
|
import Tag from 'tags/models/Tag';
|
||||||
import tagLabel from 'tags/helpers/tagLabel';
|
import addTagsPermissionScope from 'tags/addTagsPermissionScope';
|
||||||
import tagIcon from 'tags/helpers/tagIcon';
|
import addTagsPane from 'tags/addTagsPane';
|
||||||
import sortTags from 'tags/utils/sortTags';
|
|
||||||
|
|
||||||
app.initializers.add('tags', app => {
|
app.initializers.add('tags', app => {
|
||||||
app.store.models.tags = Tag;
|
app.store.models.tags = Tag;
|
||||||
|
|
||||||
extend(PermissionGrid.prototype, 'scopeItems', items => {
|
addTagsPermissionScope();
|
||||||
sortTags(app.store.all('tags'))
|
addTagsPane();
|
||||||
.filter(tag => tag.isRestricted())
|
|
||||||
.forEach(tag => items.add('tag' + tag.id(), {
|
|
||||||
label: tagLabel(tag),
|
|
||||||
onremove: () => tag.save({isRestricted: false}),
|
|
||||||
render: item => {
|
|
||||||
if (item.permission) {
|
|
||||||
let permission;
|
|
||||||
|
|
||||||
if (item.permission === 'forum.view') {
|
|
||||||
permission = 'view';
|
|
||||||
} else if (item.permission === 'forum.startDiscussion') {
|
|
||||||
permission = 'startDiscussion';
|
|
||||||
} else if (item.permission.indexOf('discussion.') === 0) {
|
|
||||||
permission = item.permission;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (permission) {
|
|
||||||
const props = Object.assign({}, item);
|
|
||||||
props.permission = 'tag' + tag.id() + '.' + permission;
|
|
||||||
|
|
||||||
return PermissionDropdown.component(props);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
extend(PermissionGrid.prototype, 'scopeControlItems', items => {
|
|
||||||
const tags = sortTags(app.store.all('tags').filter(tag => !tag.isRestricted()));
|
|
||||||
|
|
||||||
if (tags.length) {
|
|
||||||
items.add('tag', Dropdown.component({
|
|
||||||
buttonClassName: 'Button Button--text',
|
|
||||||
label: 'Restrict by Tag',
|
|
||||||
icon: 'plus',
|
|
||||||
caretIcon: null,
|
|
||||||
children: tags.map(tag => Button.component({
|
|
||||||
icon: true,
|
|
||||||
children: [tagIcon(tag, {className: 'Button-icon'}), ' ', tag.name()],
|
|
||||||
onclick: () => tag.save({isRestricted: true})
|
|
||||||
}))
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@@ -46,6 +46,7 @@ export default class TagsPage extends Component {
|
|||||||
<div className="TagTile-children">
|
<div className="TagTile-children">
|
||||||
{children.map(child =>
|
{children.map(child =>
|
||||||
<a href={app.route.tag(child)} config={function(element, isInitialized) {
|
<a href={app.route.tag(child)} config={function(element, isInitialized) {
|
||||||
|
if (isInitialized) return;
|
||||||
$(element).on('click', e => e.stopPropagation());
|
$(element).on('click', e => e.stopPropagation());
|
||||||
m.route.apply(this, arguments);
|
m.route.apply(this, arguments);
|
||||||
}}>
|
}}>
|
||||||
|
8
extensions/tags/less/admin/EditTagModal.less
Normal file
8
extensions/tags/less/admin/EditTagModal.less
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.EditTagModal {
|
||||||
|
.Form-group:not(:last-child) {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.EditTagModal-delete {
|
||||||
|
float: right;
|
||||||
|
}
|
16
extensions/tags/less/admin/TagSettingsModal.less
Normal file
16
extensions/tags/less/admin/TagSettingsModal.less
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.TagSettingsModal {
|
||||||
|
.Form-group:not(:last-child) {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.TagSettingsModal-rangeInput {
|
||||||
|
input {
|
||||||
|
width: 80px;
|
||||||
|
display: inline;
|
||||||
|
margin: 0 5px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
90
extensions/tags/less/admin/TagsPage.less
Normal file
90
extensions/tags/less/admin/TagsPage.less
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
.TagsPage-header {
|
||||||
|
background: @control-bg;
|
||||||
|
color: @control-color;
|
||||||
|
padding: 20px 0;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.Button {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.TagsPage-list {
|
||||||
|
padding: 20px 0;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.TagList, .TagList ol {
|
||||||
|
list-style: none;
|
||||||
|
padding: 10px 0;
|
||||||
|
margin: 0;
|
||||||
|
color: @muted-color;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
> li {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.TagIcon {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.TagListItem-info {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: @border-radius;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: @control-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Button {
|
||||||
|
float: right;
|
||||||
|
visibility: hidden;
|
||||||
|
margin: -8px -10px -8px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li:not(.sortable-dragging) > .TagListItem-info:hover > .Button {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.TagList--primary {
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
> .sortable-placeholder {
|
||||||
|
height: 34px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.TagList ol {
|
||||||
|
margin-left: 27px;
|
||||||
|
min-height: 10px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
& > :last-child {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sortable-placeholder {
|
||||||
|
border: 2px dashed @control-bg;
|
||||||
|
border-radius: @border-radius;
|
||||||
|
height: 29px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.TagGroup {
|
||||||
|
padding-left: 150px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-bottom: 2px solid @control-bg;
|
||||||
|
}
|
||||||
|
> label {
|
||||||
|
margin-left: -150px;
|
||||||
|
float: left;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,2 +1,6 @@
|
|||||||
@import "../lib/TagLabel.less";
|
@import "../lib/TagLabel.less";
|
||||||
@import "../lib/TagIcon.less";
|
@import "../lib/TagIcon.less";
|
||||||
|
|
||||||
|
@import "TagsPage.less";
|
||||||
|
@import "EditTagModal.less";
|
||||||
|
@import "TagSettingsModal.less";
|
||||||
|
40
extensions/tags/src/Api/CreateAction.php
Normal file
40
extensions/tags/src/Api/CreateAction.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php namespace Flarum\Tags\Api;
|
||||||
|
|
||||||
|
use Flarum\Tags\Commands\CreateTag;
|
||||||
|
use Flarum\Api\Actions\CreateAction as BaseCreateAction;
|
||||||
|
use Flarum\Api\JsonApiRequest;
|
||||||
|
use Illuminate\Contracts\Bus\Dispatcher;
|
||||||
|
|
||||||
|
class CreateAction extends BaseCreateAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Dispatcher
|
||||||
|
*/
|
||||||
|
protected $bus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
public $serializer = 'Flarum\Tags\Api\TagSerializer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Dispatcher $bus
|
||||||
|
*/
|
||||||
|
public function __construct(Dispatcher $bus)
|
||||||
|
{
|
||||||
|
$this->bus = $bus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a tag according to input from the API request.
|
||||||
|
*
|
||||||
|
* @param JsonApiRequest $request
|
||||||
|
* @return \Flarum\Core\Tags\Tag
|
||||||
|
*/
|
||||||
|
protected function create(JsonApiRequest $request)
|
||||||
|
{
|
||||||
|
return $this->bus->dispatch(
|
||||||
|
new CreateTag($request->actor, $request->get('data'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
34
extensions/tags/src/Api/DeleteAction.php
Normal file
34
extensions/tags/src/Api/DeleteAction.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php namespace Flarum\Tags\Api;
|
||||||
|
|
||||||
|
use Flarum\Tags\Commands\DeleteTag;
|
||||||
|
use Flarum\Api\Actions\DeleteAction as BaseDeleteAction;
|
||||||
|
use Flarum\Api\Request;
|
||||||
|
use Illuminate\Contracts\Bus\Dispatcher;
|
||||||
|
|
||||||
|
class DeleteAction extends BaseDeleteAction
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Dispatcher
|
||||||
|
*/
|
||||||
|
protected $bus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Dispatcher $bus
|
||||||
|
*/
|
||||||
|
public function __construct(Dispatcher $bus)
|
||||||
|
{
|
||||||
|
$this->bus = $bus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a tag.
|
||||||
|
*
|
||||||
|
* @param Request $request
|
||||||
|
*/
|
||||||
|
protected function delete(Request $request)
|
||||||
|
{
|
||||||
|
$this->bus->dispatch(
|
||||||
|
new DeleteTag($request->get('id'), $request->actor)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
41
extensions/tags/src/Api/OrderAction.php
Normal file
41
extensions/tags/src/Api/OrderAction.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php namespace Flarum\Tags\Api;
|
||||||
|
|
||||||
|
use Flarum\Api\Actions\JsonApiAction;
|
||||||
|
use Flarum\Api\Request;
|
||||||
|
use Zend\Diactoros\Response\EmptyResponse;
|
||||||
|
use Flarum\Tags\Tag;
|
||||||
|
use Flarum\Core\Exceptions\PermissionDeniedException;
|
||||||
|
|
||||||
|
class OrderAction extends JsonApiAction
|
||||||
|
{
|
||||||
|
protected function respond(Request $request)
|
||||||
|
{
|
||||||
|
if (! $request->actor->isAdmin()) {
|
||||||
|
throw new PermissionDeniedException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = $request->get('order');
|
||||||
|
|
||||||
|
Tag::query()->update([
|
||||||
|
'position' => null,
|
||||||
|
'parent_id' => null
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($order as $i => $parent) {
|
||||||
|
$parentId = array_get($parent, 'id');
|
||||||
|
|
||||||
|
Tag::where('id', $parentId)->update(['position' => $i]);
|
||||||
|
|
||||||
|
if (isset($parent['children']) && is_array($parent['children'])) {
|
||||||
|
foreach ($parent['children'] as $j => $childId) {
|
||||||
|
Tag::where('id', $childId)->update([
|
||||||
|
'position' => $j,
|
||||||
|
'parent_id' => $parentId
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EmptyResponse(204);
|
||||||
|
}
|
||||||
|
}
|
30
extensions/tags/src/Commands/CreateTag.php
Normal file
30
extensions/tags/src/Commands/CreateTag.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php namespace Flarum\Tags\Commands;
|
||||||
|
|
||||||
|
use Flarum\Core\Users\User;
|
||||||
|
|
||||||
|
class CreateTag
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The user performing the action.
|
||||||
|
*
|
||||||
|
* @var User
|
||||||
|
*/
|
||||||
|
public $actor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes of the new tag.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
public $data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param User $actor The user performing the action.
|
||||||
|
* @param array $data The attributes of the new tag.
|
||||||
|
*/
|
||||||
|
public function __construct(User $actor, array $data)
|
||||||
|
{
|
||||||
|
$this->actor = $actor;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
}
|
44
extensions/tags/src/Commands/CreateTagHandler.php
Normal file
44
extensions/tags/src/Commands/CreateTagHandler.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php namespace Flarum\Tags\Commands;
|
||||||
|
|
||||||
|
use Flarum\Tags\Tag;
|
||||||
|
use Flarum\Core\Forum;
|
||||||
|
use Flarum\Events\TagWillBeSaved;
|
||||||
|
|
||||||
|
class CreateTagHandler
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Forum
|
||||||
|
*/
|
||||||
|
protected $forum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Forum $forum
|
||||||
|
*/
|
||||||
|
public function __construct(Forum $forum)
|
||||||
|
{
|
||||||
|
$this->forum = $forum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param CreateTag $command
|
||||||
|
* @return Tag
|
||||||
|
*/
|
||||||
|
public function handle(CreateTag $command)
|
||||||
|
{
|
||||||
|
$actor = $command->actor;
|
||||||
|
$data = $command->data;
|
||||||
|
|
||||||
|
$this->forum->assertCan($actor, 'createTag');
|
||||||
|
|
||||||
|
$tag = Tag::build(
|
||||||
|
array_get($data, 'attributes.name'),
|
||||||
|
array_get($data, 'attributes.slug'),
|
||||||
|
array_get($data, 'attributes.description'),
|
||||||
|
array_get($data, 'attributes.color')
|
||||||
|
);
|
||||||
|
|
||||||
|
$tag->save();
|
||||||
|
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
}
|
42
extensions/tags/src/Commands/DeleteTag.php
Normal file
42
extensions/tags/src/Commands/DeleteTag.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php namespace Flarum\Tags\Commands;
|
||||||
|
|
||||||
|
use Flarum\Tags\Tag;
|
||||||
|
use Flarum\Core\Users\User;
|
||||||
|
|
||||||
|
class DeleteTag
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The ID of the tag to delete.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $tagId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user performing the action.
|
||||||
|
*
|
||||||
|
* @var User
|
||||||
|
*/
|
||||||
|
public $actor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any other tag input associated with the action. This is unused by
|
||||||
|
* default, but may be used by extensions.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
public $data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $tagId The ID of the tag to delete.
|
||||||
|
* @param User $actor The user performing the action.
|
||||||
|
* @param array $data Any other tag input associated with the action. This
|
||||||
|
* is unused by default, but may be used by extensions.
|
||||||
|
*/
|
||||||
|
public function __construct($tagId, User $actor, array $data = [])
|
||||||
|
{
|
||||||
|
$this->tagId = $tagId;
|
||||||
|
$this->actor = $actor;
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
}
|
38
extensions/tags/src/Commands/DeleteTagHandler.php
Normal file
38
extensions/tags/src/Commands/DeleteTagHandler.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php namespace Flarum\Tags\Commands;
|
||||||
|
|
||||||
|
use Flarum\Tags\Tag;
|
||||||
|
use Flarum\Tags\TagRepository;
|
||||||
|
|
||||||
|
class DeleteTagHandler
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var TagRepository
|
||||||
|
*/
|
||||||
|
protected $tags;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TagRepository $tags
|
||||||
|
*/
|
||||||
|
public function __construct(TagRepository $tags)
|
||||||
|
{
|
||||||
|
$this->tags = $tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param DeleteTag $command
|
||||||
|
* @return Tag
|
||||||
|
* @throws \Flarum\Core\Exceptions\PermissionDeniedException
|
||||||
|
*/
|
||||||
|
public function handle(DeleteTag $command)
|
||||||
|
{
|
||||||
|
$actor = $command->actor;
|
||||||
|
|
||||||
|
$tag = $this->tags->findOrFail($command->tagId, $actor);
|
||||||
|
|
||||||
|
$tag->assertCan($actor, 'delete');
|
||||||
|
|
||||||
|
$tag->delete();
|
||||||
|
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
}
|
@@ -34,6 +34,22 @@ class EditTagHandler
|
|||||||
|
|
||||||
$attributes = array_get($data, 'attributes', []);
|
$attributes = array_get($data, 'attributes', []);
|
||||||
|
|
||||||
|
if (isset($attributes['name'])) {
|
||||||
|
$tag->name = $attributes['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($attributes['slug'])) {
|
||||||
|
$tag->slug = $attributes['slug'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($attributes['description'])) {
|
||||||
|
$tag->description = $attributes['description'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($attributes['color'])) {
|
||||||
|
$tag->color = $attributes['color'];
|
||||||
|
}
|
||||||
|
|
||||||
if (isset($attributes['isRestricted'])) {
|
if (isset($attributes['isRestricted'])) {
|
||||||
$tag->is_restricted = (bool) $attributes['isRestricted'];
|
$tag->is_restricted = (bool) $attributes['isRestricted'];
|
||||||
}
|
}
|
||||||
|
@@ -7,6 +7,8 @@ class Extension extends BaseExtension
|
|||||||
{
|
{
|
||||||
public function boot(Dispatcher $events)
|
public function boot(Dispatcher $events)
|
||||||
{
|
{
|
||||||
|
Tag::setValidator($this->app->make('validator'));
|
||||||
|
|
||||||
$events->subscribe('Flarum\Tags\Listeners\AddClientAssets');
|
$events->subscribe('Flarum\Tags\Listeners\AddClientAssets');
|
||||||
$events->subscribe('Flarum\Tags\Listeners\AddModelRelationship');
|
$events->subscribe('Flarum\Tags\Listeners\AddModelRelationship');
|
||||||
$events->subscribe('Flarum\Tags\Listeners\ConfigureDiscussionPermissions');
|
$events->subscribe('Flarum\Tags\Listeners\ConfigureDiscussionPermissions');
|
||||||
|
@@ -74,6 +74,9 @@ class AddApiAttributes
|
|||||||
|
|
||||||
public function addRoutes(RegisterApiRoutes $event)
|
public function addRoutes(RegisterApiRoutes $event)
|
||||||
{
|
{
|
||||||
|
$event->post('/tags', 'tags.create', 'Flarum\Tags\Api\CreateAction');
|
||||||
|
$event->post('/tags/order', 'tags.order', 'Flarum\Tags\Api\OrderAction');
|
||||||
$event->patch('/tags/{id}', 'tags.update', 'Flarum\Tags\Api\UpdateAction');
|
$event->patch('/tags/{id}', 'tags.update', 'Flarum\Tags\Api\UpdateAction');
|
||||||
|
$event->delete('/tags/{id}', 'tags.delete', 'Flarum\Tags\Api\DeleteAction');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
use Flarum\Core\Model;
|
use Flarum\Core\Model;
|
||||||
use Flarum\Core\Discussions\Discussion;
|
use Flarum\Core\Discussions\Discussion;
|
||||||
|
use Flarum\Core\Groups\Permission;
|
||||||
use Flarum\Core\Support\VisibleScope;
|
use Flarum\Core\Support\VisibleScope;
|
||||||
use Flarum\Core\Support\Locked;
|
use Flarum\Core\Support\Locked;
|
||||||
|
use Flarum\Core\Support\ValidatesBeforeSave;
|
||||||
|
|
||||||
class Tag extends Model
|
class Tag extends Model
|
||||||
{
|
{
|
||||||
|
use ValidatesBeforeSave;
|
||||||
use VisibleScope;
|
use VisibleScope;
|
||||||
use Locked;
|
use Locked;
|
||||||
|
|
||||||
@@ -14,6 +17,48 @@ class Tag extends Model
|
|||||||
|
|
||||||
protected $dates = ['last_time'];
|
protected $dates = ['last_time'];
|
||||||
|
|
||||||
|
protected $rules = [
|
||||||
|
'name' => 'required',
|
||||||
|
'slug' => 'required'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the model.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::deleted(function ($tag) {
|
||||||
|
$tag->discussions()->detach();
|
||||||
|
|
||||||
|
Permission::where('permission', 'like', "tag{$tag->id}.%")->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tag.
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* @param string $slug
|
||||||
|
* @param string $description
|
||||||
|
* @param string $color
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
public static function build($name, $slug, $description, $color)
|
||||||
|
{
|
||||||
|
$tag = new static;
|
||||||
|
|
||||||
|
$tag->name = $name;
|
||||||
|
$tag->slug = $slug;
|
||||||
|
$tag->description = $description;
|
||||||
|
$tag->color = $color;
|
||||||
|
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
|
||||||
public function parent()
|
public function parent()
|
||||||
{
|
{
|
||||||
return $this->belongsTo('Flarum\Tags\Tag', 'parent_id');
|
return $this->belongsTo('Flarum\Tags\Tag', 'parent_id');
|
||||||
|
Reference in New Issue
Block a user