Merge remote-tracking branch 'extensions_tags/REWRITE'

This commit is contained in:
Alexander Skvortsov 2022-03-11 18:01:31 -05:00
commit deea028dab
131 changed files with 9972 additions and 0 deletions

View File

@ -0,0 +1,19 @@
# 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
[*.{diff,md}]
trim_trailing_whitespace = false
[*.{php,xml,json}]
indent_size = 4

18
extensions/tags/.gitattributes vendored Normal file
View File

@ -0,0 +1,18 @@
.gitattributes export-ignore
.gitignore export-ignore
.gitmodules export-ignore
.github export-ignore
.travis export-ignore
.travis.yml export-ignore
.editorconfig export-ignore
.styleci.yml export-ignore
phpunit.xml export-ignore
tests export-ignore
js/dist/* -diff
js/dist/* linguist-generated
js/dist-typings/* linguist-generated
js/yarn.lock -diff
* text=auto eol=lf

View File

@ -0,0 +1,15 @@
name: Tags PHP
on: [workflow_dispatch, push, pull_request]
# The reusable workflow definitions will be moved to the `flarum/framework` repo soon.
# This will break your current script.
# When this happens, run `flarum-cli audit infra --fix` to update your infrastructure.
jobs:
run:
uses: flarum/.github/.github/workflows/REUSABLE_backend.yml@main
with:
enable_backend_testing: true
backend_directory: .

View File

@ -0,0 +1,21 @@
name: Tags JS
on: [workflow_dispatch, push, pull_request]
# The reusable workflow definitions will be moved to the `flarum/framework` repo soon.
# This will break your current script.
# When this happens, run `flarum-cli audit infra --fix` to update your infrastructure.
jobs:
run:
uses: flarum/.github/.github/workflows/REUSABLE_frontend.yml@main
with:
enable_bundlewatch: false
enable_prettier: false
enable_typescript: false
frontend_directory: ./js
main_git_branch: master
secrets:
bundlewatch_github_token: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}

12
extensions/tags/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
/vendor
composer.lock
composer.phar
.DS_Store
Thumbs.db
tests/.phpunit.result.cache
/tests/integration/tmp
.vagrant
.idea/*
.vscode
js/coverage-ts

View File

@ -0,0 +1,14 @@
preset: recommended
enabled:
- logical_not_operators_with_successor_space
disabled:
- align_double_arrow
- blank_line_after_opening_tag
- multiline_array_trailing_comma
- new_with_braces
- phpdoc_align
- phpdoc_order
- phpdoc_separation
- phpdoc_types

View File

@ -0,0 +1,171 @@
# Changelog
## [1.2.0](https://github.com/flarum/tags/compare/v1.1.0...v1.2.0)
### Added
- Bypass tag requirements toggle button for able actors (https://github.com/flarum/tags/pull/153).
### Changed
- Eager load tags state with actor id (https://github.com/flarum/tags/pull/149, https://github.com/flarum/tags/pull/151).
- Slashes in tag slug break routing (https://github.com/flarum/tags/pull/150).
- Stop loading tag last posted discussion relation on admin side (https://github.com/flarum/tags/pull/152).
### Fixed
- UI does not reflect bypass tag requirements permission (https://github.com/flarum/tags/pull/148).
- Occassional errors when deleting flagged posts (https://github.com/flarum/tags/pull/154)
- Tag discussion count doesn't adjust when deleting first/only post of the discussion (https://github.com/flarum/tags/pull/154)
## [1.1.0](https://github.com/flarum/tags/compare/v1.0.3...v1.1.0)
### Added
- Custom colorising with CSS Custom Properties (https://github.com/flarum/tags/pulls/139)
### Changed
- Update nojs view to use slug driver (https://github.com/flarum/tags/pulls/142)
- Pass filter params (https://github.com/flarum/tags/pulls/141)
- Eager load actor tag states (https://github.com/flarum/tags/pulls/143)
- Export `getSelectableTags` util (https://github.com/flarum/tags/pulls/144)
### Fixed
- Broken side nav tag listing (https://github.com/flarum/tags/pulls/137)
- Discussions hidden from all users including admins (https://github.com/flarum/tags/pulls/140)
- Unauthorized view of restricted tags (https://github.com/flarum/tags/pulls/145)
- Make clicking edit tag button easier on mobile (https://github.com/flarum/core/issues/3098)
## [1.0.3](https://github.com/flarum/tags/compare/v1.0.2...v1.0.3)
### Fixed
- Sub tags that were previously loaded are visible when visiting the index from another page (https://github.com/flarum/tags/pull/135)
- Discussion pages are showing parent tags after child tags
## [1.0.2](https://github.com/flarum/tags/compare/v1.0.1...v1.0.2)
### Fixed
- All sub tags are open for each primary tag (https://github.com/flarum/tags/pull/134)
## [1.0.1](https://github.com/flarum/tags/compare/v1.0.0...v1.0.1)
### Fixed
- Permission grid does not lazy load secondary tags (https://github.com/flarum/tags/pull/133)
## [1.0.0](https://github.com/flarum/tags/compare/v0.1.0-beta.16...v1.0.0)
### Changed
- Compatibility with Flarum v1.0.0.
- Eager loading additional relations to improve performance (https://github.com/flarum/tags/pull/125)
- Optimize tag permissions querying to improve performance (https://github.com/flarum/tags/pull/126)
- Remove loading all tags on request to improve performance (https://github.com/flarum/tags/pull/87)
### Fixed
- Tags page uses incorrect canonical URL due to reusing the request object (https://github.com/flarum/tags/pull/122)
- Searching while on a tag page causes the search to affect everything and not the subset (https://github.com/flarum/tags/pull/129)
- Sometimes tag pages show the wrong tag information if it had children
- The API returns outdated tag information when saving discussions (https://github.com/flarum/tags/pull/131)
## [0.1.0-beta.16](https://github.com/flarum/tags/compare/v0.1.0-beta.15...v0.1.0-beta.16)
### Added
- Permission to allow bypassing tag requirements (https://github.com/flarum/tags/pull/111)
- `.Taglabel--child` added to tagLabel to allow styling (https://github.com/flarum/tags/pull/114)
### Changed
- Updated admin category from discussion to feature (https://github.com/flarum/tags/pull/118)
- Moved locale files from translation pack to extension (https://github.com/flarum/tags/pull/99)
- Compatibility with Illuminate 8 (https://github.com/flarum/tags/pull/121)
- Eager load relations lastPostedDiscussion requires (https://github.com/flarum/tags/pull/120)
### Fixed
- Prevent page creep with long list of tags (https://github.com/flarum/tags/pull/116)
- Enter key does not submit tag selection modal ([617fc4d](https://github.com/flarum/tags/commit/617fc4d4419fe4d3ef7b388d14965acc83b319ce))
- Editing a tag does not work (https://github.com/flarum/tags/pull/117)
- Tags link not wrapped inside `Button-label` (https://github.com/flarum/tags/pull/113)
- Without selectable tags the tag selection modal errors (https://github.com/flarum/tags/pull/112)
## [0.1.0-beta.15](https://github.com/flarum/tags/compare/v0.1.0-beta.14...v0.1.0-beta.15)
### Added
- Tag tiles have icons (#104).
### Changed
- Updated composer.json and admin javascript for new admin area.
- Updated to use newest extenders.
- Implement new authorization layer ([c3eff74](https://github.com/flarum/tags/commit/c3eff74289d3461e55d7320556b1e5a5ca08e0ac)).
### Fixed
- Guests do not see "new discussion" and get the log in modal when clicked (#98).
- The tag hidden property is not a bidi and is not saved ([3f54b70](https://github.com/flarum/tags/commit/3f54b70733bb94f7f100580f50f6503a0c387ad6)).
### Removed
- TagWillBeSaved event is removed ([05837de](https://github.com/flarum/tags/commit/05837de8bbe11ca094c7ac63f1a23d7aeceb28d2)).
## [0.1.0-beta.14](https://github.com/flarum/tags/compare/v0.1.0-beta.13...v0.1.0-beta.14)
### Added
- Introduced Creating and Deleting events (#86)
### Changed
- Updated mithril to version 2
- Load language strings correctly on en-/disable
- Updated JS dependencies
- Allow tag visibility override with an event listener (#79)
- TagWillBeSaved event renamed to Saving (#92)
### Fixed
- Sorting tag structure on mobile hardly worked (#82)
- Discussion count and visibility incorrectly included hidden or private discussions (#78)
- Negated tag filtering does not work (#88)
- Call to non existing method handleErrors (#94)
- Changing tags of discussions by other users is possible (#95)
- Tag modal shows duplicate tags ()
## [0.1.0-beta.13](https://github.com/flarum/tags/compare/v0.1.0-beta.12...v0.1.0-beta.13)
### Added
- Add title and description meta tags (#72)
### Changed
- Updated JS dependencies
- Improved performance of subqueries (#75)
- Allow menu items between listed tags and link to tags page (#70)
- Using new model extender
## [0.1.0-beta.12](https://github.com/flarum/tags/compare/v0.1.0-beta.11...v0.1.0-beta.12)
### Fixed
- Icons misaligned in tag selection modal (#73, #76)
- Selected tags indiscernible when they have no icon (#68, #76)
- Group permissions weren't really deleted when a tag was opened up again to the public (#65)
## [0.1.0-beta.11](https://github.com/flarum/tags/compare/v0.1.0-beta.10...v0.1.0-beta.11)
### Fixed
- Tag change events triggered errors for deleted tags ([e5694e5](https://github.com/flarum/tags/pull/66/commits/e5694e51ef7851523ac6e467b4d7d98d471fd997))
## [0.1.0-beta.10](https://github.com/flarum/tags/compare/v0.1.0-beta.9...v0.1.0-beta.10)
### Added
- SEO: The tags page now has a `rel="canonical"` meta tag, preventing duplicate content (#64)
- SEO: The tags page now has server-rendered content, for better indexing by search engines (#64)
## [0.1.0-beta.9](https://github.com/flarum/tags/compare/v0.1.0-beta.8.2...v0.1.0-beta.9)
### Added
- Allow configuration of icon per tag ([e1a0ff8](https://github.com/flarum/tags/commit/e1a0ff8e0f726fbfe26fa47aea4a0555b109aad0))
### Changed
- Replace event subscribers (that resolve services too early) with listeners ([73c0626](https://github.com/flarum/tags/commit/73c0626e722d2be2b82804eec5746646b64b0c44))
- Compatibility with Laravel 5.7 ([cb683f3](https://github.com/flarum/tags/commit/cb683f37e689a03b25e43e47447025de8e127a56))
- Update html5sortable library ([e8104a6](https://github.com/flarum/tags/commit/e8104a623edff6560c544972b2171faf050ec2ab))
### Fixed
- JS: Vulnerable lodash dependency ([c80cbe8](https://github.com/flarum/tags/commit/c80cbe8ae7063d1c18784e983e9789554dbe4e03))
- Search crashed when searched tag did not exist ([3d6921b](https://github.com/flarum/tags/commit/3d6921bdd257c0f17ea36bd8c1f352670fef66e8))
- Discussions from hidden tags weren't showing when gambits were used ([7275c39](https://github.com/flarum/tags/commit/7275c395799dac0f420aa14afccb1f125622af08))
## [0.1.0-beta.8.2](https://github.com/flarum/tags/compare/v0.1.0-beta.8.1...v0.1.0-beta.8.2)
### Fixed
- Fix dropping foreign keys in `down` migrations ([cad9741](https://github.com/flarum/tags/commit/cad97410e53854d58fefd01916ba3a1c3bd5ba3d))
- Fix `INVALID DATE` errors on tags page ([8d4d01c](https://github.com/flarum/tags/commit/8d4d01c61079fecf84608dda1c64d112f5d9be34))

22
extensions/tags/LICENSE Normal file
View File

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2019-2021 Stichting Flarum (Flarum Foundation)
Copyright (c) 2014-2019 Toby Zerner (toby.zerner@gmail.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,84 @@
{
"name": "flarum/tags",
"description": "Organize discussions into a hierarchy of tags and categories.",
"type": "flarum-extension",
"keywords": [
"discussion"
],
"license": "MIT",
"support": {
"issues": "https://github.com/flarum/core/issues",
"source": "https://github.com/flarum/tags",
"forum": "https://discuss.flarum.org"
},
"homepage": "https://flarum.org",
"funding": [
{
"type": "website",
"url": "https://flarum.org/donate/"
}
],
"require": {
"flarum/core": "^1.2"
},
"autoload": {
"psr-4": {
"Flarum\\Tags\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Flarum\\Tags\\Tests\\": "tests/"
}
},
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
},
"flarum-extension": {
"title": "Tags",
"category": "feature",
"icon": {
"name": "fas fa-tags",
"backgroundColor": "#F28326",
"color": "#fff"
}
},
"flarum-cli": {
"modules": {
"admin": true,
"forum": true,
"js": true,
"jsCommon": true,
"css": true,
"gitConf": true,
"githubActions": true,
"prettier": false,
"typescript": false,
"bundlewatch": false,
"backendTesting": true,
"editorConfig": true,
"styleci": true
}
}
},
"scripts": {
"test": [
"@test:unit",
"@test:integration"
],
"test:unit": "phpunit -c tests/phpunit.unit.xml",
"test:integration": "phpunit -c tests/phpunit.integration.xml",
"test:setup": "@php tests/integration/setup.php"
},
"scripts-descriptions": {
"test": "Runs all tests.",
"test:unit": "Runs all unit tests.",
"test:integration": "Runs all integration tests.",
"test:setup": "Sets up a database for use with integration tests. Execute this only once."
},
"require-dev": {
"flarum/core": "*@dev",
"flarum/testing": "^1.0.0"
}
}

136
extensions/tags/extend.php Normal file
View File

@ -0,0 +1,136 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Api\Controller as FlarumController;
use Flarum\Api\Serializer\DiscussionSerializer;
use Flarum\Api\Serializer\ForumSerializer;
use Flarum\Discussion\Discussion;
use Flarum\Discussion\Event\Saving;
use Flarum\Discussion\Filter\DiscussionFilterer;
use Flarum\Discussion\Search\DiscussionSearcher;
use Flarum\Extend;
use Flarum\Flags\Api\Controller\ListFlagsController;
use Flarum\Http\RequestUtil;
use Flarum\Post\Filter\PostFilterer;
use Flarum\Tags\Access;
use Flarum\Tags\Api\Controller;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Content;
use Flarum\Tags\Event\DiscussionWasTagged;
use Flarum\Tags\Filter\HideHiddenTagsFromAllDiscussionsPage;
use Flarum\Tags\Filter\PostTagFilter;
use Flarum\Tags\Listener;
use Flarum\Tags\LoadForumTagsRelationship;
use Flarum\Tags\Post\DiscussionTaggedPost;
use Flarum\Tags\Query\TagFilterGambit;
use Flarum\Tags\Tag;
use Psr\Http\Message\ServerRequestInterface;
$eagerLoadTagState = function ($query, ?ServerRequestInterface $request, array $relations) {
if ($request && in_array('tags.state', $relations, true)) {
$query->withStateFor(RequestUtil::getActor($request));
}
};
return [
(new Extend\Frontend('forum'))
->js(__DIR__.'/js/dist/forum.js')
->css(__DIR__.'/less/forum.less')
->route('/t/{slug}', 'tag', Content\Tag::class)
->route('/tags', 'tags', Content\Tags::class),
(new Extend\Frontend('admin'))
->js(__DIR__.'/js/dist/admin.js')
->css(__DIR__.'/less/admin.less'),
(new Extend\Routes('api'))
->get('/tags', 'tags.index', Controller\ListTagsController::class)
->post('/tags', 'tags.create', Controller\CreateTagController::class)
->post('/tags/order', 'tags.order', Controller\OrderTagsController::class)
->get('/tags/{slug}', 'tags.show', Controller\ShowTagController::class)
->patch('/tags/{id}', 'tags.update', Controller\UpdateTagController::class)
->delete('/tags/{id}', 'tags.delete', Controller\DeleteTagController::class),
(new Extend\Model(Discussion::class))
->belongsToMany('tags', Tag::class, 'discussion_tag'),
(new Extend\ApiSerializer(ForumSerializer::class))
->hasMany('tags', TagSerializer::class)
->attribute('canBypassTagCounts', function (ForumSerializer $serializer) {
return $serializer->getActor()->can('bypassTagCounts');
}),
(new Extend\ApiSerializer(DiscussionSerializer::class))
->hasMany('tags', TagSerializer::class)
->attribute('canTag', function (DiscussionSerializer $serializer, $model) {
return $serializer->getActor()->can('tag', $model);
}),
(new Extend\ApiController(FlarumController\ListPostsController::class))
->load('discussion.tags'),
(new Extend\ApiController(ListFlagsController::class))
->load('post.discussion.tags'),
(new Extend\ApiController(FlarumController\ListDiscussionsController::class))
->addInclude(['tags', 'tags.state', 'tags.parent'])
->loadWhere('tags', $eagerLoadTagState),
(new Extend\ApiController(FlarumController\ShowDiscussionController::class))
->addInclude(['tags', 'tags.state', 'tags.parent'])
->loadWhere('tags', $eagerLoadTagState),
(new Extend\ApiController(FlarumController\CreateDiscussionController::class))
->addInclude(['tags', 'tags.state', 'tags.parent'])
->loadWhere('tags', $eagerLoadTagState),
(new Extend\ApiController(FlarumController\ShowForumController::class))
->addInclude(['tags', 'tags.parent'])
->prepareDataForSerialization(LoadForumTagsRelationship::class),
(new Extend\Settings())
->serializeToForum('minPrimaryTags', 'flarum-tags.min_primary_tags')
->serializeToForum('maxPrimaryTags', 'flarum-tags.max_primary_tags')
->serializeToForum('minSecondaryTags', 'flarum-tags.min_secondary_tags')
->serializeToForum('maxSecondaryTags', 'flarum-tags.max_secondary_tags'),
(new Extend\Policy())
->modelPolicy(Discussion::class, Access\DiscussionPolicy::class)
->modelPolicy(Tag::class, Access\TagPolicy::class)
->globalPolicy(Access\GlobalPolicy::class),
(new Extend\ModelVisibility(Discussion::class))
->scopeAll(Access\ScopeDiscussionVisibilityForAbility::class),
(new Extend\ModelVisibility(Tag::class))
->scope(Access\ScopeTagVisibility::class),
new Extend\Locales(__DIR__.'/locale'),
(new Extend\View)
->namespace('tags', __DIR__.'/views'),
(new Extend\Post)
->type(DiscussionTaggedPost::class),
(new Extend\Event())
->listen(Saving::class, Listener\SaveTagsToDatabase::class)
->listen(DiscussionWasTagged::class, Listener\CreatePostWhenTagsAreChanged::class)
->subscribe(Listener\UpdateTagMetadata::class),
(new Extend\Filter(PostFilterer::class))
->addFilter(PostTagFilter::class),
(new Extend\Filter(DiscussionFilterer::class))
->addFilter(TagFilterGambit::class)
->addFilterMutator(HideHiddenTagsFromAllDiscussionsPage::class),
(new Extend\SimpleFlarumSearch(DiscussionSearcher::class))
->addGambit(TagFilterGambit::class),
];

9
extensions/tags/js/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
node_modules

View File

@ -0,0 +1,2 @@
export * from './src/common';
export * from './src/admin';

3
extensions/tags/js/dist/admin.js generated vendored Normal file

File diff suppressed because one or more lines are too long

6
extensions/tags/js/dist/admin.js.LICENSE.txt generated vendored Normal file
View File

@ -0,0 +1,6 @@
/**!
* Sortable 1.14.0
* @author RubaXa <trash@rubaxa.org>
* @author owenm <owen23355@gmail.com>
* @license MIT
*/

