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

[[admin/manage/admins-mods:moderators-of-category, {categories.name}]]{{{if categories.disabled}}}[[admin/manage/admins-mods:disabled]]{{{end}}}

-
- {{{ each categories.moderators }}} -
- - - -
{categories.moderators.icon:text}
- - {categories.moderators.username} + + + {{{ each categoryMods }}} +
+

{{{ if categoryMods.icon }}} {{{ end }}}[[admin/manage/admins-mods:moderators-of-category, {categoryMods.name}]]{{{if categoryMods.disabled}}}[[admin/manage/admins-mods:disabled]]{{{end}}}

+
+ {{{ each categoryMods.moderators }}} +
+ {{{ if categoryMods.moderators.picture }}} + + {{{ else }}} +
{categoryMods.moderators.icon:text}
+ {{{ end }}} + {categoryMods.moderators.username}
{{{ end }}}
-
[[admin/manage/admins-mods:no-moderators]]
+
[[admin/manage/admins-mods:no-moderators]]
- +

{{{ end }}} diff --git a/src/views/admin/manage/categories.tpl b/src/views/admin/manage/categories.tpl index e2d55c510a..608decb44e 100644 --- a/src/views/admin/manage/categories.tpl +++ b/src/views/admin/manage/categories.tpl @@ -1,11 +1,14 @@ +
-
- -
-
-
- - +
+
+ +
+
+ +
+
+
@@ -14,7 +17,9 @@
- +
+ +
\ No newline at end of file diff --git a/src/views/admin/manage/group.tpl b/src/views/admin/manage/group.tpl index ed37a7a8cc..0f63599a91 100644 --- a/src/views/admin/manage/group.tpl +++ b/src/views/admin/manage/group.tpl @@ -99,14 +99,14 @@
- -

[[groups:details.member-post-cids-help]]

+
+
+ +
+
+ +
+

@@ -143,7 +143,7 @@

-
+
[[admin/manage/privileges:edit-privileges]]
diff --git a/src/views/admin/partials/categories/category-rows.tpl b/src/views/admin/partials/categories/category-rows.tpl index 35b9a9636e..27fceb9370 100644 --- a/src/views/admin/partials/categories/category-rows.tpl +++ b/src/views/admin/partials/categories/category-rows.tpl @@ -41,6 +41,9 @@ {{{end}}} +
  • + [[admin/manage/categories:set-order]] +
  • diff --git a/src/views/admin/settings/homepage.tpl b/src/views/admin/settings/homepage.tpl index d8086a6b35..310185856b 100644 --- a/src/views/admin/settings/homepage.tpl +++ b/src/views/admin/settings/homepage.tpl @@ -5,7 +5,7 @@ [[admin/settings/homepage:description]]

    -
    +
    +

    [[user:custom_route_help]]


    diff --git a/test/controllers-admin.js b/test/controllers-admin.js index 3a37bbea51..32324df82c 100644 --- a/test/controllers-admin.js +++ b/test/controllers-admin.js @@ -585,9 +585,7 @@ describe('Admin Controllers', function () { assert.ifError(err); assert(body); assert(body.flags); - assert(body.categories); assert(body.filters); - assert.equal(body.categories[cid], 'Test Category'); assert.equal(body.filters.cid.indexOf(cid), -1); done(); }); diff --git a/test/database/hash.js b/test/database/hash.js index 2fb566d8a6..ef1eded4e9 100644 --- a/test/database/hash.js +++ b/test/database/hash.js @@ -71,6 +71,15 @@ describe('Hash methods', function () { }); }); }); + + it('should set multiple keys to different okjects', async function () { + const keys = ['bulkKey1', 'bulkKey2']; + const data = [{ foo: '1' }, { baz: 'baz' }]; + + await db.setObjectBulk(keys, data); + const result = await db.getObjects(keys); + assert.deepStrictEqual(result, data); + }); }); describe('setObjectField()', function () { diff --git a/test/mocks/databasemock.js b/test/mocks/databasemock.js index e51786258e..34d9cf58c2 100644 --- a/test/mocks/databasemock.js +++ b/test/mocks/databasemock.js @@ -40,6 +40,11 @@ const relativePath = urlObject.pathname !== '/' ? urlObject.pathname : ''; nconf.set('relative_path', relativePath); nconf.set('upload_path', path.join(nconf.get('base_dir'), nconf.get('upload_path'))); nconf.set('upload_url', '/assets/uploads'); +nconf.set('url_parsed', urlObject); +nconf.set('base_url', urlObject.protocol + '//' + urlObject.host); +nconf.set('secure', urlObject.protocol === 'https:'); +nconf.set('use_port', !!urlObject.port); +nconf.set('port', urlObject.port || nconf.get('port') || (nconf.get('PORT_ENV_VAR') ? nconf.get(nconf.get('PORT_ENV_VAR')) : false) || 4567); // cookies don't provide isolation by port: http://stackoverflow.com/a/16328399/122353 const domain = nconf.get('cookieDomain') || urlObject.hostname; @@ -118,11 +123,7 @@ before(async function () { // Parse out the relative_url and other goodies from the configured URL const urlObject = url.parse(nconf.get('url')); - nconf.set('url_parsed', urlObject); - nconf.set('base_url', urlObject.protocol + '//' + urlObject.host); - nconf.set('secure', urlObject.protocol === 'https:'); - nconf.set('use_port', !!urlObject.port); - nconf.set('port', urlObject.port || nconf.get('port') || (nconf.get('PORT_ENV_VAR') ? nconf.get(nconf.get('PORT_ENV_VAR')) : false) || 4567); + nconf.set('core_templates_path', path.join(__dirname, '../../src/views')); nconf.set('base_templates_path', path.join(nconf.get('themes_path'), 'nodebb-theme-persona/templates'));