diff --git a/public/language/en-GB/admin/manage/categories.json b/public/language/en-GB/admin/manage/categories.json
index 68a79e78aa..db353a8ce2 100644
--- a/public/language/en-GB/admin/manage/categories.json
+++ b/public/language/en-GB/admin/manage/categories.json
@@ -19,6 +19,7 @@
"category-image": "Category Image",
"parent-category": "Parent Category",
"optional-parent-category": "(Optional) Parent Category",
+ "top-level": "Top Level",
"parent-category-none": "(None)",
"copy-parent": "Copy Parent",
"copy-settings": "Copy Settings From",
@@ -31,6 +32,7 @@
"edit": "Edit",
"analytics": "Analytics",
"view-category": "View category",
+ "set-order": "Set order",
"select-category": "Select Category",
"set-parent-category": "Set Parent Category",
diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json
index b45f55ce19..a2bd79d000 100644
--- a/public/language/en-GB/error.json
+++ b/public/language/en-GB/error.json
@@ -213,5 +213,8 @@
"plugin-not-whitelisted": "Unable to install plugin – only plugins whitelisted by the NodeBB Package Manager can be installed via the ACP",
- "topic-event-unrecognized": "Topic event '%1' unrecognized"
+ "topic-event-unrecognized": "Topic event '%1' unrecognized",
+
+ "cant-set-child-as-parent": "Can't set child as parent category",
+ "cant-set-self-as-parent": "Can't set self as parent category"
}
diff --git a/public/language/en-GB/groups.json b/public/language/en-GB/groups.json
index 0256067933..7e6b5ca759 100644
--- a/public/language/en-GB/groups.json
+++ b/public/language/en-GB/groups.json
@@ -40,8 +40,7 @@
"details.member_count": "Member Count",
"details.creation_date": "Creation Date",
"details.description": "Description",
- "details.member-post-cids": "Categories to display posts from",
- "details.member-post-cids-help": "Note: Selecting no categories will assume all categories are included. Use ctrl
and shift
to select multiple options.",
+ "details.member-post-cids": "Category IDs to display posts from",
"details.badge_preview": "Badge Preview",
"details.change_icon": "Change Icon",
"details.change_label_colour": "Change Label Colour",
diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json
index cddce941c6..8490bf897e 100644
--- a/public/language/en-GB/user.json
+++ b/public/language/en-GB/user.json
@@ -149,7 +149,7 @@
"homepage": "Homepage",
"homepage_description": "Select a page to use as the forum homepage or 'None' to use the default homepage.",
"custom_route": "Custom Homepage Route",
- "custom_route_help": "Enter a route name here, without any preceding slash (e.g. \"recent\", or \"popular\")",
+ "custom_route_help": "Enter a route name here, without any preceding slash (e.g. \"recent\" or \"category/2/general-discussion\")",
"sso.title": "Single Sign-on Services",
"sso.associated": "Associated with",
diff --git a/public/openapi/components/schemas/GroupObject.yaml b/public/openapi/components/schemas/GroupObject.yaml
index c2a34509f6..c1b28479d5 100644
--- a/public/openapi/components/schemas/GroupObject.yaml
+++ b/public/openapi/components/schemas/GroupObject.yaml
@@ -46,6 +46,8 @@ GroupFullObject:
type: string
description: A six-character hexadecimal colour code
memberPostCids:
+ type: string
+ memberPostCidsArray:
type: array
items:
type: number
@@ -64,32 +66,6 @@ GroupFullObject:
type: string
descriptionParsed:
type: string
- categories:
- type: array
- items:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- level:
- type: string
- icon:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- selected:
- type: boolean
- imageClass:
- type: string
members:
type: array
items:
@@ -169,6 +145,8 @@ GroupDataObject:
cover:position:
type: string
memberPostCids:
+ type: string
+ memberPostCidsArray:
type: array
items:
type: number
diff --git a/public/openapi/read/admin/manage/admins-mods.yaml b/public/openapi/read/admin/manage/admins-mods.yaml
index fbd89d8c4b..ba922850e9 100644
--- a/public/openapi/read/admin/manage/admins-mods.yaml
+++ b/public/openapi/read/admin/manage/admins-mods.yaml
@@ -15,41 +15,90 @@ get:
$ref: ../../../components/schemas/GroupObject.yaml#/GroupFullObject
globalMods:
$ref: ../../../components/schemas/GroupObject.yaml#/GroupFullObject
- categories:
+ categoryMods:
type: array
items:
type: object
properties:
cid:
type: number
+ description: A category identifier assigned upon category creation (this value cannot be changed)
name:
type: string
- level:
- type: number
- example: 0
+ description: The category's name/title
+ description:
+ type: string
+ description: A variable-length description of the category (usually displayed underneath the category name)
+ descriptionParsed:
+ type: string
+ description: A variable-length description of the category (usually displayed underneath the category name). Unlike `description`, this value here will have been run through any parsers installed on the forum (e.g. Markdown)
icon:
type: string
description: A FontAwesome icon string
+ example: fa-comments-o
+ bgColor:
+ type: string
+ description: Theme-related, a six-character hexadecimal string representing the background colour of the category
+ color:
+ type: string
+ description: Theme-related, a six-character hexadecimal string representing the foreground/text colour of the category
+ slug:
+ type: string
+ description: An URL-safe variant of the category title. This value is automatically generated.
+ readOnly: true
parentCid:
type: number
- description: The parent category's identifier
- color:
+ description: The category identifier for the category that is the immediate ancestor of the current category
+ topic_count:
+ type: number
+ description: The number of topics in the category
+ post_count:
+ type: number
+ description: The number of posts in the category
+ disabled:
+ type: number
+ description: Whether or not this category is disabled.
+ order:
+ type: number
+ description: A number representing the category's place in the hierarchy
+ link:
type: string
- description: A six-character hexadecimal colour code
- bgColor:
+ description: If set, attempting to access the forum will go to this external link instead (theme-specific)
+ numRecentReplies:
+ type: number
+ description: The number of posts to render in the API response (this is mostly used at the theme level)
+ class:
type: string
- description: A six-character hexadecimal colour code
+ description: Values that are appended to the `class` attribute of the category's parent/root element
imageClass:
type: string
- depth:
+ enum: [auto, cover, contain]
+ description: The `background-position` of the category background image, if one is set
+ isSection:
type: number
- description: The depth of the category relative to the forum root (`0` is root level)
- disabled:
+ minTags:
+ type: number
+ description: Minimum tags per topic in this category
+ maxTags:
+ type: number
+ description: Maximum tags per topic in this category
+ postQueue:
+ type: number
+ totalPostCount:
+ type: number
+ description: The number of posts in the category
+ totalTopicCount:
+ type: number
+ description: The number of topics in the category
+ subCategoriesPerPage:
type: number
+ description: The number of subcategories to display on the categories and category page
moderators:
type: array
items:
$ref: ../../../components/schemas/UserObject.yaml#/UserObjectSlim
+ selectedCategory:
+ $ref: ../../../components/schemas/CategoryObject.yaml#/CategoryObject
allPrivileges:
type: array
items:
diff --git a/public/openapi/read/admin/manage/categories.yaml b/public/openapi/read/admin/manage/categories.yaml
index 3c731aaf88..7cfc8753ff 100644
--- a/public/openapi/read/admin/manage/categories.yaml
+++ b/public/openapi/read/admin/manage/categories.yaml
@@ -11,7 +11,9 @@ get:
allOf:
- type : object
properties:
- categories:
+ categoriesPerPage:
+ type: number
+ categoriesTree:
type: array
items:
type: object
@@ -40,7 +42,12 @@ get:
nullable: true
imageClass:
type: string
+ order:
+ type: number
+ subCategoriesPerPage:
+ type: number
children:
type: array
description: Array of children categories
+ - $ref: ../../../components/schemas/Pagination.yaml#/Pagination
- $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps
\ No newline at end of file
diff --git a/public/openapi/read/admin/manage/categories/category_id.yaml b/public/openapi/read/admin/manage/categories/category_id.yaml
index 0dba90a8a6..37794b3915 100644
--- a/public/openapi/read/admin/manage/categories/category_id.yaml
+++ b/public/openapi/read/admin/manage/categories/category_id.yaml
@@ -31,60 +31,8 @@ get:
type: string
parent:
$ref: ../../../../components/schemas/CategoryObject.yaml#/CategoryObject
- categories:
- type: array
- items:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- icon:
- type: string
- selected:
- type: boolean
- level:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- imageClass:
- type: string
- required:
- - cid
- - name
- - icon
selectedCategory:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- level:
- type: string
- icon:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- imageClass:
- type: string
- selected:
- type: boolean
+ $ref: ../../../../components/schemas/CategoryObject.yaml#/CategoryObject
customClasses:
type: array
items:
diff --git a/public/openapi/read/admin/manage/groups.yaml b/public/openapi/read/admin/manage/groups.yaml
index cc93b19469..12b224b07d 100644
--- a/public/openapi/read/admin/manage/groups.yaml
+++ b/public/openapi/read/admin/manage/groups.yaml
@@ -67,6 +67,8 @@ get:
ownerUid:
type: number
memberPostCids:
+ type: string
+ memberPostCidsArray:
type: array
items:
type: number
@@ -93,36 +95,6 @@ get:
- textColor
- createtimeISO
- cover:thumb:url
- categories:
- type: array
- items:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- icon:
- type: string
- selected:
- type: boolean
- level:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- imageClass:
- type: string
- required:
- - cid
- - name
- - icon
yourid:
type: number
- $ref: ../../../components/schemas/Pagination.yaml#/Pagination
diff --git a/public/openapi/read/admin/manage/groups/name.yaml b/public/openapi/read/admin/manage/groups/name.yaml
index e8cdf7787d..f04987a1f6 100644
--- a/public/openapi/read/admin/manage/groups/name.yaml
+++ b/public/openapi/read/admin/manage/groups/name.yaml
@@ -31,36 +31,6 @@ get:
type: string
selected:
type: boolean
- categories:
- type: array
- items:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- icon:
- type: string
- selected:
- type: boolean
- level:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- imageClass:
- type: string
- required:
- - cid
- - name
- - icon
allowPrivateGroups:
type: number
maximumGroupNameLength:
diff --git a/public/openapi/read/admin/manage/privileges/cid.yaml b/public/openapi/read/admin/manage/privileges/cid.yaml
index ddeadfefd3..73dd899610 100644
--- a/public/openapi/read/admin/manage/privileges/cid.yaml
+++ b/public/openapi/read/admin/manage/privileges/cid.yaml
@@ -121,29 +121,7 @@ get:
- icon
- selected
selectedCategory:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- level:
- type: string
- icon:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- imageClass:
- type: string
- selected:
- type: boolean
+ $ref: ../../../../components/schemas/CategoryObject.yaml#/CategoryObject
cid:
type: number
description: A category identifier
diff --git a/public/openapi/read/categories.yaml b/public/openapi/read/categories.yaml
index 7ec6fe5d81..74e2a9d486 100644
--- a/public/openapi/read/categories.yaml
+++ b/public/openapi/read/categories.yaml
@@ -23,6 +23,9 @@ get:
title:
description: The page title
type: string
+ selectCategoryLabel:
+ type: string
+ description: Label to use for the category selector
categories:
description: A collection of category data objects
type: array
@@ -112,10 +115,6 @@ get:
cid:
type: number
description: A category identifier
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
topic:
type: object
properties:
@@ -177,10 +176,6 @@ get:
cid:
type: number
description: A category identifier
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
topic:
type: object
properties:
diff --git a/public/openapi/read/category/category_id.yaml b/public/openapi/read/category/category_id.yaml
index 845af7d68a..90a1e80427 100644
--- a/public/openapi/read/category/category_id.yaml
+++ b/public/openapi/read/category/category_id.yaml
@@ -58,6 +58,9 @@ get:
type: boolean
title:
type: string
+ selectCategoryLabel:
+ type: string
+ description: Label to use for the category selector
privileges:
type: object
properties:
diff --git a/public/openapi/read/flags.yaml b/public/openapi/read/flags.yaml
index 399b58564e..7ad27374b5 100644
--- a/public/openapi/read/flags.yaml
+++ b/public/openapi/read/flags.yaml
@@ -46,12 +46,6 @@ get:
type: array
items:
type: number
- categories:
- type: object
- properties: {}
- additionalProperties:
- type: string
- description: All categories will be listed here, with the `cid` as the key, and the category name as the value
hasFilter:
type: boolean
filters:
@@ -65,6 +59,16 @@ get:
type: string
title:
type: string
+ selectedCategory:
+ type: object
+ properties:
+ icon:
+ type: string
+ name:
+ type: string
+ bgColor:
+ type: string
+ nullable: true
- $ref: ../components/schemas/Pagination.yaml#/Pagination
- $ref: ../components/schemas/Breadcrumbs.yaml#/Breadcrumbs
- $ref: ../components/schemas/CommonProps.yaml#/CommonProps
\ No newline at end of file
diff --git a/public/openapi/read/groups.yaml b/public/openapi/read/groups.yaml
index bc5847fbb7..8fc121708b 100644
--- a/public/openapi/read/groups.yaml
+++ b/public/openapi/read/groups.yaml
@@ -61,6 +61,8 @@ get:
cover:position:
type: string
memberPostCids:
+ type: string
+ memberPostCidsArray:
type: array
items:
type: number
diff --git a/public/openapi/read/index.yaml b/public/openapi/read/index.yaml
index 7750cb54e1..4e6c6e446b 100644
--- a/public/openapi/read/index.yaml
+++ b/public/openapi/read/index.yaml
@@ -18,6 +18,9 @@ get:
title:
type: string
description: The page title
+ selectCategoryLabel:
+ type: string
+ description: Label to use for the category selector
categories:
description: A collection of category data objects
type: array
@@ -175,10 +178,6 @@ get:
cid:
type: number
description: A category identifier
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
topic:
type: object
properties:
diff --git a/public/openapi/read/popular.yaml b/public/openapi/read/popular.yaml
index e5e379b683..4d870249f6 100644
--- a/public/openapi/read/popular.yaml
+++ b/public/openapi/read/popular.yaml
@@ -32,32 +32,6 @@ get:
type: boolean
showTopicTools:
type: boolean
- categories:
- type: array
- items:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- level:
- type: string
- icon:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- selected:
- type: boolean
- imageClass:
- type: string
allCategoriesUrl:
type: string
selectedCategory:
diff --git a/public/openapi/read/post-queue.yaml b/public/openapi/read/post-queue.yaml
index 46fa13c328..2edffc67d5 100644
--- a/public/openapi/read/post-queue.yaml
+++ b/public/openapi/read/post-queue.yaml
@@ -13,70 +13,6 @@ get:
properties:
title:
type: string
- allCategories:
- type: array
- items:
- type: object
- properties:
- bgColor:
- type: string
- cid:
- type: number
- color:
- type: string
- disabled:
- type: number
- disabledClass:
- type: boolean
- icon:
- type: string
- imageClass:
- type: string
- level:
- type: string
- link:
- type: string
- name:
- type: string
- parentCid:
- type: number
- slug:
- type: string
- categories:
- type: array
- items:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- level:
- type: string
- icon:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- selected:
- type: boolean
- imageClass:
- type: string
- required:
- - bgColor
- - cid
- - color
- - icon
- - imageClass
- - level
- - name
- - parentCid
allCategoriesUrl:
type: string
selectedCategory:
diff --git a/public/openapi/read/recent.yaml b/public/openapi/read/recent.yaml
index 69b64c1dfd..4e019cf157 100644
--- a/public/openapi/read/recent.yaml
+++ b/public/openapi/read/recent.yaml
@@ -30,32 +30,6 @@ get:
type: boolean
showTopicTools:
type: boolean
- categories:
- type: array
- items:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- level:
- type: string
- icon:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- selected:
- type: boolean
- imageClass:
- type: string
allCategoriesUrl:
type: string
selectedCategory:
diff --git a/public/openapi/read/tags/tag.yaml b/public/openapi/read/tags/tag.yaml
index aaa2138a37..8d742baf8e 100644
--- a/public/openapi/read/tags/tag.yaml
+++ b/public/openapi/read/tags/tag.yaml
@@ -227,32 +227,6 @@ get:
type: string
title:
type: string
- categories:
- type: array
- items:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- level:
- type: string
- icon:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- selected:
- type: boolean
- imageClass:
- type: string
rssFeedUrl:
type: string
feeds:disableRSS:
diff --git a/public/openapi/read/top.yaml b/public/openapi/read/top.yaml
index f7bce5367f..e9b552ffcf 100644
--- a/public/openapi/read/top.yaml
+++ b/public/openapi/read/top.yaml
@@ -30,32 +30,6 @@ get:
type: boolean
showTopicTools:
type: boolean
- categories:
- type: array
- items:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- level:
- type: string
- icon:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- selected:
- type: boolean
- imageClass:
- type: string
allCategoriesUrl:
type: string
selectedCategory:
diff --git a/public/openapi/read/unread.yaml b/public/openapi/read/unread.yaml
index 8fa3be8c9c..11c6011292 100644
--- a/public/openapi/read/unread.yaml
+++ b/public/openapi/read/unread.yaml
@@ -200,32 +200,6 @@ get:
type: string
pageCount:
type: number
- categories:
- type: array
- items:
- type: object
- properties:
- cid:
- type: number
- description: A category identifier
- name:
- type: string
- level:
- type: string
- icon:
- type: string
- parentCid:
- type: number
- description: The category identifier for the category that is the immediate
- ancestor of the current category
- color:
- type: string
- bgColor:
- type: string
- selected:
- type: boolean
- imageClass:
- type: string
allCategoriesUrl:
type: string
selectedCategory:
diff --git a/public/openapi/read/user/userslug/categories.yaml b/public/openapi/read/user/userslug/categories.yaml
index 3860dad988..20cc798400 100644
--- a/public/openapi/read/user/userslug/categories.yaml
+++ b/public/openapi/read/user/userslug/categories.yaml
@@ -58,5 +58,6 @@ get:
type: string
title:
type: string
+ - $ref: ../../../components/schemas/Pagination.yaml#/Pagination
- $ref: ../../../components/schemas/Breadcrumbs.yaml#/Breadcrumbs
- $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps
\ No newline at end of file
diff --git a/public/src/admin/manage/admins-mods.js b/public/src/admin/manage/admins-mods.js
index 37ed9ad7ed..2bceceb15e 100644
--- a/public/src/admin/manage/admins-mods.js
+++ b/public/src/admin/manage/admins-mods.js
@@ -1,8 +1,8 @@
'use strict';
define('admin/manage/admins-mods', [
- 'translator', 'benchpress', 'autocomplete', 'api', 'bootbox',
-], function (translator, Benchpress, autocomplete, api, bootbox) {
+ 'autocomplete', 'api', 'bootbox', 'categorySelector',
+], function (autocomplete, api, bootbox, categorySelector) {
var AdminsMods = {};
AdminsMods.init = function () {
@@ -77,6 +77,13 @@ define('admin/manage/admins-mods', [
});
+ categorySelector.init($('[component="category-selector"]'), {
+ onSelect: function (selectedCategory) {
+ ajaxify.go('admin/manage/admins-mods' + (selectedCategory.cid ? '?cid=' + selectedCategory.cid : ''));
+ },
+ localCategories: [],
+ });
+
autocomplete.user($('.moderator-search'), function (ev, ui) {
var input = $(ev.target);
var cid = $(ev.target).attr('data-cid');
diff --git a/public/src/admin/manage/categories.js b/public/src/admin/manage/categories.js
index 3a6f0e9ee7..d077ad9d39 100644
--- a/public/src/admin/manage/categories.js
+++ b/public/src/admin/manage/categories.js
@@ -6,13 +6,21 @@ define('admin/manage/categories', [
'categorySelector',
'api',
'Sortable',
-], function (translator, Benchpress, categorySelector, api, Sortable) {
+ 'bootbox',
+], function (translator, Benchpress, categorySelector, api, Sortable, bootbox) {
var Categories = {};
var newCategoryId = -1;
var sortables;
Categories.init = function () {
- Categories.render(ajaxify.data.categories);
+ categorySelector.init($('.category [component="category-selector"]'), {
+ parentCid: ajaxify.data.selectedCategory ? ajaxify.data.selectedCategory.cid : 0,
+ onSelect: function (selectedCategory) {
+ ajaxify.go('/admin/manage/categories' + (selectedCategory.cid ? '?cid=' + selectedCategory.cid : ''));
+ },
+ localCategories: [],
+ });
+ Categories.render(ajaxify.data.categoriesTree);
$('button[data-action="create"]').on('click', Categories.throwCreateModal);
@@ -36,6 +44,34 @@ define('admin/manage/categories', [
el.closest('[data-cid]').find('> ul[data-cid]').toggleClass('hidden');
});
+ $('.categories').on('click', '.set-order', function () {
+ var cid = $(this).attr('data-cid');
+ var order = $(this).attr('data-order');
+ var modal = bootbox.dialog({
+ title: '[[admin/manage/categories:set-order]]',
+ message: '',
+ show: true,
+ buttons: {
+ save: {
+ label: '[[modules:bootbox.confirm]]',
+ className: 'btn-primary',
+ callback: function () {
+ var val = modal.find('input').val();
+ if (val && cid) {
+ var modified = {};
+ modified[cid] = { order: Math.max(1, parseInt(val, 10)) };
+ api.put('/categories/' + cid, modified[cid]).then(function () {
+ ajaxify.refresh();
+ }).catch(err => app.alertError(err));
+ } else {
+ return false;
+ }
+ },
+ },
+ },
+ });
+ });
+
$('#collapse-all').on('click', function () {
toggleAll(false);
});
@@ -49,110 +85,58 @@ define('admin/manage/categories', [
el.find('i').toggleClass('fa-minus', expand).toggleClass('fa-plus', !expand);
el.closest('[data-cid]').find('> ul[data-cid]').toggleClass('hidden', !expand);
}
-
- $('#category-search').on('keyup', function () {
- searchCategory();
- });
};
- function searchCategory() {
- var container = $('#content .categories');
- function revealParents(cid) {
- var parentCid = container.find('li[data-cid="' + cid + '"]').attr('data-parent-cid');
- if (parentCid) {
- container.find('li[data-cid="' + parentCid + '"]').removeClass('hidden');
- revealParents(parentCid);
- }
- }
-
- function revealChildren(cid) {
- var els = container.find('li[data-parent-cid="' + cid + '"]');
- els.each(function (index, el) {
- var $el = $(el);
- $el.removeClass('hidden');
- revealChildren($el.attr('data-cid'));
- });
- }
-
- var categoryEls = container.find('li[data-cid]');
- var val = $('#category-search').val().toLowerCase();
- var noMatch = true;
- var cids = [];
- categoryEls.each(function () {
- var liEl = $(this);
- var isMatch = liEl.attr('data-name').toLowerCase().indexOf(val) !== -1;
- if (noMatch && isMatch) {
- noMatch = false;
- }
- if (isMatch && val) {
- cids.push(liEl.attr('data-cid'));
- }
- liEl.toggleClass('hidden', !isMatch);
- });
-
- cids.forEach(function (cid) {
- revealParents(cid);
- revealChildren(cid);
- });
-
- $('[component="category/no-matches"]').toggleClass('hidden', !noMatch);
- }
-
Categories.throwCreateModal = function () {
- socket.emit('categories.getSelectCategories', {}, function (err, categories) {
- if (err) {
- return app.alertError(err.message);
- }
-
- categories.unshift({
- cid: 0,
- name: '[[admin/manage/categories:parent-category-none]]',
- icon: 'fa-none',
+ Benchpress.render('admin/partials/categories/create', {}).then(function (html) {
+ var modal = bootbox.dialog({
+ title: '[[admin/manage/categories:alert.create]]',
+ message: html,
+ buttons: {
+ save: {
+ label: '[[global:save]]',
+ className: 'btn-primary',
+ callback: submit,
+ },
+ },
});
- Benchpress.render('admin/partials/categories/create', {
- categories: categories,
- }).then(function (html) {
- var modal = bootbox.dialog({
- title: '[[admin/manage/categories:alert.create]]',
- message: html,
- buttons: {
- save: {
- label: '[[global:save]]',
- className: 'btn-primary',
- callback: submit,
- },
+ var options = {
+ localCategories: [
+ {
+ cid: 0,
+ name: '[[admin/manage/categories:parent-category-none]]',
+ icon: 'fa-none',
},
- });
-
- var parentSelector = categorySelector.init(modal.find('#parentCidGroup [component="category-selector"]'));
- var cloneFromSelector = categorySelector.init(modal.find('#cloneFromCidGroup [component="category-selector"]'));
- function submit() {
- var formData = modal.find('form').serializeObject();
- formData.description = '';
- formData.icon = 'fa-comments';
- formData.uid = app.user.uid;
- formData.parentCid = parentSelector.getSelectedCid();
- formData.cloneFromCid = cloneFromSelector.getSelectedCid();
-
- Categories.create(formData);
- modal.modal('hide');
- return false;
- }
-
- $('#cloneChildren').on('change', function () {
- var check = $(this);
- var parentSelect = modal.find('#parentCidGroup [component="category-selector"] .dropdown-toggle');
+ ],
+ };
+ var parentSelector = categorySelector.init(modal.find('#parentCidGroup [component="category-selector"]'), options);
+ var cloneFromSelector = categorySelector.init(modal.find('#cloneFromCidGroup [component="category-selector"]'), options);
+ function submit() {
+ var formData = modal.find('form').serializeObject();
+ formData.description = '';
+ formData.icon = 'fa-comments';
+ formData.uid = app.user.uid;
+ formData.parentCid = parentSelector.getSelectedCid();
+ formData.cloneFromCid = cloneFromSelector.getSelectedCid();
+
+ Categories.create(formData);
+ modal.modal('hide');
+ return false;
+ }
- if (check.prop('checked')) {
- parentSelect.attr('disabled', 'disabled');
- parentSelector.selectCategory(0);
- } else {
- parentSelect.removeAttr('disabled');
- }
- });
+ $('#cloneChildren').on('change', function () {
+ var check = $(this);
+ var parentSelect = modal.find('#parentCidGroup [component="category-selector"] .dropdown-toggle');
- modal.find('form').on('submit', submit);
+ if (check.prop('checked')) {
+ parentSelect.attr('disabled', 'disabled');
+ parentSelector.selectCategory(0);
+ } else {
+ parentSelect.removeAttr('disabled');
+ }
});
+
+ modal.find('form').on('submit', submit);
});
};
@@ -210,25 +194,21 @@ define('admin/manage/categories', [
// Update needed?
if ((e.newIndex != null && parseInt(e.oldIndex, 10) !== parseInt(e.newIndex, 10)) || isCategoryUpdate) {
- var parentCategory = isCategoryUpdate ? sortables[newCategoryId] : sortables[e.from.dataset.cid];
+ var cid = e.item.dataset.cid;
var modified = {};
- var i = 0;
- var list = parentCategory.toArray();
- var len = list.length;
-
- for (i; i < len; i += 1) {
- modified[list[i]] = {
- order: (i + 1),
- };
- }
+ // on page 1 baseIndex is 0, on page n baseIndex is (n - 1) * ajaxify.data.categoriesPerPage
+ // this makes sure order is correct when drag & drop is used on pages > 1
+ var baseIndex = (ajaxify.data.pagination.currentPage - 1) * ajaxify.data.categoriesPerPage;
+ modified[cid] = {
+ order: baseIndex + e.newIndex + 1,
+ };
if (isCategoryUpdate) {
- modified[e.item.dataset.cid].parentCid = newCategoryId;
+ modified[cid].parentCid = newCategoryId;
}
newCategoryId = -1;
-
- Object.keys(modified).map(cid => api.put('/categories/' + cid, modified[cid]));
+ api.put('/categories/' + cid, modified[cid]);
}
}
diff --git a/public/src/admin/manage/category.js b/public/src/admin/manage/category.js
index 585f59e1a0..33777df74b 100644
--- a/public/src/admin/manage/category.js
+++ b/public/src/admin/manage/category.js
@@ -16,8 +16,11 @@ define('admin/manage/category', [
$this.val($this.attr('data-value'));
});
- categorySelector.init($('[component="category-selector"]'), function (selectedCategory) {
- ajaxify.go('admin/manage/categories/' + selectedCategory.cid);
+ categorySelector.init($('[component="category-selector"]'), {
+ onSelect: function (selectedCategory) {
+ ajaxify.go('admin/manage/categories/' + selectedCategory.cid);
+ },
+ showLinks: true,
});
handleTags();
@@ -114,55 +117,50 @@ define('admin/manage/category', [
});
$('.copy-settings').on('click', function () {
- socket.emit('categories.getSelectCategories', {}, function (err, allCategories) {
- if (err) {
- return app.alertError(err.message);
- }
-
- Benchpress.render('admin/partials/categories/copy-settings', {
- categories: allCategories,
- }).then(function (html) {
- var selectedCid;
- var modal = bootbox.dialog({
- title: '[[modules:composer.select_category]]',
- message: html,
- buttons: {
- save: {
- label: '[[modules:bootbox.confirm]]',
- className: 'btn-primary',
- callback: function () {
- if (!selectedCid || parseInt(selectedCid, 10) === parseInt(ajaxify.data.category.cid, 10)) {
- return;
+ Benchpress.render('admin/partials/categories/copy-settings', {}).then(function (html) {
+ var selectedCid;
+ var modal = bootbox.dialog({
+ title: '[[modules:composer.select_category]]',
+ message: html,
+ buttons: {
+ save: {
+ label: '[[modules:bootbox.confirm]]',
+ className: 'btn-primary',
+ callback: function () {
+ if (!selectedCid || parseInt(selectedCid, 10) === parseInt(ajaxify.data.category.cid, 10)) {
+ return;
+ }
+
+ socket.emit('admin.categories.copySettingsFrom', {
+ fromCid: selectedCid,
+ toCid: ajaxify.data.category.cid,
+ copyParent: modal.find('#copyParent').prop('checked'),
+ }, function (err) {
+ if (err) {
+ return app.alertError(err.message);
}
- socket.emit('admin.categories.copySettingsFrom', {
- fromCid: selectedCid,
- toCid: ajaxify.data.category.cid,
- copyParent: modal.find('#copyParent').prop('checked'),
- }, function (err) {
- if (err) {
- return app.alertError(err.message);
- }
-
- modal.modal('hide');
- app.alertSuccess('[[admin/manage/categories:alert.copy-success]]');
- ajaxify.refresh();
- });
- return false;
- },
+ modal.modal('hide');
+ app.alertSuccess('[[admin/manage/categories:alert.copy-success]]');
+ ajaxify.refresh();
+ });
+ return false;
},
},
- });
- modal.find('.modal-footer button').prop('disabled', true);
- categorySelector.init(modal.find('[component="category-selector"]'), function (selectedCategory) {
+ },
+ });
+ modal.find('.modal-footer button').prop('disabled', true);
+ categorySelector.init(modal.find('[component="category-selector"]'), {
+ onSelect: function (selectedCategory) {
selectedCid = selectedCategory && selectedCategory.cid;
if (selectedCid) {
modal.find('.modal-footer button').prop('disabled', false);
}
- });
+ },
+ showLinks: true,
});
- return false;
});
+ return false;
});
$('.upload-button').on('click', function () {
@@ -261,34 +259,27 @@ define('admin/manage/category', [
}
Category.launchParentSelector = function () {
- socket.emit('categories.getSelectCategories', {}, function (err, allCategories) {
- if (err) {
- return app.alertError(err.message);
- }
- var parents = [parseInt(ajaxify.data.category.cid, 10)];
- var categories = allCategories.filter(function (category) {
- var isChild = parents.includes(parseInt(category.parentCid, 10));
- if (isChild) {
- parents.push(parseInt(category.cid, 10));
+ categorySelector.modal({
+ onSubmit: function (selectedCategory) {
+ var parentCid = selectedCategory.cid;
+ if (!parentCid) {
+ return;
}
- return category && !category.disabled && parseInt(category.cid, 10) !== parseInt(ajaxify.data.category.cid, 10) && !isChild;
- });
-
- categorySelector.modal(categories, function (parentCid) {
api.put('/categories/' + ajaxify.data.category.cid, {
parentCid: parentCid,
}).then(() => {
- var parent = allCategories.filter(function (category) {
- return category && parseInt(category.cid, 10) === parseInt(parentCid, 10);
+ api.get(`/category/${parentCid}`).then(function (parent) {
+ if (parent && parent.icon && parent.name) {
+ var buttonHtml = ' ' + parent.name;
+ $('button[data-action="changeParent"]').html(buttonHtml).parent().removeClass('hide');
+ }
});
- parent = parent[0];
$('button[data-action="removeParent"]').parent().removeClass('hide');
$('button[data-action="setParent"]').addClass('hide');
- var buttonHtml = ' ' + parent.name;
- $('button[data-action="changeParent"]').html(buttonHtml).parent().removeClass('hide');
}).catch(app.alertError);
- });
+ },
+ showLinks: true,
});
};
diff --git a/public/src/admin/manage/group.js b/public/src/admin/manage/group.js
index 5eb41bab9f..c7fb51d6ae 100644
--- a/public/src/admin/manage/group.js
+++ b/public/src/admin/manage/group.js
@@ -57,8 +57,21 @@ define('admin/manage/group', [
});
});
- categorySelector.init($('[component="category-selector"]'), function (selectedCategory) {
- navigateToCategory(selectedCategory.cid);
+ categorySelector.init($('.edit-privileges-selector [component="category-selector"]'), {
+ onSelect: function (selectedCategory) {
+ navigateToCategory(selectedCategory.cid);
+ },
+ showLinks: true,
+ });
+
+ var cidSelector = categorySelector.init($('.member-post-cids-selector [component="category-selector"]'), {
+ onSelect: function (selectedCategory) {
+ var cids = ($('#memberPostCids').val() || '').split(',').map(cid => parseInt(cid, 10));
+ cids.push(selectedCategory.cid);
+ cids = cids.filter((cid, index, array) => array.indexOf(cid) === index);
+ $('#memberPostCids').val(cids.join(','));
+ cidSelector.selectCategory(0);
+ },
});
groupSearch.init($('[component="group-selector"]'));
diff --git a/public/src/admin/manage/groups.js b/public/src/admin/manage/groups.js
index b7b181776a..96fd8b63fc 100644
--- a/public/src/admin/manage/groups.js
+++ b/public/src/admin/manage/groups.js
@@ -75,8 +75,11 @@ define('admin/manage/groups', [
function enableCategorySelectors() {
$('.groups-list [component="category-selector"]').each(function () {
var nameEncoded = $(this).parents('[data-name-encoded]').attr('data-name-encoded');
- categorySelector.init($(this), function (selectedCategory) {
- ajaxify.go('admin/manage/privileges/' + selectedCategory.cid + '?group=' + nameEncoded);
+ categorySelector.init($(this), {
+ onSelect: function (selectedCategory) {
+ ajaxify.go('admin/manage/privileges/' + selectedCategory.cid + '?group=' + nameEncoded);
+ },
+ showLinks: true,
});
});
}
diff --git a/public/src/admin/manage/privileges.js b/public/src/admin/manage/privileges.js
index 85da47f370..281e06c56d 100644
--- a/public/src/admin/manage/privileges.js
+++ b/public/src/admin/manage/privileges.js
@@ -17,11 +17,16 @@ define('admin/manage/privileges', [
checkboxRowSelector.init('.privilege-table-container');
- categorySelector.init($('[component="category-selector"]'), function (category) {
- cid = parseInt(category.cid, 10);
- cid = isNaN(cid) ? 'admin' : cid;
- Privileges.refreshPrivilegeTable();
- ajaxify.updateHistory('admin/manage/privileges/' + (cid || ''));
+ categorySelector.init($('[component="category-selector"]'), {
+ onSelect: function (category) {
+ cid = parseInt(category.cid, 10);
+ cid = isNaN(cid) ? 'admin' : cid;
+ Privileges.refreshPrivilegeTable();
+ ajaxify.updateHistory('admin/manage/privileges/' + (cid || ''));
+ },
+ localCategories: ajaxify.data.categories,
+ privilege: 'find',
+ showLinks: true,
});
Privileges.setupPrivilegeTable();
@@ -262,13 +267,21 @@ define('admin/manage/privileges', [
};
Privileges.copyPrivilegesFromCategory = function (cid, group) {
- categorySelector.modal(ajaxify.data.categories.slice(1), function (fromCid) {
- socket.emit('admin.categories.copyPrivilegesFrom', { toCid: cid, fromCid: fromCid, group: group }, function (err) {
- if (err) {
- return app.alertError(err.message);
- }
- ajaxify.refresh();
- });
+ categorySelector.modal({
+ localCategories: [],
+ showLinks: true,
+ onSubmit: function (selectedCategory) {
+ socket.emit('admin.categories.copyPrivilegesFrom', {
+ toCid: cid,
+ fromCid: selectedCategory.cid,
+ group: group,
+ }, function (err) {
+ if (err) {
+ return app.alertError(err.message);
+ }
+ ajaxify.refresh();
+ });
+ },
});
};
diff --git a/public/src/client/categories.js b/public/src/client/categories.js
index b19ba2d0cd..e567b805d9 100644
--- a/public/src/client/categories.js
+++ b/public/src/client/categories.js
@@ -1,7 +1,7 @@
'use strict';
-define('forum/categories', ['components'], function (components) {
+define('forum/categories', ['components', 'categorySelector'], function (components, categorySelector) {
var categories = {};
$(window).on('action:ajaxify.start', function (ev, data) {
@@ -15,6 +15,12 @@ define('forum/categories', ['components'], function (components) {
socket.removeListener('event:new_post', categories.onNewPost);
socket.on('event:new_post', categories.onNewPost);
+ categorySelector.init($('[component="category-selector"]'), {
+ privilege: 'find',
+ onSelect: function (category) {
+ ajaxify.go('/category/' + category.cid);
+ },
+ });
$('.category-header').tooltip({
placement: 'bottom',
diff --git a/public/src/client/category.js b/public/src/client/category.js
index ce5484bb29..4d586d5665 100644
--- a/public/src/client/category.js
+++ b/public/src/client/category.js
@@ -6,7 +6,8 @@ define('forum/category', [
'navigator',
'topicList',
'sort',
-], function (infinitescroll, share, navigator, topicList, sort) {
+ 'categorySelector',
+], function (infinitescroll, share, navigator, topicList, sort, categorySelector) {
var Category = {};
$(window).on('action:ajaxify.start', function (ev, data) {
@@ -38,6 +39,14 @@ define('forum/category', [
handleLoadMoreSubcategories();
+ categorySelector.init($('[component="category-selector"]'), {
+ privilege: 'find',
+ parentCid: ajaxify.data.cid,
+ onSelect: function (category) {
+ ajaxify.go('/category/' + category.cid);
+ },
+ });
+
$(window).trigger('action:topics.loaded', { topics: ajaxify.data.topics });
$(window).trigger('action:category.loaded', { cid: ajaxify.data.cid });
};
diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js
index 7c284b08a9..dbcad7d113 100644
--- a/public/src/client/flags/list.js
+++ b/public/src/client/flags/list.js
@@ -1,13 +1,29 @@
'use strict';
-define('forum/flags/list', ['components', 'Chart'], function (components, Chart) {
+define('forum/flags/list', ['components', 'Chart', 'categoryFilter'], function (components, Chart, categoryFilter) {
var Flags = {};
+ var selectedCids;
+
Flags.init = function () {
Flags.enableFilterForm();
Flags.enableCheckboxes();
Flags.handleBulkActions();
+ selectedCids = [];
+ if (ajaxify.data.filters.hasOwnProperty('cid')) {
+ selectedCids = Array.isArray(ajaxify.data.filters.cid) ?
+ ajaxify.data.filters.cid : [ajaxify.data.filters.cid];
+ }
+
+ categoryFilter.init($('[component="category/dropdown"]'), {
+ privilege: 'moderate',
+ selectedCids: selectedCids,
+ onHidden: function (data) {
+ selectedCids = data.selectedCids;
+ },
+ });
+
components.get('flags/list')
.on('click', '[data-flag-id]', function (e) {
if (['BUTTON', 'A'].includes(e.target.nodeName)) {
@@ -39,6 +55,11 @@ define('forum/flags/list', ['components', 'Chart'], function (components, Chart)
document.getElementById('apply-filters').addEventListener('click', function () {
var payload = filtersEl.serializeArray();
+ // cid is special comes from categoryFilter module
+ selectedCids.forEach(function (cid) {
+ payload.push({ name: 'cid', value: cid });
+ });
+
ajaxify.go('flags?' + (payload.length ? $.param(payload) : 'reset=1'));
});
};
diff --git a/public/src/client/groups/details.js b/public/src/client/groups/details.js
index 9b240fc5d3..ae0d92b74a 100644
--- a/public/src/client/groups/details.js
+++ b/public/src/client/groups/details.js
@@ -9,7 +9,8 @@ define('forum/groups/details', [
'translator',
'api',
'slugify',
-], function (memberList, iconSelect, components, coverPhoto, pictureCropper, translator, api, slugify) {
+ 'categorySelector',
+], function (memberList, iconSelect, components, coverPhoto, pictureCropper, translator, api, slugify, categorySelector) {
var Details = {};
var groupName;
@@ -165,6 +166,16 @@ define('forum/groups/details', [
previewEl.addClass('hide');
}
});
+
+ var cidSelector = categorySelector.init($('.member-post-cids-selector [component="category-selector"]'), {
+ onSelect: function (selectedCategory) {
+ var cids = ($('#memberPostCids').val() || '').split(',').map(cid => parseInt(cid, 10));
+ cids.push(selectedCategory.cid);
+ cids = cids.filter((cid, index, array) => array.indexOf(cid) === index);
+ $('#memberPostCids').val(cids.join(','));
+ cidSelector.selectCategory(0);
+ },
+ });
};
Details.update = function () {
diff --git a/public/src/client/post-queue.js b/public/src/client/post-queue.js
index 06d2e5b56a..6551fb031b 100644
--- a/public/src/client/post-queue.js
+++ b/public/src/client/post-queue.js
@@ -2,14 +2,16 @@
define('forum/post-queue', [
- 'categoryFilter', 'categorySelector',
-], function (categoryFilter, categorySelector) {
+ 'categoryFilter', 'categorySelector', 'api',
+], function (categoryFilter, categorySelector, api) {
var PostQueue = {};
PostQueue.init = function () {
$('[data-toggle="tooltip"]').tooltip();
- categoryFilter.init($('[component="category/dropdown"]'));
+ categoryFilter.init($('[component="category/dropdown"]'), {
+ privilege: 'moderate',
+ });
$('.posts-list').on('click', '[data-action]', function () {
var parent = $(this).parents('[data-id]');
@@ -42,30 +44,32 @@ define('forum/post-queue', [
$('.posts-list').on('click', '.topic-category[data-editable]', function () {
var $this = $(this);
var id = $this.parents('[data-id]').attr('data-id');
- categorySelector.modal(ajaxify.data.allCategories, function (cid) {
- var category = ajaxify.data.allCategories.find(function (c) {
- return parseInt(c.cid, 10) === parseInt(cid, 10);
- });
- socket.emit('posts.editQueuedContent', {
- id: id,
- cid: cid,
- }, function (err) {
- if (err) {
- return app.alertError(err.message);
- }
- app.parseAndTranslate('post-queue', 'posts', {
- posts: [{
- category: category,
- }],
- }, function (html) {
- if ($this.find('.category-text').length) {
- $this.find('.category-text').text(html.find('.topic-category .category-text').text());
- } else {
- // for backwards compatibility, remove in 1.16.0
- $this.replaceWith(html.find('.topic-category'));
- }
+ categorySelector.modal({
+ onSubmit: function (selectedCategory) {
+ Promise.all([
+ api.get(`/categories/${selectedCategory.cid}`, {}),
+ socket.emit('posts.editQueuedContent', {
+ id: id,
+ cid: selectedCategory.cid,
+ }),
+ ]).then(function (result) {
+ var category = result[0];
+ app.parseAndTranslate('post-queue', 'posts', {
+ posts: [{
+ category: category,
+ }],
+ }, function (html) {
+ if ($this.find('.category-text').length) {
+ $this.find('.category-text').text(html.find('.topic-category .category-text').text());
+ } else {
+ // for backwards compatibility, remove in 1.16.0
+ $this.replaceWith(html.find('.topic-category'));
+ }
+ });
+ }).catch(function (err) {
+ app.alertError(err);
});
- });
+ },
});
return false;
});
diff --git a/public/src/client/topic/move.js b/public/src/client/topic/move.js
index 03c40b56d3..c7d8c22fb0 100644
--- a/public/src/client/topic/move.js
+++ b/public/src/client/topic/move.js
@@ -12,18 +12,12 @@ define('forum/topic/move', ['categorySelector', 'alerts'], function (categorySel
Move.onComplete = onComplete;
Move.moveAll = !tids;
- socket.emit('categories.getMoveCategories', onCategoriesLoaded);
+ showModal();
};
- function onCategoriesLoaded(err, categories) {
- if (err) {
- return app.alertError(err.message);
- }
-
- app.parseAndTranslate('partials/move_thread_modal', {
- categories: categories,
- }, function (html) {
- modal = $(html);
+ function showModal() {
+ app.parseAndTranslate('partials/move_thread_modal', {}, function (html) {
+ modal = html;
modal.on('hidden.bs.modal', function () {
modal.remove();
});
@@ -34,7 +28,10 @@ define('forum/topic/move', ['categorySelector', 'alerts'], function (categorySel
modal.find('.modal-header h3').translateText('[[topic:move_topics]]');
}
- categorySelector.init(modal.find('[component="category-selector"]'), onCategorySelected);
+ categorySelector.init(modal.find('[component="category-selector"]'), {
+ onSelect: onCategorySelected,
+ privilege: 'moderate',
+ });
modal.find('#move_thread_commit').on('click', onCommitClicked);
diff --git a/public/src/modules/categoryFilter.js b/public/src/modules/categoryFilter.js
index b28e29ff19..c6038b4374 100644
--- a/public/src/modules/categoryFilter.js
+++ b/public/src/modules/categoryFilter.js
@@ -3,64 +3,75 @@
define('categoryFilter', ['categorySearch'], function (categorySearch) {
var categoryFilter = {};
- categoryFilter.init = function (el) {
- categorySearch.init(el);
- var listEl = el.find('[component="category/list"]');
+ categoryFilter.init = function (el, options) {
+ if (!el || !el.length) {
+ return;
+ }
+ options = options || {};
+ options.states = options.states || ['watching', 'notwatching', 'ignoring'];
+ options.template = 'partials/category-filter';
+ $(window).trigger('action:category.filter.options', { el: el, options: options });
+
+ categorySearch.init(el, options);
+
+ var selectedCids = [];
+ var initialCids = [];
+ if (Array.isArray(options.selectedCids)) {
+ selectedCids = options.selectedCids.map(cid => parseInt(cid, 10));
+ } else if (Array.isArray(ajaxify.data.selectedCids)) {
+ selectedCids = ajaxify.data.selectedCids.map(cid => parseInt(cid, 10));
+ }
+ initialCids = selectedCids.slice();
el.on('hidden.bs.dropdown', function () {
- var cids = getSelectedCids(el);
- var changed = ajaxify.data.selectedCids.length !== cids.length;
- ajaxify.data.selectedCids.forEach(function (cid, index) {
- if (cid !== cids[index]) {
+ var changed = initialCids.length !== selectedCids.length;
+ initialCids.forEach(function (cid, index) {
+ if (cid !== selectedCids[index]) {
changed = true;
}
});
-
+ if (options.onHidden) {
+ options.onHidden({ changed: changed, selectedCids: selectedCids.slice() });
+ return;
+ }
if (changed) {
var url = window.location.pathname;
var currentParams = utils.params();
- if (cids.length) {
- currentParams.cid = cids;
+ if (selectedCids.length) {
+ currentParams.cid = selectedCids;
url += '?' + decodeURIComponent($.param(currentParams));
}
ajaxify.go(url);
}
});
- listEl.on('click', '[data-cid]', function (ev) {
- function selectChildren(parentCid, flag) {
- listEl.find('[data-parent-cid="' + parentCid + '"] [component="category/select/icon"]').toggleClass('invisible', flag);
- listEl.find('[data-parent-cid="' + parentCid + '"]').each(function (index, el) {
- selectChildren($(el).attr('data-cid'), flag);
- });
- }
+ el.on('click', '[component="category/list"] [data-cid]', function () {
+ var listEl = el.find('[component="category/list"]');
var categoryEl = $(this);
var link = categoryEl.find('a').attr('href');
if (link && link !== '#' && link.length) {
return;
}
- var cid = categoryEl.attr('data-cid');
- if (ev.ctrlKey) {
- selectChildren(cid, !categoryEl.find('[component="category/select/icon"]').hasClass('invisible'));
+ var cid = parseInt(categoryEl.attr('data-cid'), 10);
+ var icon = categoryEl.find('[component="category/select/icon"]');
+
+ if (selectedCids.includes(cid)) {
+ selectedCids.splice(selectedCids.indexOf(cid), 1);
+ } else {
+ selectedCids.push(cid);
+ }
+ selectedCids.sort(function (a, b) {
+ return a - b;
+ });
+
+ icon.toggleClass('invisible');
+ listEl.find('li[data-all="all"] i').toggleClass('invisible', !!selectedCids.length);
+ if (options.onSelect) {
+ options.onSelect({ cid: cid, selectedCids: selectedCids.slice() });
}
- categoryEl.find('[component="category/select/icon"]').toggleClass('invisible');
- listEl.find('li').first().find('i').toggleClass('invisible', !!getSelectedCids(el).length);
return false;
});
};
- function getSelectedCids(el) {
- var cids = [];
- el.find('[component="category/list"] [data-cid]').each(function (index, el) {
- if (!$(el).find('[component="category/select/icon"]').hasClass('invisible')) {
- cids.push(parseInt($(el).attr('data-cid'), 10));
- }
- });
- cids.sort(function (a, b) {
- return a - b;
- });
- return cids;
- }
-
return categoryFilter;
});
diff --git a/public/src/modules/categorySearch.js b/public/src/modules/categorySearch.js
index ed78a5ebe0..d8be81a1d9 100644
--- a/public/src/modules/categorySearch.js
+++ b/public/src/modules/categorySearch.js
@@ -3,79 +3,52 @@
define('categorySearch', function () {
var categorySearch = {};
- categorySearch.init = function (el) {
- if (utils.isTouchDevice()) {
- return;
+ categorySearch.init = function (el, options) {
+ var categoriesList = null;
+ options = options || {};
+ options.privilege = options.privilege || 'topics:read';
+ options.states = options.states || ['watching', 'notwatching', 'ignoring'];
+
+ var localCategories = [];
+ if (Array.isArray(options.localCategories)) {
+ localCategories = options.localCategories.map(c => ({ ...c }));
}
+ options.selectedCids = options.selectedCids || ajaxify.data.selectedCids || [];
+
var searchEl = el.find('[component="category-selector-search"]');
if (!searchEl.length) {
return;
}
+
var toggleVisibility = searchEl.parent('[component="category/dropdown"]').length > 0 ||
searchEl.parent('[component="category-selector"]').length > 0;
- var listEl = el.find('[component="category/list"]');
- var clonedList = listEl.clone();
- var categoryEls = clonedList.find('[data-cid]');
-
el.on('show.bs.dropdown', function () {
- var cidToParentCid = {};
-
- function revealParents(cid) {
- var parentCid = cidToParentCid[cid];
- if (parentCid) {
- clonedList.find('[data-cid="' + parentCid + '"]').removeClass('hidden');
- revealParents(parentCid);
- }
- }
-
- function revealChildren(cid) {
- var els = clonedList.find('[data-parent-cid="' + cid + '"]');
- els.each(function (index, el) {
- var $el = $(el);
- $el.removeClass('hidden');
- revealChildren($el.attr('data-cid'));
- });
- }
-
- function updateList() {
- var val = searchEl.find('input').val().toLowerCase();
- var noMatch = true;
- var cids = [];
- categoryEls.each(function () {
- var liEl = $(this);
- var isMatch = cids.length < 100 && (!val || (val.length > 1 && liEl.attr('data-name').toLowerCase().indexOf(val) !== -1));
- if (noMatch && isMatch) {
- noMatch = false;
- }
- if (isMatch && val) {
- var cid = liEl.attr('data-cid');
- cids.push(cid);
- cidToParentCid[cid] = parseInt(liEl.attr('data-parent-cid'), 10);
- }
- liEl.toggleClass('hidden', !isMatch).find('[component="category-markup"]').css({ 'font-weight': val && isMatch ? 'bold' : 'normal' });
- });
-
- cids.forEach(function (cid) {
- revealParents(cid);
- revealChildren(cid);
- });
-
- listEl.html(clonedList.html());
- el.find('[component="category/list"] [component="category/no-matches"]').toggleClass('hidden', !noMatch);
- }
if (toggleVisibility) {
el.find('.dropdown-toggle').addClass('hidden');
searchEl.removeClass('hidden');
}
+ function doSearch() {
+ var val = searchEl.find('input').val();
+ if (val.length > 1 || (!val && !categoriesList)) {
+ loadList(val, function (categories) {
+ categoriesList = categoriesList || categories;
+ renderList(categories);
+ });
+ } else if (!val && categoriesList) {
+ renderList(categoriesList);
+ }
+ }
+
searchEl.on('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
});
- searchEl.find('input').val('').on('keyup', utils.debounce(updateList, 200));
- updateList();
+ searchEl.find('input').val('').on('keyup', utils.debounce(doSearch, 300));
+ doSearch();
});
+
el.on('shown.bs.dropdown', function () {
searchEl.find('input').focus();
});
@@ -89,6 +62,35 @@ define('categorySearch', function () {
searchEl.off('click');
searchEl.find('input').off('keyup');
});
+
+ function loadList(query, callback) {
+ socket.emit('categories.categorySearch', {
+ query: query,
+ parentCid: options.parentCid || 0,
+ selectedCids: options.selectedCids,
+ privilege: options.privilege,
+ states: options.states,
+ showLinks: options.showLinks,
+ }, function (err, categories) {
+ if (err) {
+ return app.alertError(err);
+ }
+ callback(localCategories.concat(categories));
+ });
+ }
+
+ function renderList(categories) {
+ app.parseAndTranslate(options.template, {
+ categoryItems: categories.slice(0, 200),
+ selectedCategory: ajaxify.data.selectedCategory,
+ allCategoriesUrl: ajaxify.data.allCategoriesUrl,
+ }, function (html) {
+ el.find('[component="category/list"]')
+ .replaceWith(html.find('[component="category/list"]'));
+ el.find('[component="category/list"] [component="category/no-matches"]')
+ .toggleClass('hidden', !!categories.length);
+ });
+ }
};
return categorySearch;
diff --git a/public/src/modules/categorySelector.js b/public/src/modules/categorySelector.js
index c9603433e8..7965ee1181 100644
--- a/public/src/modules/categorySelector.js
+++ b/public/src/modules/categorySelector.js
@@ -1,26 +1,34 @@
'use strict';
-define('categorySelector', ['benchpress', 'translator', 'categorySearch'], function (Benchpress, translator, categorySearch) {
+define('categorySelector', ['categorySearch'], function (categorySearch) {
var categorySelector = {};
- categorySelector.init = function (el, callback) {
- callback = callback || function () {};
+ categorySelector.init = function (el, options) {
+ if (!el || !el.length) {
+ return;
+ }
+ options = options || {};
+ var onSelect = options.onSelect || function () {};
+
+ options.states = options.states || ['watching', 'notwatching', 'ignoring'];
+ options.template = 'partials/category-selector';
+ $(window).trigger('action:category.selector.options', { el: el, options: options });
+
+ categorySearch.init(el, options);
+
var selector = {
el: el,
selectedCategory: null,
};
-
el.on('click', '[data-cid]', function () {
var categoryEl = $(this);
if (categoryEl.hasClass('disabled')) {
return false;
}
selector.selectCategory(categoryEl.attr('data-cid'));
- callback(selector.selectedCategory);
+ onSelect(selector.selectedCategory);
});
- categorySearch.init(el);
-
selector.selectCategory = function (cid) {
var categoryEl = selector.el.find('[data-cid="' + cid + '"]');
selector.selectedCategory = {
@@ -43,14 +51,11 @@ define('categorySelector', ['benchpress', 'translator', 'categorySearch'], funct
return selector;
};
- categorySelector.modal = function (categories, callback) {
- if (typeof categories === 'function') {
- callback = categories;
- categories = ajaxify.data.allCategories;
- }
- app.parseAndTranslate('admin/partials/categories/select-category', {
- categories: categories,
- }, function (html) {
+ categorySelector.modal = function (options) {
+ options = options || {};
+ options.onSelect = options.onSelect || function () {};
+ options.onSubmit = options.onSubmit || function () {};
+ app.parseAndTranslate('admin/partials/categories/select-category', {}, function (html) {
var modal = bootbox.dialog({
title: '[[modules:composer.select_category]]',
message: html,
@@ -62,16 +67,21 @@ define('categorySelector', ['benchpress', 'translator', 'categorySearch'], funct
},
},
});
- var selector = categorySelector.init(modal.find('[component="category-selector"]'));
+
+ var selector = categorySelector.init(modal.find('[component="category-selector"]'), options);
function submit(ev) {
ev.preventDefault();
if (selector.selectedCategory) {
- callback(selector.selectedCategory.cid);
+ options.onSubmit(selector.selectedCategory);
modal.modal('hide');
}
return false;
}
-
+ if (options.openOnLoad) {
+ modal.on('shown.bs.modal', function () {
+ modal.find('.dropdown-toggle').dropdown('toggle');
+ });
+ }
modal.find('form').on('submit', submit);
});
};
diff --git a/public/src/modules/topicList.js b/public/src/modules/topicList.js
index ff3d67e8b0..e9d00ad5fd 100644
--- a/public/src/modules/topicList.js
+++ b/public/src/modules/topicList.js
@@ -37,8 +37,16 @@ define('topicList', [
categoryTools.init();
TopicList.watchForNewPosts();
+ var states = ['watching'];
+ if (ajaxify.data.selectedFilter && ajaxify.data.selectedFilter.filter === 'watched') {
+ states.push('notwatching', 'ignoring');
+ } else if (template !== 'unread') {
+ states.push('notwatching');
+ }
- categoryFilter.init($('[component="category/dropdown"]'));
+ categoryFilter.init($('[component="category/dropdown"]'), {
+ states: states,
+ });
if (!config.usePagination) {
infinitescroll.init(TopicList.loadMoreTopics);
@@ -86,18 +94,11 @@ define('topicList', [
socket.removeListener('event:new_post', onNewPost);
};
- function isCategoryVisible(cid) {
- return ajaxify.data.categories && ajaxify.data.categories.length && ajaxify.data.categories.some(function (c) {
- return parseInt(c.cid, 10) === parseInt(cid, 10);
- });
- }
-
function onNewTopic(data) {
if (
(ajaxify.data.selectedCids && ajaxify.data.selectedCids.length && ajaxify.data.selectedCids.indexOf(parseInt(data.cid, 10)) === -1) ||
(ajaxify.data.selectedFilter && ajaxify.data.selectedFilter.filter === 'watched') ||
- (ajaxify.data.template.category && parseInt(ajaxify.data.cid, 10) !== parseInt(data.cid, 10)) ||
- (!isCategoryVisible(data.cid))
+ (ajaxify.data.template.category && parseInt(ajaxify.data.cid, 10) !== parseInt(data.cid, 10))
) {
return;
}
@@ -116,8 +117,7 @@ define('topicList', [
(ajaxify.data.selectedCids && ajaxify.data.selectedCids.length && ajaxify.data.selectedCids.indexOf(parseInt(post.topic.cid, 10)) === -1) ||
(ajaxify.data.selectedFilter && ajaxify.data.selectedFilter.filter === 'new') ||
(ajaxify.data.selectedFilter && ajaxify.data.selectedFilter.filter === 'watched' && !post.topic.isFollowing) ||
- (ajaxify.data.template.category && parseInt(ajaxify.data.cid, 10) !== parseInt(post.topic.cid, 10)) ||
- (!isCategoryVisible(post.topic.cid))
+ (ajaxify.data.template.category && parseInt(ajaxify.data.cid, 10) !== parseInt(post.topic.cid, 10))
)) {
return;
}
diff --git a/src/cache.js b/src/cache.js
index a77cc6e637..7bb27961ce 100644
--- a/src/cache.js
+++ b/src/cache.js
@@ -4,6 +4,6 @@ const cacheCreate = require('./cacheCreate');
module.exports = cacheCreate({
name: 'local',
- max: 4000,
+ max: 40000,
maxAge: 0,
});
diff --git a/src/categories/delete.js b/src/categories/delete.js
index 3e76b22069..984ba88e28 100644
--- a/src/categories/delete.js
+++ b/src/categories/delete.js
@@ -73,7 +73,9 @@ module.exports = function (Categories) {
'categories:cid',
'cid:0:children',
'cid:' + parentCid + ':children',
+ 'cid:' + parentCid + ':children:all',
'cid:' + cid + ':children',
+ 'cid:' + cid + ':children:all',
'cid:' + cid + ':tag:whitelist',
]);
}
diff --git a/src/categories/index.js b/src/categories/index.js
index de3f329544..6320ccd620 100644
--- a/src/categories/index.js
+++ b/src/categories/index.js
@@ -71,6 +71,7 @@ Categories.getAllCidsFromSet = async function (key) {
}
cids = await db.getSortedSetRange(key, 0, -1);
+ cids = cids.map(cid => parseInt(cid, 10));
cache.set(key, cids);
return cids.slice();
};
@@ -229,6 +230,19 @@ async function getChildrenTree(category, uid) {
Categories.getChildrenTree = getChildrenTree;
+Categories.getParentCids = async function (currentCid) {
+ let cid = currentCid;
+ const parents = [];
+ while (parseInt(cid, 10)) {
+ // eslint-disable-next-line
+ cid = await Categories.getCategoryField(cid, 'parentCid');
+ if (cid) {
+ parents.unshift(cid);
+ }
+ }
+ return parents;
+};
+
Categories.getChildrenCids = async function (rootCid) {
let allCids = [];
async function recursive(keys) {
@@ -243,7 +257,7 @@ Categories.getChildrenCids = async function (rootCid) {
await recursive(keys);
}
const key = 'cid:' + rootCid + ':children';
- const cacheKey = 'cache:' + key;
+ const cacheKey = key + ':all';
const childrenCids = cache.get(cacheKey);
if (childrenCids) {
return childrenCids.slice();
@@ -311,10 +325,17 @@ Categories.getTree = function (categories, parentCid) {
}
});
function sortTree(tree) {
- tree.sort((a, b) => a.order - b.order);
- if (tree.children) {
- sortTree(tree.children);
- }
+ tree.sort((a, b) => {
+ if (a.order !== b.order) {
+ return a.order - b.order;
+ }
+ return a.cid - b.cid;
+ });
+ tree.forEach((category) => {
+ if (category && Array.isArray(category.children)) {
+ sortTree(category.children);
+ }
+ });
}
sortTree(tree);
@@ -338,7 +359,7 @@ async function getSelectData(cids, fields) {
return Categories.buildForSelectCategories(tree, fields);
}
-Categories.buildForSelectCategories = function (categories, fields) {
+Categories.buildForSelectCategories = function (categories, fields, parentCid) {
function recursive(category, categoriesData, level, depth) {
const bullet = level ? '• ' : '';
category.value = category.cid;
@@ -350,10 +371,10 @@ Categories.buildForSelectCategories = function (categories, fields) {
category.children.forEach(child => recursive(child, categoriesData, ' ' + level, depth + 1));
}
}
-
+ parentCid = parentCid || 0;
const categoriesData = [];
- const rootCategories = categories.filter(category => category && !category.parentCid);
+ const rootCategories = categories.filter(category => category && category.parentCid === parentCid);
rootCategories.forEach(category => recursive(category, categoriesData, '', 0));
diff --git a/src/categories/recentreplies.js b/src/categories/recentreplies.js
index efb9381d90..e2eb0746ce 100644
--- a/src/categories/recentreplies.js
+++ b/src/categories/recentreplies.js
@@ -91,25 +91,27 @@ module.exports = function (Categories) {
};
async function getTopics(tids, uid) {
- const topicData = await topics.getTopicsFields(tids, ['tid', 'mainPid', 'slug', 'title', 'teaserPid', 'cid', 'postcount']);
+ const topicData = await topics.getTopicsFields(
+ tids,
+ ['tid', 'mainPid', 'slug', 'title', 'teaserPid', 'cid', 'postcount']
+ );
topicData.forEach(function (topic) {
if (topic) {
topic.teaserPid = topic.teaserPid || topic.mainPid;
}
});
- var cids = _.uniq(topicData.map(topic => topic && topic.cid).filter(cid => parseInt(cid, 10)));
- const [categoryData, teasers] = await Promise.all([
- Categories.getCategoriesFields(cids, ['cid', 'parentCid']),
+ const cids = _.uniq(topicData.map(t => t && t.cid).filter(cid => parseInt(cid, 10)));
+ const getToRoot = async () => await Promise.all(cids.map(Categories.getParentCids));
+ const [toRoot, teasers] = await Promise.all([
+ getToRoot(),
topics.getTeasers(topicData, uid),
]);
- var parentCids = {};
- categoryData.forEach(function (category) {
- parentCids[category.cid] = category.parentCid;
- });
+ const cidToRoot = _.zipObject(cids, toRoot);
+
teasers.forEach(function (teaser, index) {
if (teaser) {
teaser.cid = topicData[index].cid;
- teaser.parentCid = parseInt(parentCids[teaser.cid], 10) || 0;
+ teaser.parentCids = cidToRoot[teaser.cid];
teaser.tid = undefined;
teaser.uid = undefined;
teaser.topic = {
@@ -124,11 +126,12 @@ module.exports = function (Categories) {
function assignTopicsToCategories(categories, topics) {
categories.forEach(function (category) {
if (category) {
- category.posts = topics.filter(topic => topic.cid && (topic.cid === category.cid || topic.parentCid === category.cid))
+ category.posts = topics.filter(t => t.cid && (t.cid === category.cid || t.parentCids.includes(category.cid)))
.sort((a, b) => b.pid - a.pid)
.slice(0, parseInt(category.numRecentReplies, 10));
}
});
+ topics.forEach((t) => { t.parentCids = undefined; });
}
function bubbleUpChildrenPosts(categoryData) {
@@ -137,7 +140,8 @@ module.exports = function (Categories) {
if (category.posts.length) {
return;
}
- var posts = [];
+
+ const posts = [];
getPostsRecursive(category, posts);
posts.sort((a, b) => b.pid - a.pid);
@@ -150,15 +154,12 @@ module.exports = function (Categories) {
function getPostsRecursive(category, posts) {
if (Array.isArray(category.posts)) {
- category.posts.forEach(function (p) {
- posts.push(p);
- });
+ category.posts.forEach(p => posts.push(p));
}
- category.children.forEach(function (child) {
- getPostsRecursive(child, posts);
- });
+ category.children.forEach(child => getPostsRecursive(child, posts));
}
+
// terrible name, should be topics.moveTopicPosts
Categories.moveRecentReplies = async function (tid, oldCid, cid) {
await updatePostCount(tid, oldCid, cid);
diff --git a/src/categories/search.js b/src/categories/search.js
index 4bab418caa..dab0f6a9d5 100644
--- a/src/categories/search.js
+++ b/src/categories/search.js
@@ -41,6 +41,15 @@ module.exports = function (Categories) {
Categories.getTree(categoryData, 0);
await Categories.getRecentTopicReplies(categoryData, uid, data.qs);
+ categoryData.forEach(function (category) {
+ if (category && Array.isArray(category.children)) {
+ category.children = category.children.slice(0, category.subCategoriesPerPage);
+ category.children.forEach(function (child) {
+ child.children = undefined;
+ });
+ }
+ });
+
categoryData.sort(function (c1, c2) {
if (c1.parentCid !== c2.parentCid) {
return c1.parentCid - c2.parentCid;
diff --git a/src/categories/update.js b/src/categories/update.js
index b44bb63f72..54624dd173 100644
--- a/src/categories/update.js
+++ b/src/categories/update.js
@@ -50,12 +50,12 @@ module.exports = function (Categories) {
return await updateTagWhitelist(cid, value);
} else if (key === 'name') {
return await updateName(cid, value);
+ } else if (key === 'order') {
+ return await updateOrder(cid, value);
}
await db.setObjectField('category:' + cid, key, value);
- if (key === 'order') {
- await updateOrder(cid, value);
- } else if (key === 'description') {
+ if (key === 'description') {
await Categories.parseDescription(cid, value);
}
}
@@ -76,7 +76,12 @@ module.exports = function (Categories) {
db.setObjectField('category:' + cid, 'parentCid', newParent),
]);
- cache.del(['cid:' + oldParent + ':children', 'cid:' + newParent + ':children']);
+ cache.del([
+ 'cid:' + oldParent + ':children',
+ 'cid:' + newParent + ':children',
+ 'cid:' + oldParent + ':children:all',
+ 'cid:' + newParent + ':children:all',
+ ]);
}
async function updateTagWhitelist(cid, tags) {
@@ -90,8 +95,38 @@ module.exports = function (Categories) {
async function updateOrder(cid, order) {
const parentCid = await Categories.getCategoryField(cid, 'parentCid');
- await db.sortedSetsAdd(['categories:cid', 'cid:' + parentCid + ':children'], order, cid);
- cache.del(['categories:cid', 'cid:' + parentCid + ':children']);
+ await db.sortedSetsAdd('categories:cid', order, cid);
+
+ const childrenCids = await db.getSortedSetRange(
+ 'cid:' + parentCid + ':children', 0, -1
+ );
+
+ const currentIndex = childrenCids.indexOf(String(cid));
+ if (currentIndex === -1) {
+ throw new Error('[[error:no-category]]');
+ }
+ // moves cid to index order-1 in the array
+ if (childrenCids.length > 1) {
+ childrenCids.splice(Math.max(0, order - 1), 0, childrenCids.splice(currentIndex, 1)[0]);
+ }
+
+ // recalculate orders from array indices
+ await db.sortedSetAdd(
+ 'cid:' + parentCid + ':children',
+ childrenCids.map((cid, index) => index + 1),
+ childrenCids
+ );
+
+ await db.setObjectBulk(
+ childrenCids.map(cid => 'category:' + cid),
+ childrenCids.map((cid, index) => ({ order: index + 1 }))
+ );
+
+ cache.del([
+ 'categories:cid',
+ 'cid:' + parentCid + ':children',
+ 'cid:' + parentCid + ':children:all',
+ ]);
}
Categories.parseDescription = async function (cid, description) {
diff --git a/src/controllers/accounts/categories.js b/src/controllers/accounts/categories.js
index 1f0f434f09..4e4f4b458b 100644
--- a/src/controllers/accounts/categories.js
+++ b/src/controllers/accounts/categories.js
@@ -4,6 +4,8 @@ const user = require('../../user');
const categories = require('../../categories');
const accountHelpers = require('./helpers');
const helpers = require('../helpers');
+const pagination = require('../../pagination');
+const meta = require('../../meta');
const categoriesController = module.exports;
@@ -12,11 +14,18 @@ categoriesController.get = async function (req, res, next) {
if (!userData) {
return next();
}
- const [states, categoriesData] = await Promise.all([
+ const [states, allCategoriesData] = await Promise.all([
user.getCategoryWatchState(userData.uid),
categories.buildForSelect(userData.uid, 'find', ['descriptionParsed', 'depth', 'slug']),
]);
+ const pageCount = Math.max(1, Math.ceil(allCategoriesData.length / meta.config.categoriesPerPage));
+ const page = Math.min(parseInt(req.query.page, 10) || 1, pageCount);
+ const start = Math.max(0, (page - 1) * meta.config.categoriesPerPage);
+ const stop = start + meta.config.categoriesPerPage - 1;
+ const categoriesData = allCategoriesData.slice(start, stop + 1);
+
+
categoriesData.forEach(function (category) {
if (category) {
category.isIgnored = states[category.cid] === categories.watchStates.ignoring;
@@ -30,5 +39,6 @@ categoriesController.get = async function (req, res, next) {
{ text: userData.username, url: '/user/' + userData.userslug },
{ text: '[[pages:categories]]' },
]);
+ userData.pagination = pagination.create(page, pageCount, req.query);
res.render('account/categories', userData);
};
diff --git a/src/controllers/admin/admins-mods.js b/src/controllers/admin/admins-mods.js
index d25d810d8f..6333364e37 100644
--- a/src/controllers/admin/admins-mods.js
+++ b/src/controllers/admin/admins-mods.js
@@ -2,6 +2,7 @@
const _ = require('lodash');
+const db = require('../../database');
const groups = require('../../groups');
const categories = require('../../categories');
const privileges = require('../../privileges');
@@ -9,29 +10,34 @@ const user = require('../../user');
const AdminsMods = module.exports;
-AdminsMods.get = async function (req, res) {
- const [admins, globalMods, categories] = await Promise.all([
+AdminsMods.get = async function (req, res, next) {
+ let cid = parseInt(req.query.cid, 10) || 0;
+ if (!cid) {
+ cid = (await db.getSortedSetRange('cid:0:children', 0, 0))[0];
+ }
+ const selectedCategory = await categories.getCategoryData(cid);
+ if (!selectedCategory) {
+ return next();
+ }
+ const [admins, globalMods, moderators] = await Promise.all([
groups.get('administrators', { uid: req.uid }),
groups.get('Global Moderators', { uid: req.uid }),
- getModeratorsOfCategories(),
+ getModeratorsOfCategories(selectedCategory),
]);
res.render('admin/manage/admins-mods', {
admins: admins,
globalMods: globalMods,
- categories: categories,
+ categoryMods: [moderators],
+ selectedCategory: selectedCategory,
allPrivileges: privileges.userPrivilegeList,
});
};
-async function getModeratorsOfCategories() {
- const categoryData = await categories.buildForSelectAll(['depth', 'disabled']);
- const moderatorUids = await categories.getModeratorUids(categoryData.map(c => c.cid));
+async function getModeratorsOfCategories(categoryData) {
+ const moderatorUids = await categories.getModeratorUids([categoryData.cid]);
const uids = _.uniq(_.flatten(moderatorUids));
const moderatorData = await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']);
- const moderatorMap = _.zipObject(uids, moderatorData);
- categoryData.forEach((c, index) => {
- c.moderators = moderatorUids[index].map(uid => moderatorMap[uid]);
- });
+ categoryData.moderators = moderatorData;
return categoryData;
}
diff --git a/src/controllers/admin/categories.js b/src/controllers/admin/categories.js
index b32b281659..7755d93d3d 100644
--- a/src/controllers/admin/categories.js
+++ b/src/controllers/admin/categories.js
@@ -1,18 +1,22 @@
'use strict';
+const _ = require('lodash');
+const nconf = require('nconf');
const categories = require('../../categories');
const analytics = require('../../analytics');
const plugins = require('../../plugins');
const translator = require('../../translator');
const meta = require('../../meta');
+const helpers = require('../helpers');
+const pagination = require('../../pagination');
const categoriesController = module.exports;
categoriesController.get = async function (req, res, next) {
- const [categoryData, parent, allCategories] = await Promise.all([
+ const [categoryData, parent, selectedData] = await Promise.all([
categories.getCategories([req.params.category_id], req.uid),
categories.getParents([req.params.category_id]),
- categories.buildForSelectAll(),
+ helpers.getSelectedCategory(req.params.category_id),
]);
const category = categoryData[0];
@@ -21,47 +25,108 @@ categoriesController.get = async function (req, res, next) {
}
category.parent = parent[0];
- allCategories.forEach(function (category) {
- if (category) {
- category.selected = parseInt(category.cid, 10) === parseInt(req.params.category_id, 10);
- }
- });
- const selectedCategory = allCategories.find(c => c.selected);
const data = await plugins.hooks.fire('filter:admin.category.get', {
req: req,
res: res,
category: category,
customClasses: [],
- allCategories: allCategories,
});
data.category.name = translator.escape(String(data.category.name));
data.category.description = translator.escape(String(data.category.description));
res.render('admin/manage/category', {
category: data.category,
- categories: data.allCategories,
- selectedCategory: selectedCategory,
+ selectedCategory: selectedData.selectedCategory,
customClasses: data.customClasses,
postQueueEnabled: !!meta.config.postQueue,
});
};
categoriesController.getAll = async function (req, res) {
+ const rootCid = parseInt(req.query.cid, 10) || 0;
+ async function getRootAndChildren() {
+ const rootChildren = await categories.getAllCidsFromSet(`cid:${rootCid}:children`);
+ const childCids = _.flatten(await Promise.all(rootChildren.map(cid => categories.getChildrenCids(cid))));
+ return [rootCid].concat(rootChildren.concat(childCids));
+ }
+
// Categories list will be rendered on client side with recursion, etc.
- const cids = await categories.getAllCidsFromSet('categories:cid');
+ const cids = await (rootCid ? getRootAndChildren() : categories.getAllCidsFromSet('categories:cid'));
+
+ let rootParent = 0;
+ if (rootCid) {
+ rootParent = await categories.getCategoryField(rootCid, 'parentCid') || 0;
+ }
+
const fields = [
- 'cid', 'name', 'icon', 'parentCid', 'disabled', 'link',
- 'color', 'bgColor', 'backgroundImage', 'imageClass',
+ 'cid', 'name', 'icon', 'parentCid', 'disabled', 'link', 'order',
+ 'color', 'bgColor', 'backgroundImage', 'imageClass', 'subCategoriesPerPage',
];
const categoriesData = await categories.getCategoriesFields(cids, fields);
const result = await plugins.hooks.fire('filter:admin.categories.get', { categories: categoriesData, fields: fields });
- const tree = categories.getTree(result.categories, 0);
+ let tree = categories.getTree(result.categories, rootParent);
+
+ const cidsCount = rootCid ? cids.length - 1 : tree.length;
+
+ const pageCount = Math.max(1, Math.ceil(cidsCount / meta.config.categoriesPerPage));
+ const page = Math.min(parseInt(req.query.page, 10) || 1, pageCount);
+ const start = Math.max(0, (page - 1) * meta.config.categoriesPerPage);
+ const stop = start + meta.config.categoriesPerPage;
+
+ function trim(c) {
+ if (c.children) {
+ c.children = c.children.slice(0, c.subCategoriesPerPage);
+ c.children.forEach(c => trim(c));
+ }
+ }
+ if (rootCid && tree[0] && Array.isArray(tree[0].children)) {
+ tree[0].children = tree[0].children.slice(start, stop);
+ tree[0].children.forEach(trim);
+ } else {
+ tree = tree.slice(start, stop);
+ tree.forEach(trim);
+ }
+
+ let selectedCategory;
+ if (rootCid) {
+ selectedCategory = await categories.getCategoryData(rootCid);
+ }
+ const crumbs = await buildBreadcrumbs(req, selectedCategory);
res.render('admin/manage/categories', {
- categories: tree,
+ categoriesTree: tree,
+ selectedCategory: selectedCategory,
+ breadcrumbs: crumbs,
+ pagination: pagination.create(page, pageCount, req.query),
+ categoriesPerPage: meta.config.categoriesPerPage,
});
};
+async function buildBreadcrumbs(req, categoryData) {
+ if (!categoryData) {
+ return;
+ }
+ const breadcrumbs = [
+ {
+ text: categoryData.name,
+ url: nconf.get('relative_path') + '/admin/manage/categories?cid=' + categoryData.cid,
+ cid: categoryData.cid,
+ },
+ ];
+ const allCrumbs = await helpers.buildCategoryBreadcrumbs(categoryData.parentCid);
+ const crumbs = allCrumbs.filter(c => c.cid);
+
+ crumbs.forEach(function (c) {
+ c.url = '/admin/manage/categories?cid=' + c.cid;
+ });
+ crumbs.unshift({
+ text: '[[admin/manage/categories:top-level]]',
+ url: '/admin/manage/categories',
+ });
+
+ return crumbs.concat(breadcrumbs);
+}
+
categoriesController.getAnalytics = async function (req, res) {
const [name, analyticsData] = await Promise.all([
categories.getCategoryField(req.params.category_id, 'name'),
diff --git a/src/controllers/admin/groups.js b/src/controllers/admin/groups.js
index cf46a6bdaa..0be3de5543 100644
--- a/src/controllers/admin/groups.js
+++ b/src/controllers/admin/groups.js
@@ -5,7 +5,6 @@ const validator = require('validator');
const db = require('../../database');
const user = require('../../user');
-const categories = require('../../categories');
const groups = require('../../groups');
const meta = require('../../meta');
const pagination = require('../../pagination');
@@ -23,22 +22,19 @@ groupsController.list = async function (req, res) {
const stop = start + groupsPerPage - 1;
groupNames = groupNames.slice(start, stop + 1);
- const allCategories = await categories.buildForSelectAll();
const groupData = await groups.getGroupsData(groupNames);
res.render('admin/manage/groups', {
groups: groupData,
pagination: pagination.create(page, pageCount),
yourid: req.uid,
- categories: allCategories,
});
};
groupsController.get = async function (req, res, next) {
const groupName = req.params.name;
- const [groupNames, group, allCategories] = await Promise.all([
+ const [groupNames, group] = await Promise.all([
getGroupNames(),
groups.get(groupName, { uid: req.uid, truncateUserList: true, userListCount: 20 }),
- categories.buildForSelectAll(),
]);
if (!group || groupName === groups.BANNED_USERS) {
@@ -60,7 +56,6 @@ groupsController.get = async function (req, res, next) {
allowPrivateGroups: meta.config.allowPrivateGroups,
maximumGroupNameLength: meta.config.maximumGroupNameLength,
maximumGroupTitleLength: meta.config.maximumGroupTitleLength,
- categories: allCategories,
});
};
diff --git a/src/controllers/admin/privileges.js b/src/controllers/admin/privileges.js
index 2b7d0f6e60..ad68818d5e 100644
--- a/src/controllers/admin/privileges.js
+++ b/src/controllers/admin/privileges.js
@@ -9,19 +9,14 @@ privilegesController.get = async function (req, res) {
const cid = req.params.cid ? parseInt(req.params.cid, 10) || 0 : 0;
const isAdminPriv = req.params.cid === 'admin';
- let method;
+ let privilegesData;
if (cid > 0) {
- method = privileges.categories.list.bind(null, cid);
+ privilegesData = await privileges.categories.list(cid);
} else if (cid === 0) {
- method = isAdminPriv ? privileges.admin.list : privileges.global.list;
+ privilegesData = await (isAdminPriv ? privileges.admin.list(req.uid) : privileges.global.list());
}
- const [privilegesData, categoriesData] = await Promise.all([
- method(isAdminPriv ? req.uid : undefined),
- categories.buildForSelectAll(),
- ]);
-
- categoriesData.unshift({
+ const categoriesData = [{
cid: 0,
name: '[[admin/manage/privileges:global]]',
icon: 'fa-list',
@@ -29,7 +24,7 @@ privilegesController.get = async function (req, res) {
cid: 'admin', // what do?
name: '[[admin/manage/privileges:admin]]',
icon: 'fa-lock',
- });
+ }];
let selectedCategory;
categoriesData.forEach(function (category) {
@@ -41,6 +36,10 @@ privilegesController.get = async function (req, res) {
}
}
});
+ if (!selectedCategory) {
+ selectedCategory = await categories.getCategoryFields(cid, ['cid', 'name', 'icon', 'bgColor', 'color']);
+ }
+
const group = req.query.group ? req.query.group : '';
res.render('admin/manage/privileges', {
privileges: privilegesData,
diff --git a/src/controllers/categories.js b/src/controllers/categories.js
index 65d3fcb4f5..640227fa9a 100644
--- a/src/controllers/categories.js
+++ b/src/controllers/categories.js
@@ -28,7 +28,7 @@ categoriesController.list = async function (req, res) {
const stop = start + meta.config.categoriesPerPage - 1;
const pageCids = rootCids.slice(start, stop + 1);
- const allChildCids = _.flatten(await Promise.all(pageCids.map(cid => categories.getChildrenCids(cid))));
+ const allChildCids = _.flatten(await Promise.all(pageCids.map(categories.getChildrenCids)));
const childCids = await privileges.categories.filterCids('find', allChildCids, req.uid);
const categoryData = await categories.getCategories(pageCids.concat(childCids), req.uid);
const tree = categories.getTree(categoryData, 0);
@@ -36,26 +36,15 @@ categoriesController.list = async function (req, res) {
const data = {
title: meta.config.homePageTitle || '[[pages:home]]',
+ selectCategoryLabel: '[[pages:categories]]',
categories: tree,
pagination: pagination.create(page, pageCount, req.query),
};
data.categories.forEach(function (category) {
if (category) {
- if (Array.isArray(category.children)) {
- category.children = category.children.slice(0, category.subCategoriesPerPage);
- category.children.forEach(function (child) {
- child.children = undefined;
- });
- }
- if (Array.isArray(category.posts) && category.posts.length && category.posts[0]) {
- category.teaser = {
- url: nconf.get('relative_path') + '/post/' + category.posts[0].pid,
- timestampISO: category.posts[0].timestampISO,
- pid: category.posts[0].pid,
- topic: category.posts[0].topic,
- };
- }
+ helpers.trimChildren(category);
+ helpers.setCategoryTeaser(category);
}
});
diff --git a/src/controllers/category.js b/src/controllers/category.js
index 09e9ce800b..13fcdd2edb 100644
--- a/src/controllers/category.js
+++ b/src/controllers/category.js
@@ -105,11 +105,15 @@ categoryController.get = async function (req, res, next) {
categoryData.nextSubCategoryStart = categoryData.subCategoriesPerPage;
categoryData.children = categoryData.children.slice(0, categoryData.subCategoriesPerPage);
categoryData.children.forEach(function (child) {
- child.children = undefined;
+ if (child) {
+ helpers.trimChildren(child);
+ helpers.setCategoryTeaser(child);
+ }
});
}
categoryData.title = translator.escape(categoryData.name);
+ categoryData.selectCategoryLabel = '[[category:subcategories]]';
categoryData.description = translator.escape(categoryData.description);
categoryData.privileges = userPrivileges;
categoryData.showSelect = userPrivileges.editable;
diff --git a/src/controllers/helpers.js b/src/controllers/helpers.js
index 5cd77f4487..65cced760b 100644
--- a/src/controllers/helpers.js
+++ b/src/controllers/helpers.js
@@ -244,36 +244,9 @@ async function getCategoryData(cids, uid, selectedCid, states, privilege) {
selectedCid = [selectedCid];
}
selectedCid = selectedCid && selectedCid.map(String);
- states = states || [categories.watchStates.watching, categories.watchStates.notwatching];
- const [allowed, watchState, categoryData, isAdmin] = await Promise.all([
- privileges.categories.isUserAllowedTo(privilege, cids, uid),
- categories.getWatchState(cids, uid),
- categories.getCategoriesData(cids),
- user.isAdministrator(uid),
- ]);
-
- categories.getTree(categoryData);
-
- const cidToAllowed = _.zipObject(cids, allowed.map(allowed => isAdmin || allowed));
- const cidToCategory = _.zipObject(cids, categoryData);
- const cidToWatchState = _.zipObject(cids, watchState);
-
- const visibleCategories = categoryData.filter(function (c) {
- const hasVisibleChildren = checkVisibleChildren(c, cidToAllowed, cidToWatchState, states);
- const isCategoryVisible = c && cidToAllowed[c.cid] && !c.link && !c.disabled && states.includes(cidToWatchState[c.cid]);
- const shouldBeRemoved = !hasVisibleChildren && !isCategoryVisible;
- const shouldBeDisaplayedAsDisabled = hasVisibleChildren && !isCategoryVisible;
-
- if (shouldBeDisaplayedAsDisabled) {
- c.disabledClass = true;
- }
-
- if (shouldBeRemoved && c && c.parent && c.parent.cid && cidToCategory[c.parent.cid]) {
- cidToCategory[c.parent.cid].children = cidToCategory[c.parent.cid].children.filter(child => child.cid !== c.cid);
- }
-
- return c && !shouldBeRemoved;
+ const visibleCategories = await helpers.getVisibleCategories({
+ cids, uid, states, privilege, showLinks: false,
});
const categoriesData = categories.buildForSelectCategories(visibleCategories, ['disabledClass']);
@@ -308,26 +281,112 @@ async function getCategoryData(cids, uid, selectedCid, states, privilege) {
};
}
+helpers.getVisibleCategories = async function (params) {
+ const cids = params.cids;
+ const uid = params.uid;
+ const states = params.states || [categories.watchStates.watching, categories.watchStates.notwatching];
+ const privilege = params.privilege;
+ const showLinks = !!params.showLinks;
+
+ let [allowed, watchState, categoriesData, isAdmin, isModerator] = await Promise.all([
+ privileges.categories.isUserAllowedTo(privilege, cids, uid),
+ categories.getWatchState(cids, uid),
+ categories.getCategoriesData(cids),
+ user.isAdministrator(uid),
+ user.isModerator(uid, cids),
+ ]);
+
+ const filtered = await plugins.hooks.fire('filter:helpers.getVisibleCategories', {
+ uid: uid,
+ allowed: allowed,
+ watchState: watchState,
+ categoriesData: categoriesData,
+ isModerator: isModerator,
+ isAdmin: isAdmin,
+ });
+ ({ allowed, watchState, categoriesData, isModerator, isAdmin } = filtered);
+
+ categories.getTree(categoriesData, params.parentCid);
+
+ const cidToAllowed = _.zipObject(cids, allowed.map((allowed, i) => isAdmin || isModerator[i] || allowed));
+ const cidToCategory = _.zipObject(cids, categoriesData);
+ const cidToWatchState = _.zipObject(cids, watchState);
+
+ return categoriesData.filter(function (c) {
+ if (!c) {
+ return false;
+ }
+ const hasVisibleChildren = checkVisibleChildren(c, cidToAllowed, cidToWatchState, states);
+ const isCategoryVisible = cidToAllowed[c.cid] && (showLinks || !c.link) && !c.disabled && states.includes(cidToWatchState[c.cid]);
+ const shouldBeRemoved = !hasVisibleChildren && !isCategoryVisible;
+ const shouldBeDisaplayedAsDisabled = hasVisibleChildren && !isCategoryVisible;
+
+ if (shouldBeDisaplayedAsDisabled) {
+ c.disabledClass = true;
+ }
+
+ if (shouldBeRemoved && c.parent && c.parent.cid && cidToCategory[c.parent.cid]) {
+ cidToCategory[c.parent.cid].children = cidToCategory[c.parent.cid].children.filter(child => child.cid !== c.cid);
+ }
+
+ return !shouldBeRemoved;
+ });
+};
+
+helpers.getSelectedCategory = async function (cid) {
+ if (cid && !Array.isArray(cid)) {
+ cid = [cid];
+ }
+ cid = cid && cid.map(cid => parseInt(cid, 10));
+ let selectedCategories = await categories.getCategoriesData(cid);
+
+ if (selectedCategories.length > 1) {
+ selectedCategories = {
+ icon: 'fa-plus',
+ name: '[[unread:multiple-categories-selected]]',
+ bgColor: '#ddd',
+ };
+ } else if (selectedCategories.length === 1) {
+ selectedCategories = selectedCategories[0];
+ } else {
+ selectedCategories = null;
+ }
+ return {
+ selectedCids: cid || [],
+ selectedCategory: selectedCategories,
+ };
+};
+
+helpers.trimChildren = function (category) {
+ if (Array.isArray(category.children)) {
+ category.children = category.children.slice(0, category.subCategoriesPerPage);
+ category.children.forEach(function (child) {
+ child.children = undefined;
+ });
+ }
+};
+
+helpers.setCategoryTeaser = function (category) {
+ if (Array.isArray(category.posts) && category.posts.length && category.posts[0]) {
+ category.teaser = {
+ url: nconf.get('relative_path') + '/post/' + category.posts[0].pid,
+ timestampISO: category.posts[0].timestampISO,
+ pid: category.posts[0].pid,
+ topic: category.posts[0].topic,
+ };
+ }
+};
+
function checkVisibleChildren(c, cidToAllowed, cidToWatchState, states) {
if (!c || !Array.isArray(c.children)) {
return false;
}
- return c.children.some(c => c && !c.disabled && (
+ return c.children.some(c => !c.disabled && (
(cidToAllowed[c.cid] && states.includes(cidToWatchState[c.cid])) || checkVisibleChildren(c, cidToAllowed, cidToWatchState, states)
));
}
helpers.getHomePageRoutes = async function (uid) {
- let cids = await categories.getAllCidsFromSet('categories:cid');
- cids = await privileges.categories.filterCids('find', cids, uid);
- const categoryData = await categories.getCategoriesFields(cids, ['name', 'slug']);
-
- const categoryRoutes = categoryData.map(function (category) {
- return {
- route: 'category/' + category.slug,
- name: 'Category: ' + category.name,
- };
- });
const routes = [
{
route: 'categories',
@@ -349,13 +408,15 @@ helpers.getHomePageRoutes = async function (uid) {
route: 'popular',
name: 'Popular',
},
- ].concat(categoryRoutes, [
{
route: 'custom',
name: 'Custom',
},
- ]);
- const data = await plugins.hooks.fire('filter:homepage.get', { routes: routes });
+ ];
+ const data = await plugins.hooks.fire('filter:homepage.get', {
+ uid: uid,
+ routes: routes,
+ });
return data.routes;
};
diff --git a/src/controllers/mods.js b/src/controllers/mods.js
index 946b188e51..26d70ea090 100644
--- a/src/controllers/mods.js
+++ b/src/controllers/mods.js
@@ -21,12 +21,6 @@ modsController.flags.list = async function (req, res, next) {
const validFilters = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'cid', 'quick', 'page', 'perPage'];
const validSorts = ['newest', 'oldest', 'reports', 'upvotes', 'downvotes', 'replies'];
- // Reset filters if explicitly requested
- if (parseInt(req.query.reset, 10) === 1) {
- delete req.session.flags_filters;
- delete req.session.flags_sort;
- }
-
const results = await Promise.all([
user.isAdminOrGlobalMod(req.uid),
user.getModeratedCids(req.uid),
@@ -41,30 +35,21 @@ modsController.flags.list = async function (req, res, next) {
}
if (!isAdminOrGlobalMod && moderatedCids.length) {
- res.locals.cids = moderatedCids;
+ res.locals.cids = moderatedCids.map(cid => String(cid));
}
// Parse query string params for filters, eliminate non-valid filters
filters = filters.reduce(function (memo, cur) {
if (req.query.hasOwnProperty(cur)) {
- if (req.query[cur] === '') {
- if (req.session.hasOwnProperty('flags_filters')) {
- delete req.session.flags_filters[cur];
- }
- } else {
+ if (req.query[cur] !== '') {
memo[cur] = req.query[cur];
}
}
return memo;
}, {});
- let hasFilter = !!Object.keys(filters).length;
- if (!hasFilter && req.session.hasOwnProperty('flags_filters')) {
- // Load filters from session object
- filters = req.session.flags_filters;
- hasFilter = true;
- }
+ let hasFilter = !!Object.keys(filters).length;
if (res.locals.cids) {
if (!filters.cid) {
@@ -89,9 +74,7 @@ modsController.flags.list = async function (req, res, next) {
// Parse sort from query string
let sort;
- if (!req.query.sort && req.session.hasOwnProperty('flags_sort')) {
- sort = req.session.flags_sort;
- } else {
+ if (req.query.sort) {
sort = sorts.includes(req.query.sort) ? req.query.sort : null;
}
if (sort === 'newest') {
@@ -99,26 +82,20 @@ modsController.flags.list = async function (req, res, next) {
}
hasFilter = hasFilter || !!sort;
- // Save filters and sorting into session unless removed
- if (hasFilter) {
- req.session.flags_filters = filters;
- }
- req.session.flags_sort = sort;
-
- const [flagsData, analyticsData, categoriesData] = await Promise.all([
+ const [flagsData, analyticsData, selectData] = await Promise.all([
flags.list({
filters: filters,
sort: sort,
uid: req.uid,
}),
analytics.getDailyStatsForSet('analytics:flags', Date.now(), 30),
- categories.buildForSelect(req.uid, 'read'),
+ helpers.getSelectedCategory(filters.cid),
]);
res.render('flags/list', {
flags: flagsData.flags,
analytics: analyticsData,
- categories: filterCategories(res.locals.cids, categoriesData),
+ selectedCategory: selectData.selectedCategory,
hasFilter: hasFilter,
filters: filters,
sort: sort || 'newest',
@@ -170,27 +147,6 @@ modsController.flags.detail = async function (req, res, next) {
}));
};
-function filterCategories(moderatedCids, categories) {
- // If cids is populated, then slim down the categories list
- if (moderatedCids) {
- categories = categories.filter(category => moderatedCids.includes(String(category.cid)));
- }
-
- return categories.reduce(function (memo, cur) {
- if (!moderatedCids) {
- memo[cur.cid] = cur.name;
- return memo;
- }
-
- // If mod, remove categories they can't moderate
- if (moderatedCids.includes(String(cur.cid))) {
- memo[cur.cid] = cur.name;
- }
-
- return memo;
- }, {});
-}
-
modsController.postQueue = async function (req, res, next) {
// Admins, global mods, and individual mods only
const isPrivileged = await user.isPrivileged(req.uid);
@@ -201,20 +157,16 @@ modsController.postQueue = async function (req, res, next) {
const page = parseInt(req.query.page, 10) || 1;
const postsPerPage = 20;
- const [ids, isAdminOrGlobalMod, moderatedCids, allCategories, categoriesData] = await Promise.all([
+ const [ids, isAdminOrGlobalMod, moderatedCids, categoriesData] = await Promise.all([
db.getSortedSetRange('post:queue', 0, -1),
user.isAdminOrGlobalMod(req.uid),
user.getModeratedCids(req.uid),
- categories.buildForSelect(req.uid, 'find', ['disabled', 'link', 'slug']),
- helpers.getCategoriesByStates(req.uid, cid, null, 'moderate'),
+ helpers.getSelectedCategory(cid),
]);
if (cid && !moderatedCids.includes(String(cid)) && !isAdminOrGlobalMod) {
return next();
}
- allCategories.forEach((c) => {
- c.disabledClass = !isAdminOrGlobalMod && !moderatedCids.includes(String(c.cid));
- });
let postData = await getQueuedPosts(ids);
postData = postData.filter(p => p &&
@@ -234,7 +186,6 @@ modsController.postQueue = async function (req, res, next) {
res.render('post-queue', {
title: '[[pages:post-queue]]',
posts: postData,
- allCategories: allCategories,
...categoriesData,
allCategoriesUrl: 'post-queue' + helpers.buildQueryString(req.query, 'cid', ''),
pagination: pagination.create(page, pageCount),
diff --git a/src/controllers/recent.js b/src/controllers/recent.js
index c40795b267..1945b3d538 100644
--- a/src/controllers/recent.js
+++ b/src/controllers/recent.js
@@ -32,14 +32,9 @@ recentController.getData = async function (req, url, sort) {
}
term = term || 'alltime';
- const states = [categories.watchStates.watching, categories.watchStates.notwatching];
- if (filter === 'watched') {
- states.push(categories.watchStates.ignoring);
- }
-
const [settings, categoryData, rssToken, canPost, isPrivileged] = await Promise.all([
user.getSettings(req.uid),
- helpers.getCategoriesByStates(req.uid, cid, states),
+ helpers.getSelectedCategory(cid),
user.auth.getFeedToken(req.uid),
canPostTopic(req.uid),
user.isPrivileged(req.uid),
@@ -49,7 +44,7 @@ recentController.getData = async function (req, url, sort) {
const stop = start + settings.topicsPerPage - 1;
const data = await topics.getSortedTopics({
- cids: cid || categoryData.categories.map(c => c.cid),
+ cids: cid,
uid: req.uid,
start: start,
stop: stop,
@@ -63,7 +58,6 @@ recentController.getData = async function (req, url, sort) {
data.canPost = canPost;
data.showSelect = isPrivileged;
data.showTopicTools = isPrivileged;
- data.categories = categoryData.categories;
data.allCategoriesUrl = url + helpers.buildQueryString(req.query, 'cid', '');
data.selectedCategory = categoryData.selectedCategory;
data.selectedCids = categoryData.selectedCids;
diff --git a/src/controllers/tags.js b/src/controllers/tags.js
index 5d01f365fe..4573afcd20 100644
--- a/src/controllers/tags.js
+++ b/src/controllers/tags.js
@@ -30,16 +30,12 @@ tagsController.getTag = async function (req, res) {
]);
const start = Math.max(0, (page - 1) * settings.topicsPerPage);
const stop = start + settings.topicsPerPage - 1;
- const states = [categories.watchStates.watching, categories.watchStates.notwatching, categories.watchStates.ignoring];
- const [topicCount, tids, categoriesData] = await Promise.all([
+ const [topicCount, tids] = await Promise.all([
topics.getTagTopicCount(tag, cids),
topics.getTagTidsByCids(tag, cids, start, stop),
- helpers.getCategoriesByStates(req.uid, '', states),
]);
- templateData.categories = categoriesData.categories;
-
templateData.topics = await topics.getTopics(tids, req.uid);
topics.calculateTopicIndices(templateData.topics, start);
res.locals.metaTags = [
diff --git a/src/controllers/unread.js b/src/controllers/unread.js
index ff155b72a8..b7dd9a18a7 100644
--- a/src/controllers/unread.js
+++ b/src/controllers/unread.js
@@ -7,9 +7,7 @@ const querystring = require('querystring');
const meta = require('../meta');
const pagination = require('../pagination');
const user = require('../user');
-const categories = require('../categories');
const topics = require('../topics');
-const plugins = require('../plugins');
const helpers = require('./helpers');
const unreadController = module.exports;
@@ -18,8 +16,8 @@ unreadController.get = async function (req, res) {
const cid = req.query.cid;
const filter = req.query.filter || '';
- const [watchedCategories, userSettings, isPrivileged] = await Promise.all([
- getWatchedCategories(req.uid, cid, filter),
+ const [categoryData, userSettings, isPrivileged] = await Promise.all([
+ helpers.getSelectedCategory(cid),
user.getSettings(req.uid),
user.isPrivileged(req.uid),
]);
@@ -47,10 +45,9 @@ unreadController.get = async function (req, res) {
}
data.showSelect = true;
data.showTopicTools = isPrivileged;
- data.categories = watchedCategories.categories;
data.allCategoriesUrl = 'unread' + helpers.buildQueryString(req.query, 'cid', '');
- data.selectedCategory = watchedCategories.selectedCategory;
- data.selectedCids = watchedCategories.selectedCids;
+ data.selectedCategory = categoryData.selectedCategory;
+ data.selectedCids = categoryData.selectedCids;
if (req.originalUrl.startsWith(nconf.get('relative_path') + '/api/unread') || req.originalUrl.startsWith(nconf.get('relative_path') + '/unread')) {
data.title = '[[pages:unread]]';
data.breadcrumbs = helpers.buildBreadcrumbs([{ text: '[[unread:title]]' }]);
@@ -63,17 +60,6 @@ unreadController.get = async function (req, res) {
res.render('unread', data);
};
-async function getWatchedCategories(uid, cid, filter) {
- if (plugins.hooks.hasListeners('filter:unread.categories')) {
- return await plugins.hooks.fire('filter:unread.categories', { uid: uid, cid: cid });
- }
- const states = [categories.watchStates.watching];
- if (filter === 'watched') {
- states.push(categories.watchStates.notwatching, categories.watchStates.ignoring);
- }
- return await helpers.getCategoriesByStates(uid, cid, states);
-}
-
unreadController.unreadTotal = async function (req, res, next) {
const filter = req.query.filter || '';
try {
diff --git a/src/database/mongo/hash.js b/src/database/mongo/hash.js
index 1997d0b02d..ba89a516d1 100644
--- a/src/database/mongo/hash.js
+++ b/src/database/mongo/hash.js
@@ -32,6 +32,26 @@ module.exports = function (module) {
cache.del(key);
};
+ module.setObjectBulk = async function (keys, data) {
+ if (!keys.length || !data.length) {
+ return;
+ }
+
+ const writeData = data.map(helpers.serializeData);
+ try {
+ const bulk = module.client.collection('objects').initializeUnorderedBulkOp();
+ keys.forEach((key, i) => bulk.find({ _key: key }).upsert().updateOne({ $set: writeData[i] }));
+ await bulk.execute();
+ } catch (err) {
+ if (err && err.message.startsWith('E11000 duplicate key error')) {
+ return await module.setObjectBulk(keys, data);
+ }
+ throw err;
+ }
+
+ cache.del(keys);
+ };
+
module.setObjectField = async function (key, field, value) {
if (!field) {
return;
diff --git a/src/database/postgres/hash.js b/src/database/postgres/hash.js
index a1f91d5dab..9872b86794 100644
--- a/src/database/postgres/hash.js
+++ b/src/database/postgres/hash.js
@@ -34,6 +34,14 @@ module.exports = function (module) {
});
};
+ module.setObjectBulk = async function (keys, data) {
+ if (!keys.length || !data.length) {
+ return;
+ }
+ // TODO: single query?
+ await Promise.all(keys.map((k, i) => module.setObject(k, data[i])));
+ };
+
module.setObjectField = async function (key, field, value) {
if (!field) {
return;
diff --git a/src/database/redis/hash.js b/src/database/redis/hash.js
index b495c7d44a..583cce6613 100644
--- a/src/database/redis/hash.js
+++ b/src/database/redis/hash.js
@@ -36,6 +36,16 @@ module.exports = function (module) {
cache.del(key);
};
+ module.setObjectBulk = async function (keys, data) {
+ if (!keys.length || !data.length) {
+ return;
+ }
+ const batch = module.client.batch();
+ keys.forEach((k, i) => batch.hmset(k, data[i]));
+ await helpers.execBatch(batch);
+ cache.del(keys);
+ };
+
module.setObjectField = async function (key, field, value) {
if (!field) {
return;
diff --git a/src/groups/data.js b/src/groups/data.js
index ae518c27ca..ea77eea7e1 100644
--- a/src/groups/data.js
+++ b/src/groups/data.js
@@ -76,7 +76,8 @@ function modifyGroup(group, fields) {
group.icon = validator.escape(String(group.icon || ''));
group.createtimeISO = utils.toISOString(group.createtime);
group.private = ([null, undefined].includes(group.private)) ? 1 : group.private;
- group.memberPostCids = (group.memberPostCids || '').split(',').map(cid => parseInt(cid, 10)).filter(Boolean);
+ group.memberPostCids = group.memberPostCids || '';
+ group.memberPostCidsArray = group.memberPostCids.split(',').map(cid => parseInt(cid, 10)).filter(Boolean);
group['cover:thumb:url'] = group['cover:thumb:url'] || group['cover:url'];
diff --git a/src/groups/index.js b/src/groups/index.js
index 3ea83f9c4d..36d8f8ff65 100644
--- a/src/groups/index.js
+++ b/src/groups/index.js
@@ -1,7 +1,6 @@
'use strict';
const user = require('../user');
-const categories = require('../categories');
const db = require('../database');
const plugins = require('../plugins');
const slugify = require('../slugify');
@@ -121,10 +120,9 @@ Groups.get = async function (groupName, options) {
stop = (parseInt(options.userListCount, 10) || 4) - 1;
}
- const [groupData, members, selectCategories, pending, invited, isMember, isPending, isInvited, isOwner] = await Promise.all([
+ const [groupData, members, pending, invited, isMember, isPending, isInvited, isOwner] = await Promise.all([
Groups.getGroupData(groupName),
Groups.getOwnersAndMembers(groupName, options.uid, 0, stop),
- categories.buildForSelect(groupName, 'topics:read', []),
Groups.getUsersFromSet('group:' + groupName + ':pending', ['username', 'userslug', 'picture']),
Groups.getUsersFromSet('group:' + groupName + ':invited', ['username', 'userslug', 'picture']),
Groups.isMember(options.uid, groupName),
@@ -138,10 +136,6 @@ Groups.get = async function (groupName, options) {
}
const descriptionParsed = await plugins.hooks.fire('filter:parse.raw', groupData.description);
groupData.descriptionParsed = descriptionParsed;
- groupData.categories = selectCategories.map((category) => {
- category.selected = groupData.memberPostCids.includes(category.cid);
- return category;
- });
groupData.members = members;
groupData.membersNextStart = stop + 1;
groupData.pending = pending.filter(Boolean);
diff --git a/src/groups/posts.js b/src/groups/posts.js
index 465f060b4b..f07b20deaa 100644
--- a/src/groups/posts.js
+++ b/src/groups/posts.js
@@ -15,8 +15,10 @@ module.exports = function (Groups) {
groupNames = groupNames[0];
// Only process those groups that have the cid in its memberPostCids setting (or no setting at all)
- const groupsCids = await groups.getGroupsFields(groupNames, ['memberPostCids']);
- groupNames = groupNames.filter((groupName, idx) => !groupsCids[idx].memberPostCids.length || groupsCids[idx].memberPostCids.includes(postData.cid));
+ const groupData = await groups.getGroupsFields(groupNames, ['memberPostCids']);
+ groupNames = groupNames.filter(
+ (groupName, idx) => !groupData[idx].memberPostCidsArray.length || groupData[idx].memberPostCidsArray.includes(postData.cid)
+ );
const keys = groupNames.map(groupName => 'group:' + groupName + ':member:pids');
await db.sortedSetsAdd(keys, postData.timestamp, postData.pid);
diff --git a/src/groups/update.js b/src/groups/update.js
index bced8783df..88c30c8515 100644
--- a/src/groups/update.js
+++ b/src/groups/update.js
@@ -77,7 +77,8 @@ module.exports = function (Groups) {
if (values.hasOwnProperty('memberPostCids')) {
const validCids = await categories.getCidsByPrivilege('categories:cid', groupName, 'topics:read');
- payload.memberPostCids = values.memberPostCids.filter(cid => validCids.includes(cid)).join(',') || '';
+ const cidsArray = values.memberPostCids.split(',').map(cid => parseInt(cid.trim(), 10)).filter(Boolean);
+ payload.memberPostCids = cidsArray.filter(cid => validCids.includes(cid)).join(',') || '';
}
await db.setObject('group:' + groupName, payload);
diff --git a/src/privileges/categories.js b/src/privileges/categories.js
index 188fb10ac6..923306eb55 100644
--- a/src/privileges/categories.js
+++ b/src/privileges/categories.js
@@ -77,6 +77,9 @@ module.exports = function (privileges) {
};
privileges.categories.isUserAllowedTo = async function (privilege, cid, uid) {
+ if ((Array.isArray(privilege) && !privilege.length) || (Array.isArray(cid) && !cid.length)) {
+ return [];
+ }
if (!cid) {
return false;
}
diff --git a/src/socket.io/categories.js b/src/socket.io/categories.js
index d9fdb25502..b9121d2d9b 100644
--- a/src/socket.io/categories.js
+++ b/src/socket.io/categories.js
@@ -9,6 +9,8 @@ const sockets = require('.');
const SocketCategories = module.exports;
+require('./categories/search')(SocketCategories);
+
SocketCategories.getRecentReplies = async function (socket, cid) {
return await categories.getRecentReplies(cid, socket.uid, 4);
};
@@ -148,7 +150,7 @@ SocketCategories.isModerator = async function (socket, cid) {
};
SocketCategories.getCategory = async function (socket, cid) {
- sockets.warnDeprecated(socket, 'GET /api/v3/categories/:tid');
+ sockets.warnDeprecated(socket, 'GET /api/v3/categories/:cid');
return await api.categories.get(socket, { cid });
// return await apiController.getCategoryData(cid, socket.uid);
};
diff --git a/src/socket.io/categories/search.js b/src/socket.io/categories/search.js
new file mode 100644
index 0000000000..85defb6748
--- /dev/null
+++ b/src/socket.io/categories/search.js
@@ -0,0 +1,94 @@
+'use strict';
+
+const _ = require('lodash');
+
+const meta = require('../../meta');
+const categories = require('../../categories');
+const privileges = require('../../privileges');
+const controllersHelpers = require('../../controllers/helpers');
+
+module.exports = function (SocketCategories) {
+ // used by categorySeach module
+ SocketCategories.categorySearch = async function (socket, data) {
+ let cids = [];
+ let matchedCids = [];
+ const privilege = data.privilege || 'topics:read';
+ data.states = (data.states || ['watching', 'notwatching', 'ignoring']).map(
+ state => categories.watchStates[state]
+ );
+
+ if (data.query) {
+ ({ cids, matchedCids } = await findMatchedCids(socket.uid, data));
+ } else {
+ cids = await loadCids(socket.uid, data.parentCid);
+ }
+
+ const visibleCategories = await controllersHelpers.getVisibleCategories({
+ cids, uid: socket.uid, states: data.states, privilege, showLinks: data.showLinks, parentCid: data.parentCid,
+ });
+
+ if (Array.isArray(data.selectedCids)) {
+ data.selectedCids = data.selectedCids.map(cid => parseInt(cid, 10));
+ }
+
+ let categoriesData = categories.buildForSelectCategories(visibleCategories, ['disabledClass'], data.parentCid);
+ categoriesData = categoriesData.slice(0, 200);
+
+ categoriesData.forEach(function (category) {
+ category.selected = data.selectedCids ? data.selectedCids.includes(category.cid) : false;
+ if (matchedCids.includes(category.cid)) {
+ category.match = true;
+ }
+ });
+ return categoriesData;
+ };
+
+ async function findMatchedCids(uid, data) {
+ const result = await categories.search({
+ query: data.query,
+ paginate: false,
+ });
+
+
+ let matchedCids = result.categories.map(c => c.cid);
+ // no need to filter if all 3 states are used
+ const filterByWatchState = !Object.values(categories.watchStates)
+ .every(state => data.states.includes(state));
+
+ if (filterByWatchState) {
+ const states = await categories.getWatchState(matchedCids, uid);
+ matchedCids = matchedCids.filter((cid, index) => data.states.includes(states[index]));
+ }
+
+ const rootCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getParentCids))));
+ const allChildCids = _.uniq(_.flatten(await Promise.all(matchedCids.map(categories.getChildrenCids))));
+
+ return {
+ cids: _.uniq(rootCids.concat(allChildCids).concat(matchedCids)),
+ matchedCids: matchedCids,
+ };
+ }
+
+ async function loadCids(uid, parentCid) {
+ let resultCids = [];
+ async function getCidsRecursive(cids) {
+ const categoryData = await categories.getCategoriesFields(cids, ['subCategoriesPerPage']);
+ const cidToData = _.zipObject(cids, categoryData);
+ await Promise.all(cids.map(async (cid) => {
+ const allChildCids = await categories.getAllCidsFromSet('cid:' + cid + ':children');
+ if (allChildCids.length) {
+ const childCids = await privileges.categories.filterCids('find', allChildCids, uid);
+ resultCids.push(...childCids.slice(0, cidToData[cid].subCategoriesPerPage));
+ await getCidsRecursive(childCids);
+ }
+ }));
+ }
+
+ const allRootCids = await categories.getAllCidsFromSet('cid:' + parentCid + ':children');
+ const rootCids = await privileges.categories.filterCids('find', allRootCids, uid);
+ const pageCids = rootCids.slice(0, meta.config.categoriesPerPage);
+ resultCids = pageCids;
+ await getCidsRecursive(pageCids);
+ return resultCids;
+ }
+};
diff --git a/src/user/bans.js b/src/user/bans.js
index aa7df700cf..40397fe7a8 100644
--- a/src/user/bans.js
+++ b/src/user/bans.js
@@ -11,8 +11,6 @@ const privileges = require('../privileges');
module.exports = function (User) {
User.bans = {};
- const systemGroups = groups.systemGroups.filter(group => group !== groups.BANNED_USERS);
-
User.bans.ban = async function (uid, until, reason) {
// "until" (optional) is unix timestamp in milliseconds
// "reason" (optional) is a string
@@ -37,6 +35,7 @@ module.exports = function (User) {
}
// Leaving all other system groups to have privileges constrained to the "banned-users" group
+ const systemGroups = groups.systemGroups.filter(group => group !== groups.BANNED_USERS);
await groups.leave(systemGroups, uid);
await groups.join(groups.BANNED_USERS, uid);
await db.sortedSetAdd('users:banned', now, uid);
diff --git a/src/views/admin/manage/admins-mods.tpl b/src/views/admin/manage/admins-mods.tpl
index 9e0ac321c1..e595f682db 100644
--- a/src/views/admin/manage/admins-mods.tpl
+++ b/src/views/admin/manage/admins-mods.tpl
@@ -38,26 +38,28 @@
- {{{ each categories }}}
-
[[groups:details.member-post-cids-help]]
+