1
extensions/tags/js/dist/admin.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

2
extensions/tags/js/dist/forum.js generated vendored Normal file

File diff suppressed because one or more lines are too long

1
extensions/tags/js/dist/forum.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
export * from './src/common';
export * from './src/forum';

View File

@ -0,0 +1,17 @@
{
"private": true,
"name": "@flarum/tags",
"dependencies": {
"sortablejs": "^1.14.0"
},
"scripts": {
"dev": "webpack --mode development --watch",
"build": "webpack --mode production",
"analyze": "cross-env ANALYZER=true yarn build"
},
"devDependencies": {
"flarum-webpack-config": "^2.0.0",
"webpack": "^5.65.0",
"webpack-cli": "^4.9.1"
}
}

View File

@ -0,0 +1,27 @@
import { extend } from 'flarum/extend';
import PermissionGrid from 'flarum/components/PermissionGrid';
import SettingDropdown from 'flarum/components/SettingDropdown';
export default function() {
extend(PermissionGrid.prototype, 'startItems', items => {
items.add('allowTagChange', {
icon: 'fas fa-tag',
label: app.translator.trans('flarum-tags.admin.permissions.allow_edit_tags_label'),
setting: () => {
const minutes = parseInt(app.data.settings.allow_tag_change, 10);
return SettingDropdown.component({
defaultLabel: minutes
? app.translator.trans('core.admin.permissions_controls.allow_some_minutes_button', {count: minutes})
: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button'),
key: 'allow_tag_change',
options: [
{value: '-1', label: app.translator.trans('core.admin.permissions_controls.allow_indefinitely_button')},
{value: '10', label: app.translator.trans('core.admin.permissions_controls.allow_ten_minutes_button')},
{value: 'reply', label: app.translator.trans('core.admin.permissions_controls.allow_until_reply_button')}
]
});
}
}, 90);
});
}

View File

@ -0,0 +1,14 @@
export default function () {
app.extensionData
.for('flarum-tags')
.registerPermission({
icon: 'fas fa-tag',
label: app.translator.trans('flarum-tags.admin.permissions.tag_discussions_label'),
permission: 'discussion.tag',
}, 'moderate', 95)
.registerPermission({
icon: 'fas fa-tags',
label: app.translator.trans('flarum-tags.admin.permissions.bypass_tag_counts_label'),
permission: 'bypassTagCounts',
}, 'start', 89);
}

View File

@ -0,0 +1,11 @@
import { extend } from 'flarum/extend';
import BasicsPage from 'flarum/components/BasicsPage';
export default function() {
extend(BasicsPage.prototype, 'homePageItems', items => {
items.add('tags', {
path: '/tags',
label: app.translator.trans('flarum-tags.admin.basics.tags_label')
});
});
}

View File

@ -0,0 +1,80 @@
import { extend, override } 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 LoadingIndicator from 'flarum/components/LoadingIndicator';
import tagLabel from '../common/helpers/tagLabel';
import tagIcon from '../common/helpers/tagIcon';
import sortTags from '../common/utils/sortTags';
export default function() {
extend(PermissionGrid.prototype, 'oninit', function () {
this.loading = true;
})
extend(PermissionGrid.prototype, 'oncreate', function () {
app.store.find('tags').then(() => {
this.loading = false;
m.redraw();
});
});
override(PermissionGrid.prototype, 'view', function (original, vnode) {
if (this.loading) {
return <LoadingIndicator />;
}
return original(vnode);
})
override(app, 'getRequiredPermissions', (original, permission) => {
const tagPrefix = permission.match(/^tag\d+\./);
if (tagPrefix) {
const globalPermission = permission.substr(tagPrefix[0].length);
const required = original(globalPermission);
return required.map(required => tagPrefix[0] + required);
}
return original(permission);
});
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 === 'viewForum'
|| item.permission === 'startDiscussion'
|| (item.permission && item.permission.indexOf('discussion.') === 0 && item.tagScoped !== false)
|| item.tagScoped) {
return PermissionDropdown.component({
permission: 'tag' + tag.id() + '.' + item.permission,
allowGuest: item.allowGuest
});
}
return '';
}
}));
});
extend(PermissionGrid.prototype, 'scopeControlItems', items => {
const tags = sortTags(app.store.all('tags').filter(tag => !tag.isRestricted()));
if (tags.length) {
items.add('tag', <Dropdown className='Dropdown--restrictByTag' buttonClassName='Button Button--text' label={app.translator.trans('flarum-tags.admin.permissions.restrict_by_tag_heading')} icon='fas fa-plus' caretIcon={null}>
{tags.map(tag => <Button icon={true} onclick={() => tag.save({ isRestricted: true })}>
{[tagIcon(tag, { className: 'Button-icon' }), ' ', tag.name()]}
</Button>)}
</Dropdown>);
}
});
}

View File

@ -0,0 +1,17 @@
import compat from '../common/compat';
import addTagsHomePageOption from './addTagsHomePageOption';
import addTagChangePermission from './addTagChangePermission';
import TagsPage from './components/TagsPage';
import EditTagModal from './components/EditTagModal';
import addTagPermission from './addTagPermission';
import addTagsPermissionScope from './addTagsPermissionScope';
export default Object.assign(compat, {
'tags/addTagsHomePageOption': addTagsHomePageOption,
'tags/addTagChangePermission': addTagChangePermission,
'tags/components/TagsPage': TagsPage,
'tags/components/EditTagModal': EditTagModal,
'tags/addTagPermission': addTagPermission,
'tags/addTagsPermissionScope': addTagsPermissionScope,
});

View File

@ -0,0 +1,149 @@
import app from 'flarum/admin/app';
import Modal from 'flarum/common/components/Modal';
import Button from 'flarum/common/components/Button';
import ColorPreviewInput from 'flarum/common/components/ColorPreviewInput';
import ItemList from 'flarum/common/utils/ItemList';
import { slug } from 'flarum/common/utils/string';
import Stream from 'flarum/common/utils/Stream';
import tagLabel from '../../common/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 {
oninit(vnode) {
super.oninit(vnode);
this.tag = this.attrs.model || app.store.createRecord('tags');
this.name = Stream(this.tag.name() || '');
this.slug = Stream(this.tag.slug() || '');
this.description = Stream(this.tag.description() || '');
this.color = Stream(this.tag.color() || '');
this.icon = Stream(this.tag.icon() || '');
this.isHidden = Stream(this.tag.isHidden() || false);
this.primary = Stream(this.attrs.primary || false);
}
className() {
return 'EditTagModal Modal--small';
}
title() {
return this.name()
? tagLabel(app.store.createRecord('tags', { attributes: this.submitData() }))
: app.translator.trans('flarum-tags.admin.edit_tag.title');
}
content() {
return (
<div className="Modal-body">
<div className="Form">
{this.fields().toArray()}
</div>
</div>
);
}
fields() {
const items = new ItemList();
items.add('name', <div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.edit_tag.name_label')}</label>
<input className="FormControl" placeholder={app.translator.trans('flarum-tags.admin.edit_tag.name_placeholder')} value={this.name()} oninput={e => {
this.name(e.target.value);
this.slug(slug(e.target.value));
}} />
</div>, 50);
items.add('slug', <div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.edit_tag.slug_label')}</label>
<input className="FormControl" bidi={this.slug} />
</div>, 40);
items.add('description', <div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.edit_tag.description_label')}</label>
<textarea className="FormControl" bidi={this.description} />
</div>, 30);
items.add('color', <div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.edit_tag.color_label')}</label>
<ColorPreviewInput className="FormControl" placeholder="#aaaaaa" bidi={this.color} />
</div>, 20);
items.add('icon', <div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.edit_tag.icon_label')}</label>
<div className="helpText">
{app.translator.trans('flarum-tags.admin.edit_tag.icon_text', { a: <a href="https://fontawesome.com/icons?m=free" tabindex="-1" /> })}
</div>
<input className="FormControl" placeholder="fas fa-bolt" bidi={this.icon} />
</div>, 10);
items.add('hidden', <div className="Form-group">
<div>
<label className="checkbox">
<input type="checkbox" bidi={this.isHidden} />
{app.translator.trans('flarum-tags.admin.edit_tag.hide_label')}
</label>
</div>
</div>, 10);
items.add('submit', <div className="Form-group">
{Button.component({
type: 'submit',
className: 'Button Button--primary EditTagModal-save',
loading: this.loading,
}, app.translator.trans('flarum-tags.admin.edit_tag.submit_button'))}
{this.tag.exists ? (
<button type="button" className="Button EditTagModal-delete" onclick={this.delete.bind(this)}>
{app.translator.trans('flarum-tags.admin.edit_tag.delete_tag_button')}
</button>
) : ''}
</div>, -10);
return items;
}
submitData() {
return {
name: this.name(),
slug: this.slug(),
description: this.description(),
color: this.color(),
icon: this.icon(),
isHidden: this.isHidden(),
primary: this.primary(),
};
}
onsubmit(e) {
e.preventDefault();
this.loading = true;
// Errors aren't passed to the modal onerror handler here.
// This is done for better error visibility on smaller screen heights.
this.tag.save(this.submitData()).then(
() => this.hide(),
() => this.loading = false
);
}
delete() {
if (confirm(app.translator.trans('flarum-tags.admin.edit_tag.delete_tag_confirmation'))) {
const children = app.store.all('tags').filter(tag => tag.parent() === this.tag);
this.tag.delete().then(() => {
children.forEach(tag => tag.pushData({
attributes: { isChild: false },
relationships: { parent: null }
}));
m.redraw();
});
this.hide();
}
}
}

View File

@ -0,0 +1,230 @@
import sortable from 'sortablejs';
import ExtensionPage from 'flarum/components/ExtensionPage';
import Button from 'flarum/components/Button';
import LoadingIndicator from 'flarum/components/LoadingIndicator';
import withAttr from 'flarum/utils/withAttr';
import EditTagModal from './EditTagModal';
import tagIcon from '../../common/helpers/tagIcon';
import sortTags from '../../common/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: 'fas fa-pencil-alt',
onclick: () => app.modal.show(EditTagModal, { model: tag })
})}
</div>
{!tag.isChild() && tag.position() !== null ? (
<ol className="TagListItem-children TagList">
{sortTags(app.store.all('tags'))
.filter(child => child.parent() === tag)
.map(tagItem)}
</ol>
) : ''}
</li>
);
}
export default class TagsPage extends ExtensionPage {
oninit(vnode) {
super.oninit(vnode);
// A regular 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 by changing the key, which
// makes mithril completely re-render the component on redraw.
this.forcedRefreshKey = 0;
this.loading = true;
app.store.find('tags', { include: 'parent' }).then(() => {
this.loading = false;
m.redraw();
});
}
content() {
if (this.loading) {
return <LoadingIndicator />;
}
const minPrimaryTags = this.setting('flarum-tags.min_primary_tags', 0);
const maxPrimaryTags = this.setting('flarum-tags.max_primary_tags', 0);
const minSecondaryTags = this.setting('flarum-tags.min_secondary_tags', 0);
const maxSecondaryTags = this.setting('flarum-tags.max_secondary_tags', 0);
const tags = sortTags(app.store.all('tags').filter(tag => !tag.parent()));
return (
<div className="TagsContent">
<div className="TagsContent-list">
<div className="container" key={this.forcedRefreshKey} oncreate={this.onListOnCreate.bind(this)}><div className="SettingsGroups">
<div className="TagGroup">
<label>{app.translator.trans('flarum-tags.admin.tags.primary_heading')}</label>
<ol className="TagList TagList--primary">
{tags
.filter(tag => tag.position() !== null && !tag.isChild())
.map(tagItem)}
</ol>
{Button.component(
{
className: 'Button TagList-button',
icon: 'fas fa-plus',
onclick: () => app.modal.show(EditTagModal, { primary: true }),
},
app.translator.trans('flarum-tags.admin.tags.create_primary_tag_button')
)}
</div>
<div className="TagGroup TagGroup--secondary">
<label>{app.translator.trans('flarum-tags.admin.tags.secondary_heading')}</label>
<ul className="TagList">
{tags
.filter(tag => tag.position() === null)
.sort((a, b) => a.name().localeCompare(b.name()))
.map(tagItem)}
</ul>
{Button.component(
{
className: 'Button TagList-button',
icon: 'fas fa-plus',
onclick: () => app.modal.show(EditTagModal, { primary: false }),
},
app.translator.trans('flarum-tags.admin.tags.create_secondary_tag_button')
)}
</div>
<div className="Form">
<label>{app.translator.trans('flarum-tags.admin.tags.settings_heading')}</label>
<div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_heading')}</label>
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_primary_text')}</div>
<div className="TagSettings-rangeInput">
<input
className="FormControl"
type="number"
min="0"
value={minPrimaryTags()}
oninput={withAttr('value', this.setMinTags.bind(this, minPrimaryTags, maxPrimaryTags))}
/>
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
<input className="FormControl" type="number" min={minPrimaryTags()} bidi={maxPrimaryTags} />
</div>
</div>
<div className="Form-group">
<label>{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_heading')}</label>
<div className="helpText">{app.translator.trans('flarum-tags.admin.tag_settings.required_secondary_text')}</div>
<div className="TagSettings-rangeInput">
<input
className="FormControl"
type="number"
min="0"
value={minSecondaryTags()}
oninput={withAttr('value', this.setMinTags.bind(this, minSecondaryTags, maxSecondaryTags))}
/>
{app.translator.trans('flarum-tags.admin.tag_settings.range_separator_text')}
<input className="FormControl" type="number" min={minSecondaryTags()} bidi={maxSecondaryTags} />
</div>
</div>
<div className="Form-group">{this.submitButton()}</div>
</div>
</div>
<div className="TagsContent-footer">
<p>{app.translator.trans('flarum-tags.admin.tags.about_tags_text')}</p>
</div>
</div>
</div>
</div>
);
}
onListOnCreate(vnode) {
this.$('.TagList').get().map(e => {
sortable.create(e, {
group: 'tags',
delay: 50,
delayOnTouchOnly: true,
touchStartThreshold: 5,
animation: 150,
swapThreshold: 0.65,
dragClass: 'sortable-dragging',
ghostClass: 'sortable-placeholder',
onSort: (e) => this.onSortUpdate(e)
})
});
}
setMinTags(minTags, maxTags, value) {
minTags(value);
maxTags(Math.max(value, maxTags()));
}
onSortUpdate(e) {
// 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 (e.from instanceof HTMLOListElement && e.to instanceof HTMLUListElement) {
app.store.getById('tags', e.item.getAttribute('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',
body: { order }
});
this.forcedRefreshKey++;
m.redraw();
}
}

View File

@ -0,0 +1,24 @@
import Tag from '../common/models/Tag';
import addTagsPermissionScope from './addTagsPermissionScope';
import addTagPermission from './addTagPermission';
import addTagsHomePageOption from './addTagsHomePageOption';
import addTagChangePermission from './addTagChangePermission';
import TagsPage from './components/TagsPage';
app.initializers.add('flarum-tags', app => {
app.store.models.tags = Tag;
app.extensionData.for('flarum-tags').registerPage(TagsPage);
addTagsPermissionScope();
addTagPermission();
addTagsHomePageOption();
addTagChangePermission();
});
// Expose compat API
import tagsCompat from './compat';
import { compat } from '@flarum/core/admin';
Object.assign(compat, tagsCompat);

View File

@ -0,0 +1,13 @@
import sortTags from './utils/sortTags';
import Tag from './models/Tag';
import tagsLabel from './helpers/tagsLabel';
import tagIcon from './helpers/tagIcon';
import tagLabel from './helpers/tagLabel';
export default {
'tags/utils/sortTags': sortTags,
'tags/models/Tag': Tag,
'tags/helpers/tagsLabel': tagsLabel,
'tags/helpers/tagIcon': tagIcon,
'tags/helpers/tagLabel': tagLabel
};

View File

@ -0,0 +1,25 @@
import classList from 'flarum/utils/classList';
export default function tagIcon(tag, attrs = {}, settings = {}) {
const hasIcon = tag && tag.icon();
const { useColor = true } = settings;
attrs.className = classList([
attrs.className,
'icon',
hasIcon ? tag.icon() : 'TagIcon'
]);
if (tag && useColor) {
attrs.style = attrs.style || {};
attrs.style['--color'] = tag.color();
if (hasIcon) {
attrs.style.color = tag.color();
}
} else if (!tag) {
attrs.className += ' untagged';
}
return hasIcon ? <i {...attrs}/> : <span {...attrs}/>;
}

View File

@ -0,0 +1,38 @@
import extract from 'flarum/utils/extract';
import Link from 'flarum/components/Link';
import tagIcon from './tagIcon';
export default function tagLabel(tag, attrs = {}) {
attrs.style = attrs.style || {};
attrs.className = 'TagLabel ' + (attrs.className || '');
const link = extract(attrs, 'link');
const tagText = tag ? tag.name() : app.translator.trans('flarum-tags.lib.deleted_tag_text');
if (tag) {
const color = tag.color();
if (color) {
attrs.style['--tag-bg'] = color;
attrs.className += ' colored';
}
if (link) {
attrs.title = tag.description() || '';
attrs.href = app.route('tag', {tags: tag.slug()});
}
if (tag.isChild()) {
attrs.className += ' TagLabel--child';
}
} else {
attrs.className += ' untagged';
}
return (
m((link ? Link : 'span'), attrs,
<span className="TagLabel-text">
{tag && tag.icon() && tagIcon(tag, {}, {useColor: false})} {tagText}
</span>
)
);
}

View File

@ -0,0 +1,22 @@
import extract from 'flarum/utils/extract';
import tagLabel from './tagLabel';
import sortTags from '../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

View File

@ -0,0 +1,31 @@
import Model from 'flarum/Model';
import mixin from 'flarum/utils/mixin';
import computed from 'flarum/utils/computed';
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'),
icon: Model.attribute('icon'),
position: Model.attribute('position'),
parent: Model.hasOne('parent'),
children: Model.hasMany('children'),
defaultSort: Model.attribute('defaultSort'),
isChild: Model.attribute('isChild'),
isHidden: Model.attribute('isHidden'),
discussionCount: Model.attribute('discussionCount'),
lastPostedAt: Model.attribute('lastPostedAt', Model.transformDate),
lastPostedDiscussion: Model.hasOne('lastPostedDiscussion'),
isRestricted: Model.attribute('isRestricted'),
canStartDiscussion: Model.attribute('canStartDiscussion'),
canAddToDiscussion: Model.attribute('canAddToDiscussion'),
isPrimary: computed('position', 'parent', (position, parent) => position !== null && parent === false)
}) {}

View File

@ -0,0 +1,41 @@
export default function sortTags(tags) {
return tags.slice(0).sort((a, b) => {
const aPos = a.position();
const bPos = b.position();
// If they're both secondary tags, sort them by their discussions count,
// descending.
if (aPos === null && bPos === null)
return b.discussionCount() - a.discussionCount();
// If just one is a secondary tag, then the primary tag should
// come first.
if (bPos === null) return -1;
if (aPos === null) return 1;
// If we've made it this far, we know they're both primary tags. So we'll
// need to see if they have parents.
const aParent = a.parent();
const bParent = b.parent();
// If they both have the same parent, then their positions are local,
// so we can compare them directly.
if (aParent === bParent) return aPos - bPos;
// If they are both child tags, then we will compare the positions of their
// parents.
else if (aParent && bParent)
return aParent.position() - bParent.position();
// If we are comparing a child tag with its parent, then we let the parent
// come first. If we are comparing an unrelated parent/child, then we
// compare both of the parents.
else if (aParent)
return aParent === b ? 1 : aParent.position() - bPos;
else if (bParent)
return bParent === a ? -1 : aPos - bParent.position();
return 0;
});
}

View File

@ -0,0 +1,86 @@
import { extend, override } from 'flarum/extend';
import IndexPage from 'flarum/components/IndexPage';
import DiscussionComposer from 'flarum/components/DiscussionComposer';
import classList from 'flarum/utils/classList';
import TagDiscussionModal from './components/TagDiscussionModal';
import tagsLabel from '../common/helpers/tagsLabel';
import getSelectableTags from './utils/getSelectableTags';
export default function () {
extend(IndexPage.prototype, 'newDiscussionAction', function (promise) {
// From `addTagFilter
const tag = this.currentTag();
if (tag) {
const parent = tag.parent();
const tags = parent ? [parent, tag] : [tag];
promise.then(composer => composer.fields.tags = tags);
} else {
app.composer.fields.tags = [];
}
});
extend(DiscussionComposer.prototype, 'oninit', function () {
app.tagList.load(['parent']).then(() => m.redraw())
});
// Add tag-selection abilities to the discussion composer.
DiscussionComposer.prototype.chooseTags = function () {
const selectableTags = getSelectableTags();
if (!selectableTags.length) return;
app.modal.show(TagDiscussionModal, {
selectedTags: (this.composer.fields.tags || []).slice(0),
onsubmit: tags => {
this.composer.fields.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) {
const tags = this.composer.fields.tags || [];
const selectableTags = getSelectableTags();
items.add('tags', (
<a className={classList(['DiscussionComposer-changeTags', !selectableTags.length && 'disabled'])} onclick={this.chooseTags.bind(this)}>
{tags.length
? tagsLabel(tags)
: <span className="TagLabel untagged">{app.translator.trans('flarum-tags.forum.composer_discussion.choose_tags_link')}</span>}
</a>
), 10);
});
override(DiscussionComposer.prototype, 'onsubmit', function (original) {
const chosenTags = this.composer.fields.tags || [];
const chosenPrimaryTags = chosenTags.filter(tag => tag.position() !== null && !tag.isChild());
const chosenSecondaryTags = chosenTags.filter(tag => tag.position() === null);
const selectableTags = getSelectableTags();
if ((!chosenTags.length
|| (chosenPrimaryTags.length < app.forum.attribute('minPrimaryTags'))
|| (chosenSecondaryTags.length < app.forum.attribute('minSecondaryTags'))
) && selectableTags.length) {
app.modal.show(TagDiscussionModal, {
selectedTags: chosenTags,
onsubmit: tags => {
this.composer.fields.tags = tags;
original();
}
});
} else {
original();
}
});
// 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.composer.fields.tags;
});
}

View File

@ -0,0 +1,16 @@
import { extend } from 'flarum/extend';
import DiscussionControls from 'flarum/utils/DiscussionControls';
import Button from 'flarum/components/Button';
import TagDiscussionModal from './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 icon="fas fa-tag" onclick={() => app.modal.show(TagDiscussionModal, { discussion })}>
{app.translator.trans('flarum-tags.forum.discussion_controls.edit_tags_button')}
</Button>);
}
});
}

View File

@ -0,0 +1,112 @@
import { extend, override } from 'flarum/extend';
import IndexPage from 'flarum/components/IndexPage';
import DiscussionListState from 'flarum/states/DiscussionListState';
import GlobalSearchState from 'flarum/states/GlobalSearchState';
import classList from 'flarum/utils/classList';
import TagHero from './components/TagHero';
const findTag = slug => app.store.all('tags').find(tag => tag.slug().localeCompare(slug, undefined, { sensitivity: 'base' }) === 0);
export default function() {
IndexPage.prototype.currentTag = function() {
if (this.currentActiveTag) {
return this.currentActiveTag;
}
const slug = app.search.params().tags;
let tag = null;
if (slug) {
tag = findTag(slug);
}
if (slug && !tag || (tag && !tag.isChild() && !tag.children())) {
if (this.currentTagLoading) {
return;
}
this.currentTagLoading = true;
// Unlike the backend, no need to fetch parent.children because if we're on
// a child tag page, then either:
// - We loaded in that child tag (and its siblings) in the API document
// - We first navigated to the current tag's parent, which would have loaded in the current tag's siblings.
app.store.find('tags', slug, { include: 'children,children.parent,parent,state'}).then(() => {
this.currentActiveTag = findTag(slug);
m.redraw();
}).finally(() => {
this.currentTagLoading = false;
});
}
if (tag) {
this.currentActiveTag = tag;
return this.currentActiveTag;
}
};
// 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 model={tag} />;
return original();
});
extend(IndexPage.prototype, 'view', function(vdom) {
const tag = this.currentTag();
if (tag) vdom.attrs.className += ' IndexPage--tag'+tag.id();
});
extend(IndexPage.prototype, 'setTitle', function() {
const tag = this.currentTag();
if (tag) {
app.setTitle(tag.name());
}
});
// If currently viewing a tag, restyle the 'new discussion' button to use
// the tag's color, and disable if the user isn't allowed to edit.
extend(IndexPage.prototype, 'sidebarItems', function(items) {
const tag = this.currentTag();
if (tag) {
const color = tag.color();
const canStartDiscussion = tag.canStartDiscussion() || !app.session.user;
const newDiscussion = items.get('newDiscussion');
if (color) {
newDiscussion.attrs.className = classList([newDiscussion.attrs.className, 'Button--tagColored']);
newDiscussion.attrs.style = { '--color': color };
}
newDiscussion.attrs.disabled = !canStartDiscussion;
newDiscussion.children = app.translator.trans(canStartDiscussion ? 'core.forum.index.start_discussion_button' : 'core.forum.index.cannot_start_discussion_button');
}
});
// Add a parameter for the global search state to pass on to the
// DiscussionListState that will let us filter discussions by tag.
extend(GlobalSearchState.prototype, 'params', function(params) {
params.tags = m.route.param('tags');
});
// Translate that parameter into a gambit appended to the search query.
extend(DiscussionListState.prototype, 'requestParams', function(params) {
params.include.push('tags', 'tags.parent');
if (this.params.tags) {
params.filter.tag = this.params.tags;
// TODO: replace this with a more robust system.
const q = params.filter.q;
if (q) {
params.filter.q = `${q} tag:${this.params.tags}`;
}
}
});
}

View File

@ -0,0 +1,40 @@
import { extend } from 'flarum/extend';
import DiscussionListItem from 'flarum/components/DiscussionListItem';
import DiscussionHero from 'flarum/components/DiscussionHero';
import tagsLabel from '../common/helpers/tagsLabel';
import sortTags from '../common/utils/sortTags';
export default function() {
// Add tag labels to each discussion in the discussion list.
extend(DiscussionListItem.prototype, 'infoItems', function(items) {
const tags = this.attrs.discussion.tags();
if (tags && tags.length) {
items.add('tags', tagsLabel(tags), 10);
}
});
// Restyle a discussion's hero to use its first tag's color.
extend(DiscussionHero.prototype, 'view', function(view) {
const tags = sortTags(this.attrs.discussion.tags());
if (tags && tags.length) {
const color = tags[0].color();
if (color) {
view.attrs.style = { '--hero-bg': 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.attrs.discussion.tags();
if (tags && tags.length) {
items.add('tags', tagsLabel(tags, {link: true}), 5);
}
});
}

View File

@ -0,0 +1,58 @@
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 './components/TagLinkButton';
import TagsPage from './components/TagsPage';
import sortTags from '../common/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 icon="fas fa-th-large" href={app.route('tags')}>
{app.translator.trans('flarum-tags.forum.index.tags_link')}
</LinkButton>
, -10);
if (app.current.matches(TagsPage)) return;
items.add('separator', Separator.component(), -12);
const params = app.search.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;
}
// tag.name() is passed here as children even though it isn't used directly
// because when we need to get the active child in SelectDropdown, we need to
// use its children to populate the dropdown. The problem here is that `view`
// on TagLinkButton is only called AFTER SelectDropdown, so no children are available
// for SelectDropdown to use at the time.
items.add('tag' + tag.id(), TagLinkButton.component({model: tag, params, active}, tag?.name()), -14);
};
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.discussionCount() - a.discussionCount());
more.splice(0, 3).forEach(addTag);
if (more.length) {
items.add('moreTags', <LinkButton href={app.route('tags')}>
{app.translator.trans('flarum-tags.forum.index.more_link')}
</LinkButton>, -16)
}
});
}

View File

@ -0,0 +1,27 @@
import compat from '../common/compat';
import addTagFilter from './addTagFilter';
import addTagControl from './addTagControl';
import TagHero from './components/TagHero';
import TagDiscussionModal from './components/TagDiscussionModal';
import TagsPage from './components/TagsPage';
import DiscussionTaggedPost from './components/DiscussionTaggedPost';
import TagLinkButton from './components/TagLinkButton';
import addTagList from './addTagList';
import addTagLabels from './addTagLabels';
import addTagComposer from './addTagComposer';
import getSelectableTags from './utils/getSelectableTags';
export default Object.assign(compat, {
'tags/addTagFilter': addTagFilter,
'tags/addTagControl': addTagControl,
'tags/components/TagHero': TagHero,
'tags/components/TagDiscussionModal': TagDiscussionModal,
'tags/components/TagsPage': TagsPage,
'tags/components/DiscussionTaggedPost': DiscussionTaggedPost,
'tags/components/TagLinkButton': TagLinkButton,
'tags/addTagList': addTagList,
'tags/addTagLabels': addTagLabels,
'tags/addTagComposer': addTagComposer,
'tags/utils/getSelectableTags': getSelectableTags,
});

View File

@ -0,0 +1,56 @@
import EventPost from 'flarum/components/EventPost';
import tagsLabel from '../../common/helpers/tagsLabel';
export default class DiscussionTaggedPost extends EventPost {
static initAttrs(attrs) {
super.initAttrs(attrs);
const oldTags = attrs.post.content()[0];
const newTags = attrs.post.content()[1];
function diffTags(tags1, tags2) {
return tags1
.filter(tag => tags2.indexOf(tag) === -1)
.map(id => app.store.getById('tags', id));
}
attrs.tagsAdded = diffTags(newTags, oldTags);
attrs.tagsRemoved = diffTags(oldTags, newTags);
}
icon() {
return 'fas fa-tag';
}
descriptionKey() {
if (this.attrs.tagsAdded.length) {
if (this.attrs.tagsRemoved.length) {
return 'flarum-tags.forum.post_stream.added_and_removed_tags_text';
}
return 'flarum-tags.forum.post_stream.added_tags_text';
}
return 'flarum-tags.forum.post_stream.removed_tags_text';
}
descriptionData() {
const data = {};
if (this.attrs.tagsAdded.length) {
data.tagsAdded = app.translator.trans('flarum-tags.forum.post_stream.tags_text', {
tags: tagsLabel(this.attrs.tagsAdded, {link: true}),
count: this.attrs.tagsAdded.length
});
}
if (this.attrs.tagsRemoved.length) {
data.tagsRemoved = app.translator.trans('flarum-tags.forum.post_stream.tags_text', {
tags: tagsLabel(this.attrs.tagsRemoved, {link: true}),
count: this.attrs.tagsRemoved.length
});
}
return data;
}
}

View File

@ -0,0 +1,348 @@
import Modal from 'flarum/components/Modal';
import DiscussionPage from 'flarum/components/DiscussionPage';
import Button from 'flarum/components/Button';
import LoadingIndicator from 'flarum/components/LoadingIndicator';
import highlight from 'flarum/helpers/highlight';
import classList from 'flarum/utils/classList';
import extractText from 'flarum/utils/extractText';
import KeyboardNavigatable from 'flarum/utils/KeyboardNavigatable';
import Stream from 'flarum/utils/Stream';
import tagLabel from '../../common/helpers/tagLabel';
import tagIcon from '../../common/helpers/tagIcon';
import sortTags from '../../common/utils/sortTags';
import getSelectableTags from '../utils/getSelectableTags';
import ToggleButton from './ToggleButton';
export default class TagDiscussionModal extends Modal {
oninit(vnode) {
super.oninit(vnode);
this.tagsLoading = true;
this.selected = [];
this.filter = Stream('');
this.focused = false;
this.minPrimary = app.forum.attribute('minPrimaryTags');
this.maxPrimary = app.forum.attribute('maxPrimaryTags');
this.minSecondary = app.forum.attribute('minSecondaryTags');
this.maxSecondary = app.forum.attribute('maxSecondaryTags');
this.bypassReqs = false;
this.navigator = new KeyboardNavigatable();
this.navigator
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
.onSelect(this.select.bind(this))
.onRemove(() => this.selected.splice(this.selected.length - 1, 1));
app.tagList.load(['parent']).then(() => {
this.tagsLoading = false;
this.tags = sortTags(getSelectableTags(this.attrs.discussion));
if (this.attrs.selectedTags) {
this.attrs.selectedTags.map(this.addTag.bind(this));
} else if (this.attrs.discussion) {
this.attrs.discussion.tags().map(this.addTag.bind(this));
}
this.index = this.tags[0].id();
m.redraw();
});
}
primaryCount() {
return this.selected.filter(tag => tag.isPrimary()).length;
}
secondaryCount() {
return this.selected.filter(tag => !tag.isPrimary()).length;
}
/**
* 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 && !this.selected.includes(parent)) {
this.selected.push(parent);
}
if (!this.selected.includes(tag)) {
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() === tag)
.forEach(this.removeTag.bind(this));
}
}
className() {
return 'TagDiscussionModal';
}
title() {
return this.attrs.discussion
? app.translator.trans('flarum-tags.forum.choose_tags.edit_title', {title: <em>{this.attrs.discussion.title()}</em>})
: app.translator.trans('flarum-tags.forum.choose_tags.title');
}
getInstruction(primaryCount, secondaryCount) {
if (this.bypassReqs) {
return '';
}
if (primaryCount < this.minPrimary) {
const remaining = this.minPrimary - primaryCount;
return app.translator.trans('flarum-tags.forum.choose_tags.choose_primary_placeholder', {count: remaining});
} else if (secondaryCount < this.minSecondary) {
const remaining = this.minSecondary - secondaryCount;
return app.translator.trans('flarum-tags.forum.choose_tags.choose_secondary_placeholder', {count: remaining});
}
return '';
}
content() {
if (this.tagsLoading) {
return <LoadingIndicator />;
}
let tags = this.tags;
const filter = this.filter().toLowerCase();
const primaryCount = this.primaryCount();
const secondaryCount = this.secondaryCount();
// Filter out all child tags whose parents have not been selected. This
// makes it impossible to select a child if its parent hasn't been selected.
tags = tags.filter(tag => {
const parent = tag.parent();
return parent === false || this.selected.includes(parent);
});
// If the number of selected primary/secondary tags is at the maximum, then
// we'll filter out all other tags of that type.
if (primaryCount >= this.maxPrimary && !this.bypassReqs) {
tags = tags.filter(tag => !tag.isPrimary() || this.selected.includes(tag));
}
if (secondaryCount >= this.maxSecondary && !this.bypassReqs) {
tags = tags.filter(tag => tag.isPrimary() || this.selected.includes(tag));
}
// If the user has entered text in the filter input, then filter by tags
// whose name matches what they've entered.
if (filter) {
tags = tags.filter(tag => tag.name().substr(0, filter.length).toLowerCase() === filter);
}
if (!tags.includes(this.index)) this.index = tags[0];
const inputWidth = Math.max(extractText(this.getInstruction(primaryCount, secondaryCount)).length, this.filter().length);
return [
<div className="Modal-body">
<div className="TagDiscussionModal-form">
<div className="TagDiscussionModal-form-input">
<div className={'TagsInput FormControl ' + (this.focused ? 'focus' : '')}
onclick={() => this.$('.TagsInput input').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={extractText(this.getInstruction(primaryCount, secondaryCount))}
bidi={this.filter}
style={{ width: inputWidth + 'ch' }}
onkeydown={this.navigator.navigate.bind(this.navigator)}
onfocus={() => this.focused = true}
onblur={() => this.focused = false}/>
</div>
</div>
<div className="TagDiscussionModal-form-submit App-primaryControl">
<Button type="submit" className="Button Button--primary" disabled={!this.meetsRequirements(primaryCount, secondaryCount)} icon="fas fa-check">
{app.translator.trans('flarum-tags.forum.choose_tags.submit_button')}
</Button>
</div>
</div>
</div>,
<div className="Modal-footer">
<ul className="TagDiscussionModal-list SelectTagList">
{tags
.filter(tag => filter || !tag.parent() || this.selected.includes(tag.parent()))
.map(tag => (
<li data-index={tag.id()}
className={classList({
pinned: tag.position() !== null,
child: !!tag.parent(),
colored: !!tag.color(),
selected: this.selected.includes(tag),
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>
{!!app.forum.attribute('canBypassTagCounts') && (
<div className="TagDiscussionModal-controls">
<ToggleButton className="Button" onclick={() => this.bypassReqs = !this.bypassReqs} isToggled={this.bypassReqs}>
{app.translator.trans('flarum-tags.forum.choose_tags.bypass_requirements')}
</ToggleButton>
</div>
)}
</div>
];
}
meetsRequirements(primaryCount, secondaryCount) {
if (this.bypassReqs) {
return true;
}
return primaryCount >= this.minPrimary && secondaryCount >= this.minSecondary;
}
toggleTag(tag) {
if (this.selected.includes(tag)) {
this.removeTag(tag);
} else {
this.addTag(tag);
}
if (this.filter()) {
this.filter('');
this.index = this.tags[0];
}
this.onready();
}
select(e) {
// Ctrl + Enter submits the selection, just Enter completes the current entry
if (e.metaKey || e.ctrlKey || this.selected.includes(this.index)) {
if (this.selected.length) {
// The DOM submit method doesn't emit a `submit event, so we
// simulate a manual submission so our `onsubmit` logic is run.
this.$('button[type="submit"]').click();
}
} else {
this.getItem(this.index)[0].dispatchEvent(new Event('click'));
}
}
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.attrs.discussion;
const tags = this.selected;
if (discussion) {
discussion.save({relationships: {tags}})
.then(() => {
if (app.current.matches(DiscussionPage)) {
app.current.get('stream').update();
}
m.redraw();
});
}
if (this.attrs.onsubmit) this.attrs.onsubmit(tags);
this.hide();
}
}

View File

@ -0,0 +1,21 @@
import Component from 'flarum/Component';
import tagIcon from '../../common/helpers/tagIcon';
export default class TagHero extends Component {
view() {
const tag = this.attrs.model;
const color = tag.color();
return (
<header className={'Hero TagHero' + (color ? ' TagHero--colored' : '')}
style={color ? { '--hero-bg': color } : ''}>
<div className="container">
<div className="containerNarrow">
<h2 className="Hero-title">{tag.icon() && tagIcon(tag, {}, { useColor: false })} {tag.name()}</h2>
<div className="Hero-subtitle">{tag.description()}</div>
</div>
</div>
</header>
);
}
}

View File

@ -0,0 +1,38 @@
import Link from 'flarum/components/Link';
import LinkButton from 'flarum/components/LinkButton';
import classList from 'flarum/utils/classList';
import tagIcon from '../../common/helpers/tagIcon';
export default class TagLinkButton extends LinkButton {
view(vnode) {
const tag = this.attrs.model;
const active = this.constructor.isActive(this.attrs);
const description = tag && tag.description();
const className = classList([
'TagLinkButton',
'hasIcon',
this.attrs.className,
tag.isChild() && 'child',
]);
return (
<Link className={className} href={this.attrs.route}
style={tag ? { '--color': tag.color() } : ''}
title={description || ''}>
{tagIcon(tag, { className: 'Button-icon' })}
<span className="Button-label">
{tag ? tag.name() : app.translator.trans('flarum-tags.forum.index.untagged_link')}
</span>
</Link>
);
}
static initAttrs(attrs) {
super.initAttrs(attrs);
const tag = attrs.model;
attrs.params.tags = tag ? tag.slug() : 'untagged';
attrs.route = app.route('tag', attrs.params);
}
}

View File

@ -0,0 +1,115 @@
import Page from 'flarum/components/Page';
import IndexPage from 'flarum/components/IndexPage';
import Link from 'flarum/components/Link';
import LoadingIndicator from 'flarum/components/LoadingIndicator';
import listItems from 'flarum/helpers/listItems';
import humanTime from 'flarum/helpers/humanTime';
import tagIcon from '../../common/helpers/tagIcon';
import tagLabel from '../../common/helpers/tagLabel';
import sortTags from '../../common/utils/sortTags';
export default class TagsPage extends Page {
oninit(vnode) {
super.oninit(vnode);
app.history.push('tags', app.translator.trans('flarum-tags.forum.header.back_to_tags_tooltip'));
this.tags = [];
const preloaded = app.preloadedApiDocument();
if (preloaded) {
this.tags = sortTags(preloaded.filter(tag => !tag.isChild()));
return;
}
this.loading = true;
app.tagList.load(['children', 'lastPostedDiscussion', 'parent']).then(() => {
this.tags = sortTags(app.store.all('tags').filter(tag => !tag.isChild()));
this.loading = false;
m.redraw();
});
}
view() {
if (this.loading) {
return <LoadingIndicator />;
}
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">
<ul>{listItems(IndexPage.prototype.sidebarItems().toArray())}</ul>
</nav>
<div className="TagsPage-content sideNavOffset">
<ul className="TagTiles">
{pinned.map(tag => {
const lastPostedDiscussion = tag.lastPostedDiscussion();
const children = sortTags(tag.children() || []);
return (
<li className={'TagTile ' + (tag.color() ? 'colored' : '')}
style={{ '--tag-bg': tag.color() }}>
<Link className="TagTile-info" href={app.route.tag(tag)}>
{tag.icon() && tagIcon(tag, {}, { useColor: false })}
<h3 className="TagTile-name">{tag.name()}</h3>
<p className="TagTile-description">{tag.description()}</p>
{children
? (
<div className="TagTile-children">
{children.map(child => [
<Link href={app.route.tag(child)}>
{child.name()}
</Link>,
' '
])}
</div>
) : ''}
</Link>
{lastPostedDiscussion
? (
<Link className="TagTile-lastPostedDiscussion"
href={app.route.discussion(lastPostedDiscussion, lastPostedDiscussion.lastPostNumber())}
>
<span className="TagTile-lastPostedDiscussion-title">{lastPostedDiscussion.title()}</span>
{humanTime(lastPostedDiscussion.lastPostedAt())}
</Link>
) : (
<span className="TagTile-lastPostedDiscussion"/>
)}
</li>
);
})}
</ul>
{cloud.length ? (
<div className="TagCloud">
{cloud.map(tag => [
tagLabel(tag, {link: true}),
' ',
])}
</div>
) : ''}
</div>
</div>
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
app.setTitle(app.translator.trans('flarum-tags.forum.all_tags.meta_title_text'));
app.setTitleCount(0);
}
}

View File

@ -0,0 +1,19 @@
import Component from 'flarum/common/Component';
import Button from 'flarum/common/components/Button';
import classList from 'flarum/common/utils/classList';
/**
* @TODO move to core
*/
export default class ToggleButton extends Component {
view(vnode) {
const { className, isToggled, ...attrs } = this.attrs;
const icon = isToggled ? 'far fa-check-circle' : 'far fa-circle';
return (
<Button {...attrs} icon={icon} className={classList([className, isToggled && 'Button--toggled'])}>
{vnode.children}
</Button>
);
}
}

View File

@ -0,0 +1,44 @@
import Model from 'flarum/Model';
import Discussion from 'flarum/models/Discussion';
import IndexPage from 'flarum/components/IndexPage';
import Tag from '../common/models/Tag';
import TagsPage from './components/TagsPage';
import DiscussionTaggedPost from './components/DiscussionTaggedPost';
import TagListState from './states/TagListState';
import addTagList from './addTagList';
import addTagFilter from './addTagFilter';
import addTagLabels from './addTagLabels';
import addTagControl from './addTagControl';
import addTagComposer from './addTagComposer';
app.initializers.add('flarum-tags', function(app) {
app.routes.tags = {path: '/tags', component: TagsPage };
app.routes.tag = {path: '/t/:tags', component: IndexPage };
app.route.tag = tag => app.route('tag', {tags: tag.slug()});
app.postComponents.discussionTagged = DiscussionTaggedPost;
app.store.models.tags = Tag;
app.tagList = new TagListState();
Discussion.prototype.tags = Model.hasMany('tags');
Discussion.prototype.canTag = Model.attribute('canTag');
addTagList();
addTagFilter();
addTagLabels();
addTagControl();
addTagComposer();
});
// Expose compat API
import tagsCompat from './compat';
import { compat } from '@flarum/core/forum';
Object.assign(compat, tagsCompat);

View File

@ -0,0 +1,20 @@
export default class TagListState {
constructor() {
this.loadedIncludes = new Set();
}
async load(includes = []) {
const unloadedIncludes = includes.filter(include => !this.loadedIncludes.has(include));
if (unloadedIncludes.length === 0) {
return Promise.resolve(app.store.all('tags'));
}
return app.store
.find('tags', { include: unloadedIncludes.join(',') })
.then(val => {
unloadedIncludes.forEach(include => this.loadedIncludes.add(include));
return val;
});
}
}

View File

@ -0,0 +1,11 @@
export default function getSelectableTags(discussion) {
let tags = app.store.all('tags');
if (discussion) {
tags = tags.filter(tag => tag.canAddToDiscussion() || discussion.tags().indexOf(tag) !== -1);
} else {
tags = tags.filter(tag => tag.canStartDiscussion());
}
return tags;
}

View File

@ -0,0 +1 @@
module.exports = require('flarum-webpack-config')();

2279
extensions/tags/js/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
@import "common/common";
@import "admin/TagsPage";
@import "admin/EditTagModal";
.Dropdown--restrictByTag .Dropdown-menu {
max-height: 400px;
overflow: auto;
}

View File

@ -0,0 +1,8 @@
.EditTagModal {
.Form-group:not(:last-child) {
margin-bottom: 30px;
}
}
.EditTagModal-delete {
float: right;
}

View File

@ -0,0 +1,156 @@
.flarum-tags-Page {
padding-bottom: 140px;
}
.TagsContent-footer {
color: @control-color;
padding: 20px 0;
p {
margin-top: 10px;
}
}
.TagsContent-list {
padding: 20px 0 0;
}
.TagList,
.TagList ol {
list-style: none;
padding: 0;
color: @muted-color;
font-size: 13px;
>li {
display: inline-block;
max-height: 40px;
cursor: move;
width: 100%;
}
.TagIcon,
.icon {
margin-right: 10px;
}
}
.TagListItem-info {
border-radius: @border-radius;
padding: 5px;
&:hover {
background: @control-bg;
}
.Button {
float: right;
visibility: hidden;
margin: -8px -16px -8px 16px;
}
}
li:not(.sortable-dragging)>.TagListItem-info:hover>.Button {
visibility: visible;
}
.TagList--primary {
font-size: 16px;
>.sortable-placeholder {
height: 38px;
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: 34px;
}
.SettingsGroups {
display: flex;
column-count: 3;
column-gap: 30px;
flex-wrap: wrap;
@media (@tablet-up) {
.TagGroup--secondary {
max-width: 250px !important;
}
}
.Form {
min-width: 300px;
max-height: 500px;
>label {
margin-bottom: 10px;
}
.TagSettings-rangeInput {
input {
width: 80px;
display: inline;
margin: 0 5px;
&:first-child {
margin-left: 0;
}
}
}
}
.TagGroup,
.Form {
display: inline-grid;
padding: 10px 20px;
min-height: 20vh;
max-width: 400px;
grid-template-rows: min-content;
border: 1px solid @control-bg;
border-radius: @border-radius;
flex: 1 1 160px;
@media (max-width: 1209px) {
margin-bottom: 20px;
}
>ol {
>li {
margin-top: 8px;
.Button {
float: right;
visibility: hidden;
margin: -8px -16px -8px 16px;
}
}
}
.TagList-button {
background: none;
border: 1px dashed @control-bg;
height: 40px;
margin: auto auto 0 0;
}
>label {
float: left;
font-weight: bold;
color: @muted-color;
}
}
}

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: var(--color, @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: var(--tag-bg);
color: var(--tag-color);
text-transform: none;
vertical-align: bottom;
&.untagged {
--tag-bg: transparent;
--tag-color: @muted-color;
border: 1px dotted;
}
&.colored {
--tag-color: @body-bg;
.TagLabel-text {
color: var(--tag-color) !important;
}
}
.DiscussionHero .TagsLabel & {
background: transparent;
border-radius: @border-radius !important;
font-size: 14px;
&.colored {
--tag-color: var(--tag-bg);
margin-right: 5px;
background: @body-bg !important;
}
}
}
.DiscussionHero--colored {
&, a {
color: @body-bg;
}
}
.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,3 @@
@import "root";
@import "TagLabel";
@import "TagIcon";

View File

@ -0,0 +1,4 @@
:root {
--tag-bg: @control-bg;
--tag-color: @control-color;
}

View File

@ -0,0 +1,94 @@
@import "common/common";
@import "forum/TagCloud";
@import "forum/TagDiscussionModal";
@import "forum/TagHero";
@import "forum/TagTiles";
@import "forum/ToggleButton";
.Button--tagColored {
--button-primary-bg: var(--color);
--button-primary-bg-hover: var(--color);
--button-primary-bg-active: var(--color);
}
.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;
}
}
.active > & {
--sidenav-color-active: var(--color);
}
}
.DiscussionComposer-changeTags {
margin-right: 15px;
vertical-align: 2px;
&.disabled {
opacity: 0.5;
cursor: default;
}
}
.DiscussionListItem-info > .item-tags {
margin-right: 4px;
opacity: 1;
}
@media @tablet-up {
.IndexPage, .UserPage {
.DiscussionListItem-title {
margin-right: 155px;
}
.DiscussionListItem-info > .item-tags {
margin-right: 0;
position: absolute;
right: 80px;
top: 14px;
max-width: 150px;
white-space: nowrap;
overflow: hidden;
transition: max-width 0.2s ease-in-out, -webkit-mask-image 0.2s;
-webkit-mask-image: linear-gradient(to right, rgba(0,0,0,1) 140px, rgba(0,0,0,0) 150px);
font-size: 12px;
&:hover {
max-width: 400px;
-webkit-mask-image: none;
}
}
}
}
@media @desktop-up {
.TagsPage {
.sideNav {
.sideNav--horizontal();
float: none;
width: auto;
padding-top: 0;
&:after {
display: none;
}
> ul > li:first-child {
width: 190px;
}
}
.sideNavOffset {
margin: 15px 0 0;
}
}
}

View File

@ -0,0 +1,10 @@
.TagCloud {
margin-top: 30px;
text-align: center;
font-size: 16px;
line-height: 1.6;
a {
margin-bottom: 5px;
}
}

View File

@ -0,0 +1,175 @@
.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;
}
&-controls {
padding: 20px;
}
}
@media @tablet, @desktop, @desktop-hd {
.TagDiscussionModal-form {
display: table;
width: 100%;
}
.TagDiscussionModal-form-input {
display: table-cell;
width: 100%;
vertical-align: top;
}
.TagDiscussionModal-form-submit {
display: table-cell;
padding-left: 15px;
}
}
.TagsInput {
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
height: auto;
cursor: text;
input {
display: inline;
outline: none;
margin-top: -2px;
border: 0 !important;
padding: 0;
max-width: 100%;
margin-right: -100%;
background: transparent !important;
}
}
.TagsInput-tag {
cursor: not-allowed;
}
.TagsInput-selected {
.TagsInput-tag {
margin-right: 5px;
&:last-child {
margin-right: 10px;
}
}
}
.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;
}
}
&.pinned + li:not(.pinned) {
border-top: 2px solid @control-bg;
}
&.child {
padding-left: 45px;
.SelectTagListItem-name {
width: 125px;
}
}
&.active {
background: @control-bg;
}
.icon::before {
display: inline-block;
width: 16px;
text-align: center;
vertical-align: middle;
}
&.selected {
.icon::before {
.fa();
content: @fa-var-check !important;
color: @muted-color;
font-size: 14px;
text-align: center;
vertical-align: 1px;
}
&.colored .TagIcon:before {
color: #fff;
}
}
.TagIcon {
vertical-align: top;
margin-top: 3px;
margin-left: 0;
}
}
}
.SelectTagListItem-name {
display: inline-block;
width: 150px;
margin-right: 10px;
margin-left: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
@media @phone {
width: auto;
}
}
.SelectTagListItem-description {
color: @muted-color;
font-size: 12px;
width: 370px;
display: inline-block;
vertical-align: top;
margin-top: 2px;
@media @phone {
display: none;
}
}
.SelectTagListItem mark {
font-weight: bold;
background: none;
box-shadow: none;
color: inherit;
}

View File

@ -0,0 +1,5 @@
.TagHero {
&--colored {
--hero-color: #fff;
}
}

View File

@ -0,0 +1,144 @@
.TagTiles {
list-style-type: none;
padding: 0;
margin: 0;
overflow: hidden;
@media @phone {
margin: -15px -15px 0;
}
> li {
height: 200px;
overflow: hidden;
@media @tablet {
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;
}
}
@media @desktop-up {
float: left;
width: 33.333%;
&:first-child {
border-top-left-radius: @border-radius;
}
&:nth-child(3),
&:nth-child(2):last-child,
&:first-child:last-child {
border-top-right-radius: @border-radius;
}
&:nth-child(3n):nth-last-child(2),
&:nth-child(3n):nth-last-child(3),
&:last-child {
border-bottom-right-radius: @border-radius;
}
&:nth-child(3n+1):last-child,
&:nth-child(3n+1):nth-last-child(2),
&:nth-child(3n+1):nth-last-child(3) {
border-bottom-left-radius: @border-radius;
}
}
}
}
.TagTile {
position: relative;
background: var(--tag-bg);
&, a {
color: @control-color;
}
&.colored {
&, a {
color: @body-bg;
}
}
}
.TagTile-info, .TagTile-lastPostedDiscussion {
padding: 20px;
text-decoration: none !important;
display: block;
position: absolute;
left: 0;
right: 0;
}
.TagTile-info {
top: 0;
bottom: 42px;
padding-right: 20px;
transition: background 0.2s;
overflow: auto;
&:hover {
background: fade(#000, 5%);
}
.icon {
font-size: 24px;
float: left;
margin-right: 10px;
}
}
.TagTile-name {
font-size: 18px;
margin: 0 0 10px;
font-weight: bold;
}
.TagTile-description {
font-size: 12px;
margin: 0 0 10px;
.TagTile.colored & {
color: fade(@body-bg, 70%);
}
}
.TagTile-children {
font-weight: bold;
font-size: 12px;
a {
margin-right: 10px;
}
}
.TagTile-lastPostedDiscussion {
bottom: 0;
height: 42px;
padding: 7px 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 21px;
font-size: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.15);
margin: 0 20px;
.TagTile.colored & {
color: fade(@body-bg, 70%);
}
&:hover .TagTile-lastPostedDiscussion-title {
text-decoration: underline;
}
time {
text-transform: uppercase;
font-size: 11px;
font-weight: bold;
}
}
.TagTile-lastPostedDiscussion-title {
margin-right: 10px;
}

View File

@ -0,0 +1,7 @@
:root {
.Button--color-vars(@control-bg, @control-color, 'button-toggled');
}
.Button--toggled {
.Button--color-auto('button-toggled');
}

View File

@ -0,0 +1,117 @@
flarum-tags:
##
# UNIQUE KEYS - The following keys are used in only one location each.
##
# Translations in this namespace are used by the admin interface.
admin:
# These translations are used in the Basics page.
basics:
tags_label: => flarum-tags.ref.tags
# These translations are used in the Edit Tag modal dialog.
edit_tag:
color_label: => core.ref.color
delete_tag_button: Delete Tag
delete_tag_confirmation: "Are you sure you want to delete this tag? The tag's discussions will NOT be deleted."
description_label: Description
hide_label: Hide from All Discussions
icon_label: => core.ref.icon
icon_text: => core.ref.icon_text
name_label: => flarum-tags.ref.name
name_placeholder: => flarum-tags.ref.name
slug_label: Slug
submit_button: => core.ref.save_changes
title: Create Tag
# These translations are used in the navigation bar.
nav:
tags_button: => flarum-tags.ref.tags
tags_text: Manage the list of tags available to organise discussions with.
# These translations are used in the Permissions page of the admin interface.
permissions:
allow_edit_tags_label: Allow tag editing
bypass_tag_counts_label: Bypass tag requirements
restrict_by_tag_heading: Restrict by Tag
tag_discussions_label: Tag discussions
# These translations are used in the Tag Settings modal dialog.
tag_settings:
range_separator_text: " to "
required_primary_heading: Required Number of Primary Tags
required_primary_text: Enter the minimum and maximum number of primary tags that may be applied to a discussion.
required_secondary_heading: Required Number of Secondary Tags
required_secondary_text: Enter the minimum and maximum number of secondary tags that may be applied to a discussion.
title: Tag Settings
# These translations are used in the Tags page.
tags:
about_tags_text: "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."
create_primary_tag_button: Create Primary Tag
create_secondary_tag_button: Create Secondary Tag
primary_heading: Primary Tags
secondary_heading: Secondary Tags
settings_heading: Settings
# Translations in this namespace are used by the forum user interface.
forum:
# These translations are displayed on the page that lists all tags.
all_tags:
meta_description_text: All Tags
meta_title_text: => flarum-tags.ref.tags
# These translations are used by the Choose Tags modal dialog.
choose_tags:
bypass_requirements: Bypass tag requirements
choose_primary_placeholder: "{count, plural, one {Choose a primary tag} other {Choose # primary tags}}"
choose_secondary_placeholder: "{count, plural, one {Choose 1 more tag} other {Choose # more tags}}"
edit_title: "Edit Tags for {title}"
submit_button: => core.ref.okay
title: Choose Tags for Your Discussion
# These translations are used by the composer when starting a discussion.
composer_discussion:
choose_tags_link: Choose Tags
# These translations are used by the discussion control buttons.
discussion_controls:
edit_tags_button: Edit Tags
header:
back_to_tags_tooltip: Back to Tag List
# These translations are used on the index page, peripheral to the discussion list.
index:
more_link: More...
tags_link: => flarum-tags.ref.tags
untagged_link: Untagged
# These translations are displayed between posts in the post stream.
post_stream:
added_and_removed_tags_text: "{username} added the {tagsAdded} and removed the {tagsRemoved}."
added_tags_text: "{username} added the {tagsAdded}."
removed_tags_text: "{username} removed the {tagsRemoved}."
tags_text: "{count, plural, one {{tags} tag} other {{tags} tags}}"
# These translations are used when visiting a single tag's discussion list.
tag:
meta_description_text: "All discussions with the {tag} tag"
# Translations in this namespace are used by the forum and admin interfaces.
lib:
# This translation is displayed in place of the name of a tag that's been deleted.
deleted_tag_text: Deleted
##
# REUSED TRANSLATIONS - These keys should not be used directly in code!
##
# Translations in this namespace are referenced by two or more unique keys.
ref:
name: Name
tags: Tags

View File

@ -0,0 +1,20 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
use Illuminate\Database\Schema\Blueprint;
return Migration::createTable(
'discussions_tags',
function (Blueprint $table) {
$table->integer('discussion_id')->unsigned();
$table->integer('tag_id')->unsigned();
$table->primary(['discussion_id', 'tag_id']);
}
);

View File

@ -0,0 +1,47 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->create('tags', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 100);
$table->string('slug', 100);
$table->text('description')->nullable();
$table->string('color', 50)->nullable();
$table->string('background_path', 100)->nullable();
$table->string('background_mode', 100)->nullable();
$table->integer('position')->nullable();
$table->integer('parent_id')->unsigned()->nullable();
$table->string('default_sort', 50)->nullable();
$table->boolean('is_restricted')->default(0);
$table->boolean('is_hidden')->default(0);
$table->integer('discussions_count')->unsigned()->default(0);
$table->dateTime('last_time')->nullable();
$table->integer('last_discussion_id')->unsigned()->nullable();
});
$schema->getConnection()->table('tags')->insert([
'name' => 'General',
'slug' => 'general',
'color' => '#888',
'position' => '0'
]);
},
'down' => function (Builder $schema) {
$schema->drop('tags');
}
];

View File

@ -0,0 +1,22 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
use Illuminate\Database\Schema\Blueprint;
return Migration::createTable(
'users_tags',
function (Blueprint $table) {
$table->integer('user_id')->unsigned();
$table->integer('tag_id')->unsigned();
$table->dateTime('read_time')->nullable();
$table->boolean('is_hidden')->default(0);
$table->primary(['user_id', 'tag_id']);
}
);

View File

@ -0,0 +1,17 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
return Migration::addSettings([
'flarum-tags.max_primary_tags' => '1',
'flarum-tags.min_primary_tags' => '1',
'flarum-tags.max_secondary_tags' => '3',
'flarum-tags.min_secondary_tags' => '0',
]);

View File

@ -0,0 +1,27 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('tags', function (Blueprint $table) {
$table->string('slug', 100)->change();
$table->unique('slug');
});
},
'down' => function (Builder $schema) {
$schema->table('tags', function (Blueprint $table) {
$table->string('slug', 255)->change();
$table->dropUnique(['slug']);
});
}
];

View File

@ -0,0 +1,15 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
use Flarum\Group\Group;
return Migration::addPermissions([
'discussion.tag' => Group::MODERATOR_ID
]);

View File

@ -0,0 +1,37 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
$schema->table('tags', function (Blueprint $table) {
$table->renameColumn('discussions_count', 'discussion_count');
$table->renameColumn('last_time', 'last_posted_at');
$table->renameColumn('last_discussion_id', 'last_posted_discussion_id');
$table->integer('parent_id')->unsigned()->nullable()->change();
$table->integer('last_posted_user_id')->unsigned()->nullable();
});
},
'down' => function (Builder $schema) {
$schema->table('tags', function (Blueprint $table) {
$table->dropColumn('last_posted_user_id');
$table->integer('parent_id')->nullable()->change();
$table->renameColumn('discussion_count', 'discussions_count');
$table->renameColumn('last_posted_at', 'last_time');
$table->renameColumn('last_posted_discussion_id', 'last_discussion_id');
});
}
];

View File

@ -0,0 +1,45 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
// Set non-existent entity IDs to NULL so that we will be able to create
// foreign keys without any issues.
$connection = $schema->getConnection();
$select = function ($id, $table, $column) use ($connection) {
return new Expression(
'('.$connection->table($table)->whereColumn('id', $column)->select($id)->toSql().')'
);
};
$connection->table('tags')->update([
'last_posted_user_id' => $select('last_posted_user_id', 'discussions', 'last_posted_discussion_id'),
'last_posted_discussion_id' => $select('id', 'discussions', 'last_posted_discussion_id'),
]);
$schema->table('tags', function (Blueprint $table) {
$table->foreign('parent_id')->references('id')->on('tags')->onDelete('set null');
$table->foreign('last_posted_user_id')->references('id')->on('users')->onDelete('set null');
$table->foreign('last_posted_discussion_id')->references('id')->on('discussions')->onDelete('set null');
});
},
'down' => function (Builder $schema) {
$schema->table('tags', function (Blueprint $table) {
$table->dropForeign(['parent_id']);
$table->dropForeign(['last_posted_discussion_id']);
$table->dropForeign(['last_posted_user_id']);
});
}
];

View File

@ -0,0 +1,12 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
return Migration::renameTable('users_tags', 'tag_user');

View File

@ -0,0 +1,12 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
return Migration::renameColumn('tag_user', 'read_time', 'marked_as_read_at');

View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
// Delete rows with non-existent entities so that we will be able to create
// foreign keys without any issues.
$schema->getConnection()
->table('tag_user')
->whereNotExists(function ($query) {
$query->selectRaw(1)->from('tags')->whereColumn('id', 'tag_id');
})
->orWhereNotExists(function ($query) {
$query->selectRaw(1)->from('users')->whereColumn('id', 'user_id');
})
->delete();
$schema->table('tag_user', function (Blueprint $table) {
$table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
},
'down' => function (Builder $schema) {
$schema->table('tag_user', function (Blueprint $table) {
$table->dropForeign(['tag_id']);
$table->dropForeign(['user_id']);
});
}
];

View File

@ -0,0 +1,12 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
return Migration::renameTable('discussions_tags', 'discussion_tag');

View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Schema\Builder;
return [
'up' => function (Builder $schema) {
// Delete rows with non-existent entities so that we will be able to create
// foreign keys without any issues.
$schema->getConnection()
->table('discussion_tag')
->whereNotExists(function ($query) {
$query->selectRaw(1)->from('discussions')->whereColumn('id', 'discussion_id');
})
->orWhereNotExists(function ($query) {
$query->selectRaw(1)->from('tags')->whereColumn('id', 'tag_id');
})
->delete();
$schema->table('discussion_tag', function (Blueprint $table) {
$table->foreign('discussion_id')->references('id')->on('discussions')->onDelete('cascade');
$table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
});
},
'down' => function (Builder $schema) {
$schema->table('discussion_tag', function (Blueprint $table) {
$table->dropForeign(['discussion_id']);
$table->dropForeign(['tag_id']);
});
}
];

View File

@ -0,0 +1,14 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
use Flarum\Database\Migration;
return Migration::addColumns('tags', [
'icon' => ['string', 'length' => 100, 'nullable' => true]
]);

View File

@ -0,0 +1,87 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Access;
use Carbon\Carbon;
use Flarum\Discussion\Discussion;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class DiscussionPolicy extends AbstractPolicy
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @param SettingsRepositoryInterface $settings
*/
public function __construct(SettingsRepositoryInterface $settings)
{
$this->settings = $settings;
}
/**
* @param User $actor
* @param string $ability
* @param Discussion $discussion
* @return bool
*/
public function can(User $actor, $ability, Discussion $discussion)
{
// Wrap all discussion permission checks with some logic pertaining to
// the discussion's tags. If the discussion has a tag that has been
// restricted, the user must have the permission for that tag.
$tags = $discussion->tags;
if (count($tags)) {
$restrictedButHasAccess = false;
foreach ($tags as $tag) {
if ($tag->is_restricted) {
if (! $actor->hasPermission('tag'.$tag->id.'.discussion.'.$ability)) {
return $this->deny();
}
$restrictedButHasAccess = true;
}
}
if ($restrictedButHasAccess) {
return $this->allow();
}
}
}
/**
* This method checks, if the user is still allowed to edit the tags
* based on the configuration item.
*
* @param User $actor
* @param Discussion $discussion
* @return bool
*/
public function tag(User $actor, Discussion $discussion)
{
if ($discussion->user_id == $actor->id && $actor->can('reply', $discussion)) {
$allowEditTags = $this->settings->get('allow_tag_change');
if (
$allowEditTags === '-1'
|| ($allowEditTags === 'reply' && $discussion->participant_count <= 1)
|| (is_numeric($allowEditTags) && $discussion->created_at->diffInMinutes(new Carbon) < $allowEditTags)
) {
return $this->allow();
}
}
}
}

View File

@ -0,0 +1,81 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Access;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\Tags\Tag;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class GlobalPolicy extends AbstractPolicy
{
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
public function __construct(SettingsRepositoryInterface $settings)
{
$this->settings = $settings;
}
/**
* @param Flarum\User\User $actor
* @param string $ability
* @return bool|void
*/
public function can(User $actor, string $ability)
{
static $enoughPrimary;
static $enoughSecondary;
if ($ability === 'startDiscussion'
&& $actor->hasPermission($ability)
&& $actor->hasPermission('bypassTagCounts')) {
return $this->allow();
}
if (in_array($ability, ['viewForum', 'startDiscussion'])) {
if (! isset($enoughPrimary[$actor->id][$ability])) {
$primaryTagsWhereNeedsPermission = $this->settings->get('flarum-tags.min_primary_tags');
$primaryTagsWhereHasPermission = Tag::whereHasPermission($actor, $ability)
->where('tags.position', '!=', null)
->count();
if ($ability === 'viewForum') {
$primaryTagsCount = Tag::query()->where('position', '!=', null)->count();
$enoughPrimary[$actor->id][$ability] = $primaryTagsWhereHasPermission >= min($primaryTagsCount, $primaryTagsWhereNeedsPermission);
} else {
$enoughPrimary[$actor->id][$ability] = $primaryTagsWhereHasPermission >= $primaryTagsWhereNeedsPermission;
}
}
if (! isset($enoughSecondary[$actor->id][$ability])) {
$secondaryTagsWhereNeedsPermission = $this->settings->get('flarum-tags.min_secondary_tags');
$secondaryTagsWhereHasPermission = Tag::whereHasPermission($actor, $ability)
->where('tags.position', '=', null)
->count();
if ($ability === 'viewForum') {
$secondaryTagsCount = Tag::query()->where(['position' => null, 'parent_id' => null])->count();
$enoughSecondary[$actor->id][$ability] = $secondaryTagsWhereHasPermission >= min($secondaryTagsCount, $secondaryTagsWhereNeedsPermission);
} else {
$enoughSecondary[$actor->id][$ability] = $secondaryTagsWhereHasPermission >= $secondaryTagsWhereNeedsPermission;
}
}
if ($enoughPrimary[$actor->id][$ability] && $enoughSecondary[$actor->id][$ability]) {
return $this->allow();
} else {
return $this->deny();
}
}
}
}

View File

@ -0,0 +1,68 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Access;
use Flarum\Tags\Tag;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class ScopeDiscussionVisibilityForAbility
{
/**
* @param User $actor
* @param Builder $query
* @param string $ability
*/
public function __invoke(User $actor, Builder $query, $ability)
{
// Automatic scoping should be applied to the global `view` ability,
// and to arbitrary abilities that aren't subqueries of `view`.
// For example, if we want to scope discussions where the user can
// edit posts, this should apply.
// But if we are expanding a restriction of `view` (for example,
// `viewPrivate`), we shouldn't apply this query again.
if (substr($ability, 0, 4) === 'view' && $ability !== 'view') {
return;
}
// Avoid an infinite recursive loop.
if (Str::endsWith($ability, 'InRestrictedTags')) {
return;
}
// `view` is a special case where the permission string is represented by `viewForum`.
$permission = $ability === 'view' ? 'viewForum' : $ability;
// Restrict discussions where users don't have necessary permissions in all tags.
// We use a double notIn instead of a doubleIn because the permission must be present in ALL tags,
// not just one.
$query->where(function ($query) use ($actor, $permission) {
$query
->whereNotIn('discussions.id', function ($query) use ($actor, $permission) {
return $query->select('discussion_id')
->from('discussion_tag')
->whereNotIn('tag_id', function ($query) use ($actor, $permission) {
Tag::query()->setQuery($query->from('tags'))->whereHasPermission($actor, $permission)->select('tags.id');
});
})
->orWhere(function ($query) use ($actor, $permission) {
// Allow extensions a way to override scoping for any given permission.
$query->whereVisibleTo($actor, "${permission}InRestrictedTags");
});
});
// Hide discussions with no tags if the user doesn't have that global
// permission.
if (! $actor->hasPermission($permission)) {
$query->has('tags');
}
}
}

View File

@ -0,0 +1,28 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Access;
use Flarum\Tags\Tag;
use Flarum\User\User;
use Illuminate\Database\Eloquent\Builder;
class ScopeTagVisibility
{
/**
* @param User $actor
* @param Builder $query
*/
public function __invoke(User $actor, Builder $query)
{
$query->whereIn('id', function ($query) use ($actor) {
Tag::query()->setQuery($query->from('tags'))->whereHasPermission($actor, 'viewForum')->select('tags.id');
});
}
}

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Access;
use Flarum\Tags\Tag;
use Flarum\User\Access\AbstractPolicy;
use Flarum\User\User;
class TagPolicy extends AbstractPolicy
{
public function can(User $actor, string $ability, Tag $tag)
{
if ($tag->parent_id !== null && ! $actor->can($ability, $tag->parent)) {
return $this->deny();
}
if ($tag->is_restricted) {
$id = $tag->id;
return $actor->hasPermission("tag$id.$ability");
}
}
public function addToDiscussion(User $actor, Tag $tag)
{
return $actor->can('startDiscussion', $tag);
}
}

View File

@ -0,0 +1,55 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractCreateController;
use Flarum\Http\RequestUtil;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Command\CreateTag;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class CreateTagController extends AbstractCreateController
{
/**
* {@inheritdoc}
*/
public $serializer = TagSerializer::class;
/**
* {@inheritdoc}
*/
public $include = ['parent'];
/**
* @var Dispatcher
*/
protected $bus;
/**
* @param Dispatcher $bus
*/
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
/**
* {@inheritdoc}
*/
protected function data(ServerRequestInterface $request, Document $document)
{
return $this->bus->dispatch(
new CreateTag(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data', []))
);
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractDeleteController;
use Flarum\Http\RequestUtil;
use Flarum\Tags\Command\DeleteTag;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
class DeleteTagController extends AbstractDeleteController
{
/**
* @var Dispatcher
*/
protected $bus;
/**
* @param Dispatcher $bus
*/
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
/**
* {@inheritdoc}
*/
protected function delete(ServerRequestInterface $request)
{
$this->bus->dispatch(
new DeleteTag(Arr::get($request->getQueryParams(), 'id'), RequestUtil::getActor($request))
);
}
}

View File

@ -0,0 +1,70 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractListController;
use Flarum\Http\RequestUtil;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\TagRepository;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ListTagsController extends AbstractListController
{
/**
* {@inheritdoc}
*/
public $serializer = TagSerializer::class;
/**
* {@inheritdoc}
*/
public $include = [
'parent'
];
/**
* {@inheritdoc}
*/
public $optionalInclude = [
'children',
'lastPostedDiscussion',
'state'
];
/**
* @var TagRepository
*/
protected $tags;
public function __construct(TagRepository $tags)
{
$this->tags = $tags;
}
/**
* {@inheritdoc}
*/
protected function data(ServerRequestInterface $request, Document $document)
{
$actor = RequestUtil::getActor($request);
$include = $this->extractInclude($request);
if (in_array('lastPostedDiscussion', $include)) {
$include = array_merge($include, ['lastPostedDiscussion.tags', 'lastPostedDiscussion.state']);
}
return $this->tags
->with($include, $actor)
->whereVisibleTo($actor)
->withStateFor($actor)
->get();
}
}

View File

@ -0,0 +1,57 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Api\Controller;
use Flarum\Http\RequestUtil;
use Flarum\Tags\Tag;
use Illuminate\Support\Arr;
use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class OrderTagsController implements RequestHandlerInterface
{
/**
* {@inheritdoc}
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
RequestUtil::getActor($request)->assertAdmin();
$order = Arr::get($request->getParsedBody(), 'order');
if ($order === null) {
return new EmptyResponse(422);
}
Tag::query()->update([
'position' => null,
'parent_id' => null
]);
foreach ($order as $i => $parent) {
$parentId = Arr::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);
}
}

View File

@ -0,0 +1,59 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractShowController;
use Flarum\Http\RequestUtil;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\TagRepository;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class ShowTagController extends AbstractShowController
{
public $serializer = TagSerializer::class;
public $optionalInclude = [
'children',
'children.parent',
'lastPostedDiscussion',
'parent',
'parent.children',
'parent.children.parent',
'state'
];
/**
* @var TagRepository
*/
private $tags;
public function __construct(TagRepository $tags)
{
$this->tags = $tags;
}
/**
* {@inheritdoc}
*/
protected function data(ServerRequestInterface $request, Document $document)
{
$slug = Arr::get($request->getQueryParams(), 'slug');
$actor = RequestUtil::getActor($request);
$include = $this->extractInclude($request);
return $this->tags
->with($include, $actor)
->whereVisibleTo($actor)
->where('slug', $slug)
->firstOrFail();
}
}

View File

@ -0,0 +1,54 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Api\Controller;
use Flarum\Api\Controller\AbstractShowController;
use Flarum\Http\RequestUtil;
use Flarum\Tags\Api\Serializer\TagSerializer;
use Flarum\Tags\Command\EditTag;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface;
use Tobscure\JsonApi\Document;
class UpdateTagController extends AbstractShowController
{
/**
* {@inheritdoc}
*/
public $serializer = TagSerializer::class;
/**
* @var Dispatcher
*/
protected $bus;
/**
* @param Dispatcher $bus
*/
public function __construct(Dispatcher $bus)
{
$this->bus = $bus;
}
/**
* {@inheritdoc}
*/
protected function data(ServerRequestInterface $request, Document $document)
{
$id = Arr::get($request->getQueryParams(), 'id');
$actor = RequestUtil::getActor($request);
$data = Arr::get($request->getParsedBody(), 'data', []);
return $this->bus->dispatch(
new EditTag($id, $actor, $data)
);
}
}

View File

@ -0,0 +1,72 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Api\Serializer;
use Flarum\Api\Serializer\AbstractSerializer;
use Flarum\Api\Serializer\DiscussionSerializer;
class TagSerializer extends AbstractSerializer
{
/**
* {@inheritdoc}
*/
protected $type = 'tags';
/**
* {@inheritdoc}
*/
protected function getDefaultAttributes($tag)
{
$attributes = [
'name' => $tag->name,
'description' => $tag->description,
'slug' => $tag->slug,
'color' => $tag->color,
'backgroundUrl' => $tag->background_path,
'backgroundMode' => $tag->background_mode,
'icon' => $tag->icon,
'discussionCount' => (int) $tag->discussion_count,
'position' => $tag->position === null ? null : (int) $tag->position,
'defaultSort' => $tag->default_sort,
'isChild' => (bool) $tag->parent_id,
'isHidden' => (bool) $tag->is_hidden,
'lastPostedAt' => $this->formatDate($tag->last_posted_at),
'canStartDiscussion' => $this->actor->can('startDiscussion', $tag),
'canAddToDiscussion' => $this->actor->can('addToDiscussion', $tag)
];
if ($this->actor->isAdmin()) {
$attributes['isRestricted'] = (bool) $tag->is_restricted;
}
return $attributes;
}
/**
* @return \Tobscure\JsonApi\Relationship
*/
protected function parent($tag)
{
return $this->hasOne($tag, self::class);
}
protected function children($tag)
{
return $this->hasMany($tag, self::class);
}
/**
* @return \Tobscure\JsonApi\Relationship
*/
protected function lastPostedDiscussion($tag)
{
return $this->hasOne($tag, DiscussionSerializer::class);
}
}

View File

@ -0,0 +1,39 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Command;
use Flarum\User\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;
}
}

View File

@ -0,0 +1,76 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Command;
use Flarum\Tags\Event\Creating;
use Flarum\Tags\Tag;
use Flarum\Tags\TagValidator;
use Illuminate\Support\Arr;
class CreateTagHandler
{
/**
* @var TagValidator
*/
protected $validator;
/**
* @param TagValidator $validator
*/
public function __construct(TagValidator $validator)
{
$this->validator = $validator;
}
/**
* @param CreateTag $command
* @return Tag
*/
public function handle(CreateTag $command)
{
$actor = $command->actor;
$data = $command->data;
$actor->assertCan('createTag');
$tag = Tag::build(
Arr::get($data, 'attributes.name'),
Arr::get($data, 'attributes.slug'),
Arr::get($data, 'attributes.description'),
Arr::get($data, 'attributes.color'),
Arr::get($data, 'attributes.icon'),
Arr::get($data, 'attributes.isHidden')
);
$parentId = Arr::get($data, 'relationships.parent.data.id');
$primary = Arr::get($data, 'attributes.primary');
if ($parentId !== null || $primary) {
$rootTags = Tag::whereNull('parent_id')->whereNotNull('position');
if ($parentId === 0 || $primary) {
$tag->position = $rootTags->max('position') + 1;
} elseif ($rootTags->find($parentId)) {
$position = Tag::where('parent_id', $parentId)->max('position');
$tag->parent()->associate($parentId);
$tag->position = $position === null ? 0 : $position + 1;
}
}
event(new Creating($tag, $actor, $data));
$this->validator->assertValid($tag->getAttributes());
$tag->save();
return $tag;
}
}

View File

@ -0,0 +1,50 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Command;
use Flarum\User\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;
}
}

View File

@ -0,0 +1,49 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Command;
use Flarum\Tags\Event\Deleting;
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 \Flarum\Tags\Tag
* @throws \Flarum\User\Exception\PermissionDeniedException
*/
public function handle(DeleteTag $command)
{
$actor = $command->actor;
$tag = $this->tags->findOrFail($command->tagId, $actor);
$actor->assertCan('delete', $tag);
event(new Deleting($tag, $actor));
$tag->delete();
return $tag;
}
}

View File

@ -0,0 +1,48 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Command;
use Flarum\User\User;
class EditTag
{
/**
* The ID of the tag to edit.
*
* @var int
*/
public $tagId;
/**
* The user performing the action.
*
* @var User
*/
public $actor;
/**
* The attributes to update on the tag.
*
* @var array
*/
public $data;
/**
* @param int $tagId The ID of the tag to edit.
* @param User $actor The user performing the action.
* @param array $data The attributes to update on the tag.
*/
public function __construct($tagId, User $actor, array $data)
{
$this->tagId = $tagId;
$this->actor = $actor;
$this->data = $data;
}
}

View File

@ -0,0 +1,91 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Command;
use Flarum\Tags\Event\Saving;
use Flarum\Tags\TagRepository;
use Flarum\Tags\TagValidator;
use Illuminate\Support\Arr;
class EditTagHandler
{
/**
* @var TagRepository
*/
protected $tags;
/**
* @var TagValidator
*/
protected $validator;
/**
* @param TagRepository $tags
* @param TagValidator $validator
*/
public function __construct(TagRepository $tags, TagValidator $validator)
{
$this->tags = $tags;
$this->validator = $validator;
}
/**
* @param EditTag $command
* @return \Flarum\Tags\Tag
* @throws \Flarum\User\Exception\PermissionDeniedException
*/
public function handle(EditTag $command)
{
$actor = $command->actor;
$data = $command->data;
$tag = $this->tags->findOrFail($command->tagId, $actor);
$actor->assertCan('edit', $tag);
$attributes = Arr::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['icon'])) {
$tag->icon = $attributes['icon'];
}
if (isset($attributes['isHidden'])) {
$tag->is_hidden = (bool) $attributes['isHidden'];
}
if (isset($attributes['isRestricted'])) {
$tag->is_restricted = (bool) $attributes['isRestricted'];
}
event(new Saving($tag, $actor, $data));
$this->validator->assertValid($tag->getDirty());
$tag->save();
return $tag;
}
}

View File

@ -0,0 +1,134 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Content;
use Flarum\Api\Client;
use Flarum\Frontend\Document;
use Flarum\Http\RequestUtil;
use Flarum\Tags\TagRepository;
use Illuminate\Contracts\View\Factory;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface as Request;
use Symfony\Contracts\Translation\TranslatorInterface;
class Tag
{
/**
* @var Client
*/
protected $api;
/**
* @var Factory
*/
protected $view;
/**
* @var TagRepository
*/
protected $tags;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @param Client $api
* @param Factory $view
* @param TagRepository $tags
* @param TranslatorInterface $translator
*/
public function __construct(Client $api, Factory $view, TagRepository $tags, TranslatorInterface $translator)
{
$this->api = $api;
$this->view = $view;
$this->tags = $tags;
$this->translator = $translator;
}
public function __invoke(Document $document, Request $request)
{
$queryParams = $request->getQueryParams();
$actor = RequestUtil::getActor($request);
$slug = Arr::pull($queryParams, 'slug');
$sort = Arr::pull($queryParams, 'sort');
$q = Arr::pull($queryParams, 'q', '');
$page = Arr::pull($queryParams, 'page', 1);
$filters = Arr::pull($queryParams, 'filter', []);
$sortMap = $this->getSortMap();
$tagId = $this->tags->getIdForSlug($slug);
$tag = $this->tags->findOrFail($tagId, $actor);
$params = [
'sort' => $sort && isset($sortMap[$sort]) ? $sortMap[$sort] : '',
'filter' => [
'tag' => "$slug"
],
'page' => ['offset' => ($page - 1) * 20, 'limit' => 20]
];
$params['filter'] = array_merge($filters, $params['filter']);
$apiDocument = $this->getApiDocument($request, $params);
$tagsDocument = $this->getTagsDocument($request, $slug);
$apiDocument->included[] = $tagsDocument->data;
$includedTags = $tagsDocument->included ?? [];
foreach ((array) $includedTags as $includedTag) {
$apiDocument->included[] = $includedTag;
}
$document->title = $tag->name;
if ($tag->description) {
$document->meta['description'] = $tag->description;
} else {
$document->meta['description'] = $this->translator->trans('flarum-tags.forum.tag.meta_description_text', ['{tag}' => $tag->name]);
}
$document->content = $this->view->make('tags::frontend.content.tag', compact('apiDocument', 'page', 'tag'));
$document->payload['apiDocument'] = $apiDocument;
return $document;
}
/**
* Get a map of sort query param values and their API sort params.
*
* @return array
*/
private function getSortMap()
{
return [
'latest' => '-lastPostedAt',
'top' => '-commentCount',
'newest' => '-createdAt',
'oldest' => 'createdAt'
];
}
/**
* Get the result of an API request to list discussions.
*/
private function getApiDocument(Request $request, array $params)
{
return json_decode($this->api->withParentRequest($request)->withQueryParams($params)->get('/discussions')->getBody());
}
private function getTagsDocument(Request $request, string $slug)
{
return json_decode($this->api->withParentRequest($request)->withQueryParams([
'include' => 'children,children.parent,parent,parent.children.parent,state'
])->get("/tags/$slug")->getBody());
}
}

View File

@ -0,0 +1,109 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Content;
use Flarum\Api\Client;
use Flarum\Frontend\Document;
use Flarum\Http\UrlGenerator;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\Tags\TagRepository;
use Illuminate\Contracts\View\Factory;
use Illuminate\Support\Arr;
use Psr\Http\Message\ServerRequestInterface as Request;
use Symfony\Contracts\Translation\TranslatorInterface;
class Tags
{
/**
* @var Client
*/
protected $api;
/**
* @var Factory
*/
protected $view;
/**
* @var TagRepository
*/
protected $tags;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @var SettingsRepositoryInterface
*/
protected $settings;
/**
* @var UrlGenerator
*/
protected $url;
/**
* @param Client $api
* @param Factory $view
* @param TagRepository $tags
* @param TranslatorInterface $translator
* @param SettingsRepositoryInterface $settings
* @param UrlGenerator $url
*/
public function __construct(
Client $api,
Factory $view,
TagRepository $tags,
TranslatorInterface $translator,
SettingsRepositoryInterface $settings,
UrlGenerator $url
) {
$this->api = $api;
$this->view = $view;
$this->tags = $tags;
$this->settings = $settings;
$this->translator = $translator;
$this->url = $url;
}
public function __invoke(Document $document, Request $request)
{
$apiDocument = $this->getTagsDocument($request);
$tags = collect(Arr::get($apiDocument, 'data', []));
$childTags = $tags->where('attributes.isChild', true);
$primaryTags = $tags->where('attributes.isChild', false)->where('attributes.position', '!==', null)->sortBy('attributes.position');
$secondaryTags = $tags->where('attributes.isChild', false)->where('attributes.position', '===', null)->sortBy('attributes.name');
$children = $primaryTags->mapWithKeys(function ($tag) use ($childTags) {
$childIds = collect(Arr::get($tag, 'relationships.children.data'))->pluck('id');
return [$tag['id'] => $childTags->whereIn('id', $childIds)->sortBy('position')];
});
$defaultRoute = $this->settings->get('default_route');
$document->title = $this->translator->trans('flarum-tags.forum.all_tags.meta_title_text');
$document->meta['description'] = $this->translator->trans('flarum-tags.forum.all_tags.meta_description_text');
$document->content = $this->view->make('tags::frontend.content.tags', compact('primaryTags', 'secondaryTags', 'children'));
$document->canonicalUrl = $this->url->to('forum')->base().($defaultRoute === '/tags' ? '' : $request->getUri()->getPath());
$document->payload['apiDocument'] = $apiDocument;
return $document;
}
private function getTagsDocument(Request $request)
{
return json_decode($this->api->withParentRequest($request)->withQueryParams([
'include' => 'children,lastPostedDiscussion,parent'
])->get('/tags')->getBody(), true);
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of Flarum.
*
* For detailed copyright and license information, please view the
* LICENSE file that was distributed with this source code.
*/
namespace Flarum\Tags\Event;
use Flarum\Tags\Tag;
use Flarum\User\User;
class Creating
{
/**
* @var Tag
*/
public $tag;
/**
* @var User
*/
public $actor;
/**
* @var array
*/
public $data;
/**
* @param Tag $tag
* @param User $actor
* @param array $data
*/
public function __construct(Tag $tag, User $actor, array $data)
{
$this->tag = $tag;
$this->actor = $actor;
$this->data = $data;
}
}

Some files were not shown because too many files have changed in this diff Show More