Categories refactor (#9257)

* feat: wip categories pagination

* feat: add subCategoriesPerPage setting

* feat: add load more sub categories button to category page

* fix: openapi spec

* feat: show sub categories left on category page

hide button when no more categories left

* breaking: rename categories to allCategories on /search

categories contains the search results

* fix: spec

* refactor: remove cidsPerPage

* fix: tests

* feat: use component for subcategories

* fix: prevent negative subCategoriesLeft

* feat: new category filter/search WIP

* feat: remove categories from /tag

* fix: dont load all categories when showing move modal

* feat: allow adding custom categories to list

* breaking: dont load entire category tree on post queue

removed unused code
add hooks to filter/selector
add options to filter/selector

* feat: make selector modal work again

* feat: replace old search module

* fix: topic move selector

* feat: dont load all categories on create category modal

* fix: fix more categorySelectors

* feat: dont load entire category tree on group details page

* feat: dont load all categories on home page and user settings page

* feat: add pagination to /user/:userslug/categories

* fix: update schemas

* fix: more tests

* fix: test

* feat: flags page, dont return entire category tree

* fix: flag test

* feat: categories manage page

dont load all categories
allow changing root category
clear caches properly

* fix: spec

* feat: admins&mods page

dont load all categories

* fix: spec

* fix: dont load all children when opening dropdown

* fix: on search results dont return all children

* refactor: pass all options, rename options.cids to options.selectedCids

* fix: #9266

* fix: index 0

* fix: spec

* feat: #9265, add setObjectBulk

* refactor: shoter updateOrder

* feat: selectors on categories/category

* fix: tests and search filter

* fix: category update test

* feat: pagination on acp categories page

show order in set order modal

* fix: allow drag&drop on pages > 1 in /admin/manage/categories

* fix: teasers for deep nested categories

fix sub category display on /category page

* fix: spec

* refactor: use eslint-disable-next-line

* refactor: shorter
v1.18.x
Barış Soner Uşaklı 4 years ago committed by GitHub
parent 2cfab3678e
commit 47299ea587
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -19,6 +19,7 @@
"category-image": "Category Image", "category-image": "Category Image",
"parent-category": "Parent Category", "parent-category": "Parent Category",
"optional-parent-category": "(Optional) Parent Category", "optional-parent-category": "(Optional) Parent Category",
"top-level": "Top Level",
"parent-category-none": "(None)", "parent-category-none": "(None)",
"copy-parent": "Copy Parent", "copy-parent": "Copy Parent",
"copy-settings": "Copy Settings From", "copy-settings": "Copy Settings From",
@ -31,6 +32,7 @@
"edit": "Edit", "edit": "Edit",
"analytics": "Analytics", "analytics": "Analytics",
"view-category": "View category", "view-category": "View category",
"set-order": "Set order",
"select-category": "Select Category", "select-category": "Select Category",
"set-parent-category": "Set Parent Category", "set-parent-category": "Set Parent Category",

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

@ -40,8 +40,7 @@
"details.member_count": "Member Count", "details.member_count": "Member Count",
"details.creation_date": "Creation Date", "details.creation_date": "Creation Date",
"details.description": "Description", "details.description": "Description",
"details.member-post-cids": "Categories to display posts from", "details.member-post-cids": "Category IDs to display posts from",
"details.member-post-cids-help": "<strong>Note</strong>: Selecting no categories will assume all categories are included. Use <code>ctrl</code> and <code>shift</code> to select multiple options.",
"details.badge_preview": "Badge Preview", "details.badge_preview": "Badge Preview",
"details.change_icon": "Change Icon", "details.change_icon": "Change Icon",
"details.change_label_colour": "Change Label Colour", "details.change_label_colour": "Change Label Colour",

@ -149,7 +149,7 @@
"homepage": "Homepage", "homepage": "Homepage",
"homepage_description": "Select a page to use as the forum homepage or 'None' to use the default 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": "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.title": "Single Sign-on Services",
"sso.associated": "Associated with", "sso.associated": "Associated with",

@ -46,6 +46,8 @@ GroupFullObject:
type: string type: string
description: A six-character hexadecimal colour code description: A six-character hexadecimal colour code
memberPostCids: memberPostCids:
type: string
memberPostCidsArray:
type: array type: array
items: items:
type: number type: number
@ -64,32 +66,6 @@ GroupFullObject:
type: string type: string
descriptionParsed: descriptionParsed:
type: string 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: members:
type: array type: array
items: items:
@ -169,6 +145,8 @@ GroupDataObject:
cover:position: cover:position:
type: string type: string
memberPostCids: memberPostCids:
type: string
memberPostCidsArray:
type: array type: array
items: items:
type: number type: number

@ -15,41 +15,90 @@ get:
$ref: ../../../components/schemas/GroupObject.yaml#/GroupFullObject $ref: ../../../components/schemas/GroupObject.yaml#/GroupFullObject
globalMods: globalMods:
$ref: ../../../components/schemas/GroupObject.yaml#/GroupFullObject $ref: ../../../components/schemas/GroupObject.yaml#/GroupFullObject
categories: categoryMods:
type: array type: array
items: items:
type: object type: object
properties: properties:
cid: cid:
type: number type: number
description: A category identifier assigned upon category creation (this value cannot be changed)
name: name:
type: string type: string
level: description: The category's name/title
type: number description:
example: 0 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: icon:
type: string type: string
description: A FontAwesome icon 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: parentCid:
type: number type: number
description: The parent category's identifier description: The category identifier for the category that is the immediate ancestor of the current category
color: 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 type: string
description: A six-character hexadecimal colour code description: If set, attempting to access the forum will go to this external link instead (theme-specific)
bgColor: 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 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: imageClass:
type: string type: string
depth: enum: [auto, cover, contain]
description: The `background-position` of the category background image, if one is set
isSection:
type: number type: number
description: The depth of the category relative to the forum root (`0` is root level) minTags:
disabled: 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 type: number
description: The number of subcategories to display on the categories and category page
moderators: moderators:
type: array type: array
items: items:
$ref: ../../../components/schemas/UserObject.yaml#/UserObjectSlim $ref: ../../../components/schemas/UserObject.yaml#/UserObjectSlim
selectedCategory:
$ref: ../../../components/schemas/CategoryObject.yaml#/CategoryObject
allPrivileges: allPrivileges:
type: array type: array
items: items:

@ -11,7 +11,9 @@ get:
allOf: allOf:
- type : object - type : object
properties: properties:
categories: categoriesPerPage:
type: number
categoriesTree:
type: array type: array
items: items:
type: object type: object
@ -40,7 +42,12 @@ get:
nullable: true nullable: true
imageClass: imageClass:
type: string type: string
order:
type: number
subCategoriesPerPage:
type: number
children: children:
type: array type: array
description: Array of children categories description: Array of children categories
- $ref: ../../../components/schemas/Pagination.yaml#/Pagination
- $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps - $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps

@ -31,60 +31,8 @@ get:
type: string type: string
parent: parent:
$ref: ../../../../components/schemas/CategoryObject.yaml#/CategoryObject $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: selectedCategory:
type: object $ref: ../../../../components/schemas/CategoryObject.yaml#/CategoryObject
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
customClasses: customClasses:
type: array type: array
items: items:

@ -67,6 +67,8 @@ get:
ownerUid: ownerUid:
type: number type: number
memberPostCids: memberPostCids:
type: string
memberPostCidsArray:
type: array type: array
items: items:
type: number type: number
@ -93,36 +95,6 @@ get:
- textColor - textColor
- createtimeISO - createtimeISO
- cover:thumb:url - 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: yourid:
type: number type: number
- $ref: ../../../components/schemas/Pagination.yaml#/Pagination - $ref: ../../../components/schemas/Pagination.yaml#/Pagination

@ -31,36 +31,6 @@ get:
type: string type: string
selected: selected:
type: boolean 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: allowPrivateGroups:
type: number type: number
maximumGroupNameLength: maximumGroupNameLength:

@ -121,29 +121,7 @@ get:
- icon - icon
- selected - selected
selectedCategory: selectedCategory:
type: object $ref: ../../../../components/schemas/CategoryObject.yaml#/CategoryObject
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
cid: cid:
type: number type: number
description: A category identifier description: A category identifier

@ -23,6 +23,9 @@ get:
title: title:
description: The page title description: The page title
type: string type: string
selectCategoryLabel:
type: string
description: Label to use for the category selector
categories: categories:
description: A collection of category data objects description: A collection of category data objects
type: array type: array
@ -112,10 +115,6 @@ get:
cid: cid:
type: number type: number
description: A category identifier description: A category identifier
parentCid:
type: number
description: The category identifier for the category that is the immediate
ancestor of the current category
topic: topic:
type: object type: object
properties: properties:
@ -177,10 +176,6 @@ get:
cid: cid:
type: number type: number
description: A category identifier description: A category identifier
parentCid:
type: number
description: The category identifier for the category that is the immediate
ancestor of the current category
topic: topic:
type: object type: object
properties: properties:

@ -58,6 +58,9 @@ get:
type: boolean type: boolean
title: title:
type: string type: string
selectCategoryLabel:
type: string
description: Label to use for the category selector
privileges: privileges:
type: object type: object
properties: properties:

@ -46,12 +46,6 @@ get:
type: array type: array
items: items:
type: number 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: hasFilter:
type: boolean type: boolean
filters: filters:
@ -65,6 +59,16 @@ get:
type: string type: string
title: title:
type: string 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/Pagination.yaml#/Pagination
- $ref: ../components/schemas/Breadcrumbs.yaml#/Breadcrumbs - $ref: ../components/schemas/Breadcrumbs.yaml#/Breadcrumbs
- $ref: ../components/schemas/CommonProps.yaml#/CommonProps - $ref: ../components/schemas/CommonProps.yaml#/CommonProps

@ -61,6 +61,8 @@ get:
cover:position: cover:position:
type: string type: string
memberPostCids: memberPostCids:
type: string
memberPostCidsArray:
type: array type: array
items: items:
type: number type: number

@ -18,6 +18,9 @@ get:
title: title:
type: string type: string
description: The page title description: The page title
selectCategoryLabel:
type: string
description: Label to use for the category selector
categories: categories:
description: A collection of category data objects description: A collection of category data objects
type: array type: array
@ -175,10 +178,6 @@ get:
cid: cid:
type: number type: number
description: A category identifier description: A category identifier
parentCid:
type: number
description: The category identifier for the category that is the immediate
ancestor of the current category
topic: topic:
type: object type: object
properties: properties:

@ -32,32 +32,6 @@ get:
type: boolean type: boolean
showTopicTools: showTopicTools:
type: boolean 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: allCategoriesUrl:
type: string type: string
selectedCategory: selectedCategory:

@ -13,70 +13,6 @@ get:
properties: properties:
title: title:
type: string 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: allCategoriesUrl:
type: string type: string
selectedCategory: selectedCategory:

@ -30,32 +30,6 @@ get:
type: boolean type: boolean
showTopicTools: showTopicTools:
type: boolean 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: allCategoriesUrl:
type: string type: string
selectedCategory: selectedCategory:

@ -227,32 +227,6 @@ get:
type: string type: string
title: title:
type: string 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: rssFeedUrl:
type: string type: string
feeds:disableRSS: feeds:disableRSS:

@ -30,32 +30,6 @@ get:
type: boolean type: boolean
showTopicTools: showTopicTools:
type: boolean 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: allCategoriesUrl:
type: string type: string
selectedCategory: selectedCategory:

@ -200,32 +200,6 @@ get:
type: string type: string
pageCount: pageCount:
type: number 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: allCategoriesUrl:
type: string type: string
selectedCategory: selectedCategory:

@ -58,5 +58,6 @@ get:
type: string type: string
title: title:
type: string type: string
- $ref: ../../../components/schemas/Pagination.yaml#/Pagination
- $ref: ../../../components/schemas/Breadcrumbs.yaml#/Breadcrumbs - $ref: ../../../components/schemas/Breadcrumbs.yaml#/Breadcrumbs
- $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps - $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps

@ -1,8 +1,8 @@
'use strict'; 'use strict';
define('admin/manage/admins-mods', [ define('admin/manage/admins-mods', [
'translator', 'benchpress', 'autocomplete', 'api', 'bootbox', 'autocomplete', 'api', 'bootbox', 'categorySelector',
], function (translator, Benchpress, autocomplete, api, bootbox) { ], function (autocomplete, api, bootbox, categorySelector) {
var AdminsMods = {}; var AdminsMods = {};
AdminsMods.init = function () { 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) { autocomplete.user($('.moderator-search'), function (ev, ui) {
var input = $(ev.target); var input = $(ev.target);
var cid = $(ev.target).attr('data-cid'); var cid = $(ev.target).attr('data-cid');

@ -6,13 +6,21 @@ define('admin/manage/categories', [
'categorySelector', 'categorySelector',
'api', 'api',
'Sortable', 'Sortable',
], function (translator, Benchpress, categorySelector, api, Sortable) { 'bootbox',
], function (translator, Benchpress, categorySelector, api, Sortable, bootbox) {
var Categories = {}; var Categories = {};
var newCategoryId = -1; var newCategoryId = -1;
var sortables; var sortables;
Categories.init = function () { 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); $('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'); 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: '<input class="form-control input-lg" value=' + order + ' />',
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 () { $('#collapse-all').on('click', function () {
toggleAll(false); toggleAll(false);
}); });
@ -49,110 +85,58 @@ define('admin/manage/categories', [
el.find('i').toggleClass('fa-minus', expand).toggleClass('fa-plus', !expand); el.find('i').toggleClass('fa-minus', expand).toggleClass('fa-plus', !expand);
el.closest('[data-cid]').find('> ul[data-cid]').toggleClass('hidden', !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 () { Categories.throwCreateModal = function () {
socket.emit('categories.getSelectCategories', {}, function (err, categories) { Benchpress.render('admin/partials/categories/create', {}).then(function (html) {
if (err) { var modal = bootbox.dialog({
return app.alertError(err.message); title: '[[admin/manage/categories:alert.create]]',
} message: html,
buttons: {
categories.unshift({ save: {
cid: 0, label: '[[global:save]]',
name: '[[admin/manage/categories:parent-category-none]]', className: 'btn-primary',
icon: 'fa-none', callback: submit,
},
},
}); });
Benchpress.render('admin/partials/categories/create', { var options = {
categories: categories, localCategories: [
}).then(function (html) { {
var modal = bootbox.dialog({ cid: 0,
title: '[[admin/manage/categories:alert.create]]', name: '[[admin/manage/categories:parent-category-none]]',
message: html, icon: 'fa-none',
buttons: {
save: {
label: '[[global:save]]',
className: 'btn-primary',
callback: submit,
},
}, },
}); ],
};
var parentSelector = categorySelector.init(modal.find('#parentCidGroup [component="category-selector"]')); var parentSelector = categorySelector.init(modal.find('#parentCidGroup [component="category-selector"]'), options);
var cloneFromSelector = categorySelector.init(modal.find('#cloneFromCidGroup [component="category-selector"]')); var cloneFromSelector = categorySelector.init(modal.find('#cloneFromCidGroup [component="category-selector"]'), options);
function submit() { function submit() {
var formData = modal.find('form').serializeObject(); var formData = modal.find('form').serializeObject();
formData.description = ''; formData.description = '';
formData.icon = 'fa-comments'; formData.icon = 'fa-comments';
formData.uid = app.user.uid; formData.uid = app.user.uid;
formData.parentCid = parentSelector.getSelectedCid(); formData.parentCid = parentSelector.getSelectedCid();
formData.cloneFromCid = cloneFromSelector.getSelectedCid(); formData.cloneFromCid = cloneFromSelector.getSelectedCid();
Categories.create(formData); Categories.create(formData);
modal.modal('hide'); modal.modal('hide');
return false; return false;
} }
$('#cloneChildren').on('change', function () {
var check = $(this);
var parentSelect = modal.find('#parentCidGroup [component="category-selector"] .dropdown-toggle');
if (check.prop('checked')) { $('#cloneChildren').on('change', function () {
parentSelect.attr('disabled', 'disabled'); var check = $(this);
parentSelector.selectCategory(0); var parentSelect = modal.find('#parentCidGroup [component="category-selector"] .dropdown-toggle');
} else {
parentSelect.removeAttr('disabled');
}
});
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? // Update needed?
if ((e.newIndex != null && parseInt(e.oldIndex, 10) !== parseInt(e.newIndex, 10)) || isCategoryUpdate) { 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 modified = {};
var i = 0; // on page 1 baseIndex is 0, on page n baseIndex is (n - 1) * ajaxify.data.categoriesPerPage
var list = parentCategory.toArray(); // this makes sure order is correct when drag & drop is used on pages > 1
var len = list.length; var baseIndex = (ajaxify.data.pagination.currentPage - 1) * ajaxify.data.categoriesPerPage;
modified[cid] = {
for (i; i < len; i += 1) { order: baseIndex + e.newIndex + 1,
modified[list[i]] = { };
order: (i + 1),
};
}
if (isCategoryUpdate) { if (isCategoryUpdate) {
modified[e.item.dataset.cid].parentCid = newCategoryId; modified[cid].parentCid = newCategoryId;
} }
newCategoryId = -1; newCategoryId = -1;
api.put('/categories/' + cid, modified[cid]);
Object.keys(modified).map(cid => api.put('/categories/' + cid, modified[cid]));
} }
} }

@ -16,8 +16,11 @@ define('admin/manage/category', [
$this.val($this.attr('data-value')); $this.val($this.attr('data-value'));
}); });
categorySelector.init($('[component="category-selector"]'), function (selectedCategory) { categorySelector.init($('[component="category-selector"]'), {
ajaxify.go('admin/manage/categories/' + selectedCategory.cid); onSelect: function (selectedCategory) {
ajaxify.go('admin/manage/categories/' + selectedCategory.cid);
},
showLinks: true,
}); });
handleTags(); handleTags();
@ -114,55 +117,50 @@ define('admin/manage/category', [
}); });
$('.copy-settings').on('click', function () { $('.copy-settings').on('click', function () {
socket.emit('categories.getSelectCategories', {}, function (err, allCategories) { Benchpress.render('admin/partials/categories/copy-settings', {}).then(function (html) {
if (err) { var selectedCid;
return app.alertError(err.message); var modal = bootbox.dialog({
} title: '[[modules:composer.select_category]]',
message: html,
Benchpress.render('admin/partials/categories/copy-settings', { buttons: {
categories: allCategories, save: {
}).then(function (html) { label: '[[modules:bootbox.confirm]]',
var selectedCid; className: 'btn-primary',
var modal = bootbox.dialog({ callback: function () {
title: '[[modules:composer.select_category]]', if (!selectedCid || parseInt(selectedCid, 10) === parseInt(ajaxify.data.category.cid, 10)) {
message: html, return;
buttons: { }
save: {
label: '[[modules:bootbox.confirm]]', socket.emit('admin.categories.copySettingsFrom', {
className: 'btn-primary', fromCid: selectedCid,
callback: function () { toCid: ajaxify.data.category.cid,
if (!selectedCid || parseInt(selectedCid, 10) === parseInt(ajaxify.data.category.cid, 10)) { copyParent: modal.find('#copyParent').prop('checked'),
return; }, function (err) {
if (err) {
return app.alertError(err.message);
} }
socket.emit('admin.categories.copySettingsFrom', { modal.modal('hide');
fromCid: selectedCid, app.alertSuccess('[[admin/manage/categories:alert.copy-success]]');
toCid: ajaxify.data.category.cid, ajaxify.refresh();
copyParent: modal.find('#copyParent').prop('checked'), });
}, function (err) { return false;
if (err) {
return app.alertError(err.message);
}
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; selectedCid = selectedCategory && selectedCategory.cid;
if (selectedCid) { if (selectedCid) {
modal.find('.modal-footer button').prop('disabled', false); modal.find('.modal-footer button').prop('disabled', false);
} }
}); },
showLinks: true,
}); });
return false;
}); });
return false;
}); });
$('.upload-button').on('click', function () { $('.upload-button').on('click', function () {
@ -261,34 +259,27 @@ define('admin/manage/category', [
} }
Category.launchParentSelector = function () { Category.launchParentSelector = function () {
socket.emit('categories.getSelectCategories', {}, function (err, allCategories) { categorySelector.modal({
if (err) { onSubmit: function (selectedCategory) {
return app.alertError(err.message); var parentCid = selectedCategory.cid;
} if (!parentCid) {
var parents = [parseInt(ajaxify.data.category.cid, 10)]; return;
var categories = allCategories.filter(function (category) {
var isChild = parents.includes(parseInt(category.parentCid, 10));
if (isChild) {
parents.push(parseInt(category.cid, 10));
} }
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, { api.put('/categories/' + ajaxify.data.category.cid, {
parentCid: parentCid, parentCid: parentCid,
}).then(() => { }).then(() => {
var parent = allCategories.filter(function (category) { api.get(`/category/${parentCid}`).then(function (parent) {
return category && parseInt(category.cid, 10) === parseInt(parentCid, 10); if (parent && parent.icon && parent.name) {
var buttonHtml = '<i class="fa ' + parent.icon + '"></i> ' + parent.name;
$('button[data-action="changeParent"]').html(buttonHtml).parent().removeClass('hide');
}
}); });
parent = parent[0];
$('button[data-action="removeParent"]').parent().removeClass('hide'); $('button[data-action="removeParent"]').parent().removeClass('hide');
$('button[data-action="setParent"]').addClass('hide'); $('button[data-action="setParent"]').addClass('hide');
var buttonHtml = '<i class="fa ' + parent.icon + '"></i> ' + parent.name;
$('button[data-action="changeParent"]').html(buttonHtml).parent().removeClass('hide');
}).catch(app.alertError); }).catch(app.alertError);
}); },
showLinks: true,
}); });
}; };

@ -57,8 +57,21 @@ define('admin/manage/group', [
}); });
}); });
categorySelector.init($('[component="category-selector"]'), function (selectedCategory) { categorySelector.init($('.edit-privileges-selector [component="category-selector"]'), {
navigateToCategory(selectedCategory.cid); 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"]')); groupSearch.init($('[component="group-selector"]'));

@ -75,8 +75,11 @@ define('admin/manage/groups', [
function enableCategorySelectors() { function enableCategorySelectors() {
$('.groups-list [component="category-selector"]').each(function () { $('.groups-list [component="category-selector"]').each(function () {
var nameEncoded = $(this).parents('[data-name-encoded]').attr('data-name-encoded'); var nameEncoded = $(this).parents('[data-name-encoded]').attr('data-name-encoded');
categorySelector.init($(this), function (selectedCategory) { categorySelector.init($(this), {
ajaxify.go('admin/manage/privileges/' + selectedCategory.cid + '?group=' + nameEncoded); onSelect: function (selectedCategory) {
ajaxify.go('admin/manage/privileges/' + selectedCategory.cid + '?group=' + nameEncoded);
},
showLinks: true,
}); });
}); });
} }

@ -17,11 +17,16 @@ define('admin/manage/privileges', [
checkboxRowSelector.init('.privilege-table-container'); checkboxRowSelector.init('.privilege-table-container');
categorySelector.init($('[component="category-selector"]'), function (category) { categorySelector.init($('[component="category-selector"]'), {
cid = parseInt(category.cid, 10); onSelect: function (category) {
cid = isNaN(cid) ? 'admin' : cid; cid = parseInt(category.cid, 10);
Privileges.refreshPrivilegeTable(); cid = isNaN(cid) ? 'admin' : cid;
ajaxify.updateHistory('admin/manage/privileges/' + (cid || '')); Privileges.refreshPrivilegeTable();
ajaxify.updateHistory('admin/manage/privileges/' + (cid || ''));
},
localCategories: ajaxify.data.categories,
privilege: 'find',
showLinks: true,
}); });
Privileges.setupPrivilegeTable(); Privileges.setupPrivilegeTable();
@ -262,13 +267,21 @@ define('admin/manage/privileges', [
}; };
Privileges.copyPrivilegesFromCategory = function (cid, group) { Privileges.copyPrivilegesFromCategory = function (cid, group) {
categorySelector.modal(ajaxify.data.categories.slice(1), function (fromCid) { categorySelector.modal({
socket.emit('admin.categories.copyPrivilegesFrom', { toCid: cid, fromCid: fromCid, group: group }, function (err) { localCategories: [],
if (err) { showLinks: true,
return app.alertError(err.message); onSubmit: function (selectedCategory) {
} socket.emit('admin.categories.copyPrivilegesFrom', {
ajaxify.refresh(); toCid: cid,
}); fromCid: selectedCategory.cid,
group: group,
}, function (err) {
if (err) {
return app.alertError(err.message);
}
ajaxify.refresh();
});
},
}); });
}; };

@ -1,7 +1,7 @@
'use strict'; 'use strict';
define('forum/categories', ['components'], function (components) { define('forum/categories', ['components', 'categorySelector'], function (components, categorySelector) {
var categories = {}; var categories = {};
$(window).on('action:ajaxify.start', function (ev, data) { $(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.removeListener('event:new_post', categories.onNewPost);
socket.on('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({ $('.category-header').tooltip({
placement: 'bottom', placement: 'bottom',

@ -6,7 +6,8 @@ define('forum/category', [
'navigator', 'navigator',
'topicList', 'topicList',
'sort', 'sort',
], function (infinitescroll, share, navigator, topicList, sort) { 'categorySelector',
], function (infinitescroll, share, navigator, topicList, sort, categorySelector) {
var Category = {}; var Category = {};
$(window).on('action:ajaxify.start', function (ev, data) { $(window).on('action:ajaxify.start', function (ev, data) {
@ -38,6 +39,14 @@ define('forum/category', [
handleLoadMoreSubcategories(); 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:topics.loaded', { topics: ajaxify.data.topics });
$(window).trigger('action:category.loaded', { cid: ajaxify.data.cid }); $(window).trigger('action:category.loaded', { cid: ajaxify.data.cid });
}; };

@ -1,13 +1,29 @@
'use strict'; '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 Flags = {};
var selectedCids;
Flags.init = function () { Flags.init = function () {
Flags.enableFilterForm(); Flags.enableFilterForm();
Flags.enableCheckboxes(); Flags.enableCheckboxes();
Flags.handleBulkActions(); 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') components.get('flags/list')
.on('click', '[data-flag-id]', function (e) { .on('click', '[data-flag-id]', function (e) {
if (['BUTTON', 'A'].includes(e.target.nodeName)) { 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 () { document.getElementById('apply-filters').addEventListener('click', function () {
var payload = filtersEl.serializeArray(); 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')); ajaxify.go('flags?' + (payload.length ? $.param(payload) : 'reset=1'));
}); });
}; };

@ -9,7 +9,8 @@ define('forum/groups/details', [
'translator', 'translator',
'api', 'api',
'slugify', 'slugify',
], function (memberList, iconSelect, components, coverPhoto, pictureCropper, translator, api, slugify) { 'categorySelector',
], function (memberList, iconSelect, components, coverPhoto, pictureCropper, translator, api, slugify, categorySelector) {
var Details = {}; var Details = {};
var groupName; var groupName;
@ -165,6 +166,16 @@ define('forum/groups/details', [
previewEl.addClass('hide'); 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 () { Details.update = function () {

@ -2,14 +2,16 @@
define('forum/post-queue', [ define('forum/post-queue', [
'categoryFilter', 'categorySelector', 'categoryFilter', 'categorySelector', 'api',
], function (categoryFilter, categorySelector) { ], function (categoryFilter, categorySelector, api) {
var PostQueue = {}; var PostQueue = {};
PostQueue.init = function () { PostQueue.init = function () {
$('[data-toggle="tooltip"]').tooltip(); $('[data-toggle="tooltip"]').tooltip();
categoryFilter.init($('[component="category/dropdown"]')); categoryFilter.init($('[component="category/dropdown"]'), {
privilege: 'moderate',
});
$('.posts-list').on('click', '[data-action]', function () { $('.posts-list').on('click', '[data-action]', function () {
var parent = $(this).parents('[data-id]'); var parent = $(this).parents('[data-id]');
@ -42,30 +44,32 @@ define('forum/post-queue', [
$('.posts-list').on('click', '.topic-category[data-editable]', function () { $('.posts-list').on('click', '.topic-category[data-editable]', function () {
var $this = $(this); var $this = $(this);
var id = $this.parents('[data-id]').attr('data-id'); var id = $this.parents('[data-id]').attr('data-id');
categorySelector.modal(ajaxify.data.allCategories, function (cid) { categorySelector.modal({
var category = ajaxify.data.allCategories.find(function (c) { onSubmit: function (selectedCategory) {
return parseInt(c.cid, 10) === parseInt(cid, 10); Promise.all([
}); api.get(`/categories/${selectedCategory.cid}`, {}),
socket.emit('posts.editQueuedContent', { socket.emit('posts.editQueuedContent', {
id: id, id: id,
cid: cid, cid: selectedCategory.cid,
}, function (err) { }),
if (err) { ]).then(function (result) {
return app.alertError(err.message); var category = result[0];
} app.parseAndTranslate('post-queue', 'posts', {
app.parseAndTranslate('post-queue', 'posts', { posts: [{
posts: [{ category: category,
category: category, }],
}], }, function (html) {
}, function (html) { if ($this.find('.category-text').length) {
if ($this.find('.category-text').length) { $this.find('.category-text').text(html.find('.topic-category .category-text').text());
$this.find('.category-text').text(html.find('.topic-category .category-text').text()); } else {
} else { // for backwards compatibility, remove in 1.16.0
// for backwards compatibility, remove in 1.16.0 $this.replaceWith(html.find('.topic-category'));
$this.replaceWith(html.find('.topic-category')); }
} });
}).catch(function (err) {
app.alertError(err);
}); });
}); },
}); });
return false; return false;
}); });

@ -12,18 +12,12 @@ define('forum/topic/move', ['categorySelector', 'alerts'], function (categorySel
Move.onComplete = onComplete; Move.onComplete = onComplete;
Move.moveAll = !tids; Move.moveAll = !tids;
socket.emit('categories.getMoveCategories', onCategoriesLoaded); showModal();
}; };
function onCategoriesLoaded(err, categories) { function showModal() {
if (err) { app.parseAndTranslate('partials/move_thread_modal', {}, function (html) {
return app.alertError(err.message); modal = html;
}
app.parseAndTranslate('partials/move_thread_modal', {
categories: categories,
}, function (html) {
modal = $(html);
modal.on('hidden.bs.modal', function () { modal.on('hidden.bs.modal', function () {
modal.remove(); modal.remove();
}); });
@ -34,7 +28,10 @@ define('forum/topic/move', ['categorySelector', 'alerts'], function (categorySel
modal.find('.modal-header h3').translateText('[[topic:move_topics]]'); 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); modal.find('#move_thread_commit').on('click', onCommitClicked);

@ -3,64 +3,75 @@
define('categoryFilter', ['categorySearch'], function (categorySearch) { define('categoryFilter', ['categorySearch'], function (categorySearch) {
var categoryFilter = {}; var categoryFilter = {};
categoryFilter.init = function (el) { categoryFilter.init = function (el, options) {
categorySearch.init(el); if (!el || !el.length) {
var listEl = el.find('[component="category/list"]'); 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 () { el.on('hidden.bs.dropdown', function () {
var cids = getSelectedCids(el); var changed = initialCids.length !== selectedCids.length;
var changed = ajaxify.data.selectedCids.length !== cids.length; initialCids.forEach(function (cid, index) {
ajaxify.data.selectedCids.forEach(function (cid, index) { if (cid !== selectedCids[index]) {
if (cid !== cids[index]) {
changed = true; changed = true;
} }
}); });
if (options.onHidden) {
options.onHidden({ changed: changed, selectedCids: selectedCids.slice() });
return;
}
if (changed) { if (changed) {
var url = window.location.pathname; var url = window.location.pathname;
var currentParams = utils.params(); var currentParams = utils.params();
if (cids.length) { if (selectedCids.length) {
currentParams.cid = cids; currentParams.cid = selectedCids;
url += '?' + decodeURIComponent($.param(currentParams)); url += '?' + decodeURIComponent($.param(currentParams));
} }
ajaxify.go(url); ajaxify.go(url);
} }
}); });
listEl.on('click', '[data-cid]', function (ev) { el.on('click', '[component="category/list"] [data-cid]', function () {
function selectChildren(parentCid, flag) { var listEl = el.find('[component="category/list"]');
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);
});
}
var categoryEl = $(this); var categoryEl = $(this);
var link = categoryEl.find('a').attr('href'); var link = categoryEl.find('a').attr('href');
if (link && link !== '#' && link.length) { if (link && link !== '#' && link.length) {
return; return;
} }
var cid = categoryEl.attr('data-cid'); var cid = parseInt(categoryEl.attr('data-cid'), 10);
if (ev.ctrlKey) { var icon = categoryEl.find('[component="category/select/icon"]');
selectChildren(cid, !categoryEl.find('[component="category/select/icon"]').hasClass('invisible'));
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; 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; return categoryFilter;
}); });

@ -3,79 +3,52 @@
define('categorySearch', function () { define('categorySearch', function () {
var categorySearch = {}; var categorySearch = {};
categorySearch.init = function (el) { categorySearch.init = function (el, options) {
if (utils.isTouchDevice()) { var categoriesList = null;
return; 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"]'); var searchEl = el.find('[component="category-selector-search"]');
if (!searchEl.length) { if (!searchEl.length) {
return; return;
} }
var toggleVisibility = searchEl.parent('[component="category/dropdown"]').length > 0 || var toggleVisibility = searchEl.parent('[component="category/dropdown"]').length > 0 ||
searchEl.parent('[component="category-selector"]').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 () { 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) { if (toggleVisibility) {
el.find('.dropdown-toggle').addClass('hidden'); el.find('.dropdown-toggle').addClass('hidden');
searchEl.removeClass('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) { searchEl.on('click', function (ev) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
}); });
searchEl.find('input').val('').on('keyup', utils.debounce(updateList, 200)); searchEl.find('input').val('').on('keyup', utils.debounce(doSearch, 300));
updateList(); doSearch();
}); });
el.on('shown.bs.dropdown', function () { el.on('shown.bs.dropdown', function () {
searchEl.find('input').focus(); searchEl.find('input').focus();
}); });
@ -89,6 +62,35 @@ define('categorySearch', function () {
searchEl.off('click'); searchEl.off('click');
searchEl.find('input').off('keyup'); 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; return categorySearch;

@ -1,26 +1,34 @@
'use strict'; 'use strict';
define('categorySelector', ['benchpress', 'translator', 'categorySearch'], function (Benchpress, translator, categorySearch) { define('categorySelector', ['categorySearch'], function (categorySearch) {
var categorySelector = {}; var categorySelector = {};
categorySelector.init = function (el, callback) { categorySelector.init = function (el, options) {
callback = callback || function () {}; 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 = { var selector = {
el: el, el: el,
selectedCategory: null, selectedCategory: null,
}; };
el.on('click', '[data-cid]', function () { el.on('click', '[data-cid]', function () {
var categoryEl = $(this); var categoryEl = $(this);
if (categoryEl.hasClass('disabled')) { if (categoryEl.hasClass('disabled')) {
return false; return false;
} }
selector.selectCategory(categoryEl.attr('data-cid')); selector.selectCategory(categoryEl.attr('data-cid'));
callback(selector.selectedCategory); onSelect(selector.selectedCategory);
}); });
categorySearch.init(el);
selector.selectCategory = function (cid) { selector.selectCategory = function (cid) {
var categoryEl = selector.el.find('[data-cid="' + cid + '"]'); var categoryEl = selector.el.find('[data-cid="' + cid + '"]');
selector.selectedCategory = { selector.selectedCategory = {
@ -43,14 +51,11 @@ define('categorySelector', ['benchpress', 'translator', 'categorySearch'], funct
return selector; return selector;
}; };
categorySelector.modal = function (categories, callback) { categorySelector.modal = function (options) {
if (typeof categories === 'function') { options = options || {};
callback = categories; options.onSelect = options.onSelect || function () {};
categories = ajaxify.data.allCategories; options.onSubmit = options.onSubmit || function () {};
} app.parseAndTranslate('admin/partials/categories/select-category', {}, function (html) {
app.parseAndTranslate('admin/partials/categories/select-category', {
categories: categories,
}, function (html) {
var modal = bootbox.dialog({ var modal = bootbox.dialog({
title: '[[modules:composer.select_category]]', title: '[[modules:composer.select_category]]',
message: html, 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) { function submit(ev) {
ev.preventDefault(); ev.preventDefault();
if (selector.selectedCategory) { if (selector.selectedCategory) {
callback(selector.selectedCategory.cid); options.onSubmit(selector.selectedCategory);
modal.modal('hide'); modal.modal('hide');
} }
return false; return false;
} }
if (options.openOnLoad) {
modal.on('shown.bs.modal', function () {
modal.find('.dropdown-toggle').dropdown('toggle');
});
}
modal.find('form').on('submit', submit); modal.find('form').on('submit', submit);
}); });
}; };

@ -37,8 +37,16 @@ define('topicList', [
categoryTools.init(); categoryTools.init();
TopicList.watchForNewPosts(); 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) { if (!config.usePagination) {
infinitescroll.init(TopicList.loadMoreTopics); infinitescroll.init(TopicList.loadMoreTopics);
@ -86,18 +94,11 @@ define('topicList', [
socket.removeListener('event:new_post', onNewPost); 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) { function onNewTopic(data) {
if ( if (
(ajaxify.data.selectedCids && ajaxify.data.selectedCids.length && ajaxify.data.selectedCids.indexOf(parseInt(data.cid, 10)) === -1) || (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.selectedFilter && ajaxify.data.selectedFilter.filter === 'watched') ||
(ajaxify.data.template.category && parseInt(ajaxify.data.cid, 10) !== parseInt(data.cid, 10)) || (ajaxify.data.template.category && parseInt(ajaxify.data.cid, 10) !== parseInt(data.cid, 10))
(!isCategoryVisible(data.cid))
) { ) {
return; 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.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 === 'new') ||
(ajaxify.data.selectedFilter && ajaxify.data.selectedFilter.filter === 'watched' && !post.topic.isFollowing) || (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)) || (ajaxify.data.template.category && parseInt(ajaxify.data.cid, 10) !== parseInt(post.topic.cid, 10))
(!isCategoryVisible(post.topic.cid))
)) { )) {
return; return;
} }

@ -4,6 +4,6 @@ const cacheCreate = require('./cacheCreate');
module.exports = cacheCreate({ module.exports = cacheCreate({
name: 'local', name: 'local',
max: 4000, max: 40000,
maxAge: 0, maxAge: 0,
}); });

@ -73,7 +73,9 @@ module.exports = function (Categories) {
'categories:cid', 'categories:cid',
'cid:0:children', 'cid:0:children',
'cid:' + parentCid + ':children', 'cid:' + parentCid + ':children',
'cid:' + parentCid + ':children:all',
'cid:' + cid + ':children', 'cid:' + cid + ':children',
'cid:' + cid + ':children:all',
'cid:' + cid + ':tag:whitelist', 'cid:' + cid + ':tag:whitelist',
]); ]);
} }

@ -71,6 +71,7 @@ Categories.getAllCidsFromSet = async function (key) {
} }
cids = await db.getSortedSetRange(key, 0, -1); cids = await db.getSortedSetRange(key, 0, -1);
cids = cids.map(cid => parseInt(cid, 10));
cache.set(key, cids); cache.set(key, cids);
return cids.slice(); return cids.slice();
}; };
@ -229,6 +230,19 @@ async function getChildrenTree(category, uid) {
Categories.getChildrenTree = getChildrenTree; 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) { Categories.getChildrenCids = async function (rootCid) {
let allCids = []; let allCids = [];
async function recursive(keys) { async function recursive(keys) {
@ -243,7 +257,7 @@ Categories.getChildrenCids = async function (rootCid) {
await recursive(keys); await recursive(keys);
} }
const key = 'cid:' + rootCid + ':children'; const key = 'cid:' + rootCid + ':children';
const cacheKey = 'cache:' + key; const cacheKey = key + ':all';
const childrenCids = cache.get(cacheKey); const childrenCids = cache.get(cacheKey);
if (childrenCids) { if (childrenCids) {
return childrenCids.slice(); return childrenCids.slice();
@ -311,10 +325,17 @@ Categories.getTree = function (categories, parentCid) {
} }
}); });
function sortTree(tree) { function sortTree(tree) {
tree.sort((a, b) => a.order - b.order); tree.sort((a, b) => {
if (tree.children) { if (a.order !== b.order) {
sortTree(tree.children); return a.order - b.order;
} }
return a.cid - b.cid;
});
tree.forEach((category) => {
if (category && Array.isArray(category.children)) {
sortTree(category.children);
}
});
} }
sortTree(tree); sortTree(tree);
@ -338,7 +359,7 @@ async function getSelectData(cids, fields) {
return Categories.buildForSelectCategories(tree, fields); return Categories.buildForSelectCategories(tree, fields);
} }
Categories.buildForSelectCategories = function (categories, fields) { Categories.buildForSelectCategories = function (categories, fields, parentCid) {
function recursive(category, categoriesData, level, depth) { function recursive(category, categoriesData, level, depth) {
const bullet = level ? '&bull; ' : ''; const bullet = level ? '&bull; ' : '';
category.value = category.cid; category.value = category.cid;
@ -350,10 +371,10 @@ Categories.buildForSelectCategories = function (categories, fields) {
category.children.forEach(child => recursive(child, categoriesData, '&nbsp;&nbsp;&nbsp;&nbsp;' + level, depth + 1)); category.children.forEach(child => recursive(child, categoriesData, '&nbsp;&nbsp;&nbsp;&nbsp;' + level, depth + 1));
} }
} }
parentCid = parentCid || 0;
const categoriesData = []; 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)); rootCategories.forEach(category => recursive(category, categoriesData, '', 0));

@ -91,25 +91,27 @@ module.exports = function (Categories) {
}; };
async function getTopics(tids, uid) { 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) { topicData.forEach(function (topic) {
if (topic) { if (topic) {
topic.teaserPid = topic.teaserPid || topic.mainPid; topic.teaserPid = topic.teaserPid || topic.mainPid;
} }
}); });
var cids = _.uniq(topicData.map(topic => topic && topic.cid).filter(cid => parseInt(cid, 10))); const cids = _.uniq(topicData.map(t => t && t.cid).filter(cid => parseInt(cid, 10)));
const [categoryData, teasers] = await Promise.all([ const getToRoot = async () => await Promise.all(cids.map(Categories.getParentCids));
Categories.getCategoriesFields(cids, ['cid', 'parentCid']), const [toRoot, teasers] = await Promise.all([
getToRoot(),
topics.getTeasers(topicData, uid), topics.getTeasers(topicData, uid),
]); ]);
var parentCids = {}; const cidToRoot = _.zipObject(cids, toRoot);
categoryData.forEach(function (category) {
parentCids[category.cid] = category.parentCid;
});
teasers.forEach(function (teaser, index) { teasers.forEach(function (teaser, index) {
if (teaser) { if (teaser) {
teaser.cid = topicData[index].cid; teaser.cid = topicData[index].cid;
teaser.parentCid = parseInt(parentCids[teaser.cid], 10) || 0; teaser.parentCids = cidToRoot[teaser.cid];
teaser.tid = undefined; teaser.tid = undefined;
teaser.uid = undefined; teaser.uid = undefined;
teaser.topic = { teaser.topic = {
@ -124,11 +126,12 @@ module.exports = function (Categories) {
function assignTopicsToCategories(categories, topics) { function assignTopicsToCategories(categories, topics) {
categories.forEach(function (category) { categories.forEach(function (category) {
if (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) .sort((a, b) => b.pid - a.pid)
.slice(0, parseInt(category.numRecentReplies, 10)); .slice(0, parseInt(category.numRecentReplies, 10));
} }
}); });
topics.forEach((t) => { t.parentCids = undefined; });
} }
function bubbleUpChildrenPosts(categoryData) { function bubbleUpChildrenPosts(categoryData) {
@ -137,7 +140,8 @@ module.exports = function (Categories) {
if (category.posts.length) { if (category.posts.length) {
return; return;
} }
var posts = [];
const posts = [];
getPostsRecursive(category, posts); getPostsRecursive(category, posts);
posts.sort((a, b) => b.pid - a.pid); posts.sort((a, b) => b.pid - a.pid);
@ -150,15 +154,12 @@ module.exports = function (Categories) {
function getPostsRecursive(category, posts) { function getPostsRecursive(category, posts) {
if (Array.isArray(category.posts)) { if (Array.isArray(category.posts)) {
category.posts.forEach(function (p) { category.posts.forEach(p => posts.push(p));
posts.push(p);
});
} }
category.children.forEach(function (child) { category.children.forEach(child => getPostsRecursive(child, posts));
getPostsRecursive(child, posts);
});
} }
// terrible name, should be topics.moveTopicPosts // terrible name, should be topics.moveTopicPosts
Categories.moveRecentReplies = async function (tid, oldCid, cid) { Categories.moveRecentReplies = async function (tid, oldCid, cid) {
await updatePostCount(tid, oldCid, cid); await updatePostCount(tid, oldCid, cid);

@ -41,6 +41,15 @@ module.exports = function (Categories) {
Categories.getTree(categoryData, 0); Categories.getTree(categoryData, 0);
await Categories.getRecentTopicReplies(categoryData, uid, data.qs); 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) { categoryData.sort(function (c1, c2) {
if (c1.parentCid !== c2.parentCid) { if (c1.parentCid !== c2.parentCid) {
return c1.parentCid - c2.parentCid; return c1.parentCid - c2.parentCid;

@ -50,12 +50,12 @@ module.exports = function (Categories) {
return await updateTagWhitelist(cid, value); return await updateTagWhitelist(cid, value);
} else if (key === 'name') { } else if (key === 'name') {
return await updateName(cid, value); return await updateName(cid, value);
} else if (key === 'order') {
return await updateOrder(cid, value);
} }
await db.setObjectField('category:' + cid, key, value); await db.setObjectField('category:' + cid, key, value);
if (key === 'order') { if (key === 'description') {
await updateOrder(cid, value);
} else if (key === 'description') {
await Categories.parseDescription(cid, value); await Categories.parseDescription(cid, value);
} }
} }
@ -76,7 +76,12 @@ module.exports = function (Categories) {
db.setObjectField('category:' + cid, 'parentCid', newParent), 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) { async function updateTagWhitelist(cid, tags) {
@ -90,8 +95,38 @@ module.exports = function (Categories) {
async function updateOrder(cid, order) { async function updateOrder(cid, order) {
const parentCid = await Categories.getCategoryField(cid, 'parentCid'); const parentCid = await Categories.getCategoryField(cid, 'parentCid');
await db.sortedSetsAdd(['categories:cid', 'cid:' + parentCid + ':children'], order, cid); await db.sortedSetsAdd('categories:cid', order, cid);
cache.del(['categories:cid', 'cid:' + parentCid + ':children']);
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) { Categories.parseDescription = async function (cid, description) {

@ -4,6 +4,8 @@ const user = require('../../user');
const categories = require('../../categories'); const categories = require('../../categories');
const accountHelpers = require('./helpers'); const accountHelpers = require('./helpers');
const helpers = require('../helpers'); const helpers = require('../helpers');
const pagination = require('../../pagination');
const meta = require('../../meta');
const categoriesController = module.exports; const categoriesController = module.exports;
@ -12,11 +14,18 @@ categoriesController.get = async function (req, res, next) {
if (!userData) { if (!userData) {
return next(); return next();
} }
const [states, categoriesData] = await Promise.all([ const [states, allCategoriesData] = await Promise.all([
user.getCategoryWatchState(userData.uid), user.getCategoryWatchState(userData.uid),
categories.buildForSelect(userData.uid, 'find', ['descriptionParsed', 'depth', 'slug']), 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) { categoriesData.forEach(function (category) {
if (category) { if (category) {
category.isIgnored = states[category.cid] === categories.watchStates.ignoring; 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: userData.username, url: '/user/' + userData.userslug },
{ text: '[[pages:categories]]' }, { text: '[[pages:categories]]' },
]); ]);
userData.pagination = pagination.create(page, pageCount, req.query);
res.render('account/categories', userData); res.render('account/categories', userData);
}; };

@ -2,6 +2,7 @@
const _ = require('lodash'); const _ = require('lodash');
const db = require('../../database');
const groups = require('../../groups'); const groups = require('../../groups');
const categories = require('../../categories'); const categories = require('../../categories');
const privileges = require('../../privileges'); const privileges = require('../../privileges');
@ -9,29 +10,34 @@ const user = require('../../user');
const AdminsMods = module.exports; const AdminsMods = module.exports;
AdminsMods.get = async function (req, res) { AdminsMods.get = async function (req, res, next) {
const [admins, globalMods, categories] = await Promise.all([ 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('administrators', { uid: req.uid }),
groups.get('Global Moderators', { uid: req.uid }), groups.get('Global Moderators', { uid: req.uid }),
getModeratorsOfCategories(), getModeratorsOfCategories(selectedCategory),
]); ]);
res.render('admin/manage/admins-mods', { res.render('admin/manage/admins-mods', {
admins: admins, admins: admins,
globalMods: globalMods, globalMods: globalMods,
categories: categories, categoryMods: [moderators],
selectedCategory: selectedCategory,
allPrivileges: privileges.userPrivilegeList, allPrivileges: privileges.userPrivilegeList,
}); });
}; };
async function getModeratorsOfCategories() { async function getModeratorsOfCategories(categoryData) {
const categoryData = await categories.buildForSelectAll(['depth', 'disabled']); const moderatorUids = await categories.getModeratorUids([categoryData.cid]);
const moderatorUids = await categories.getModeratorUids(categoryData.map(c => c.cid));
const uids = _.uniq(_.flatten(moderatorUids)); const uids = _.uniq(_.flatten(moderatorUids));
const moderatorData = await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']); const moderatorData = await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture']);
const moderatorMap = _.zipObject(uids, moderatorData); categoryData.moderators = moderatorData;
categoryData.forEach((c, index) => {
c.moderators = moderatorUids[index].map(uid => moderatorMap[uid]);
});
return categoryData; return categoryData;
} }

@ -1,18 +1,22 @@
'use strict'; 'use strict';
const _ = require('lodash');
const nconf = require('nconf');
const categories = require('../../categories'); const categories = require('../../categories');
const analytics = require('../../analytics'); const analytics = require('../../analytics');
const plugins = require('../../plugins'); const plugins = require('../../plugins');
const translator = require('../../translator'); const translator = require('../../translator');
const meta = require('../../meta'); const meta = require('../../meta');
const helpers = require('../helpers');
const pagination = require('../../pagination');
const categoriesController = module.exports; const categoriesController = module.exports;
categoriesController.get = async function (req, res, next) { 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.getCategories([req.params.category_id], req.uid),
categories.getParents([req.params.category_id]), categories.getParents([req.params.category_id]),
categories.buildForSelectAll(), helpers.getSelectedCategory(req.params.category_id),
]); ]);
const category = categoryData[0]; const category = categoryData[0];
@ -21,47 +25,108 @@ categoriesController.get = async function (req, res, next) {
} }
category.parent = parent[0]; 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', { const data = await plugins.hooks.fire('filter:admin.category.get', {
req: req, req: req,
res: res, res: res,
category: category, category: category,
customClasses: [], customClasses: [],
allCategories: allCategories,
}); });
data.category.name = translator.escape(String(data.category.name)); data.category.name = translator.escape(String(data.category.name));
data.category.description = translator.escape(String(data.category.description)); data.category.description = translator.escape(String(data.category.description));
res.render('admin/manage/category', { res.render('admin/manage/category', {
category: data.category, category: data.category,
categories: data.allCategories, selectedCategory: selectedData.selectedCategory,
selectedCategory: selectedCategory,
customClasses: data.customClasses, customClasses: data.customClasses,
postQueueEnabled: !!meta.config.postQueue, postQueueEnabled: !!meta.config.postQueue,
}); });
}; };
categoriesController.getAll = async function (req, res) { 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. // 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 = [ const fields = [
'cid', 'name', 'icon', 'parentCid', 'disabled', 'link', 'cid', 'name', 'icon', 'parentCid', 'disabled', 'link', 'order',
'color', 'bgColor', 'backgroundImage', 'imageClass', 'color', 'bgColor', 'backgroundImage', 'imageClass', 'subCategoriesPerPage',
]; ];
const categoriesData = await categories.getCategoriesFields(cids, fields); const categoriesData = await categories.getCategoriesFields(cids, fields);
const result = await plugins.hooks.fire('filter:admin.categories.get', { categories: categoriesData, fields: 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', { 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) { categoriesController.getAnalytics = async function (req, res) {
const [name, analyticsData] = await Promise.all([ const [name, analyticsData] = await Promise.all([
categories.getCategoryField(req.params.category_id, 'name'), categories.getCategoryField(req.params.category_id, 'name'),

@ -5,7 +5,6 @@ const validator = require('validator');
const db = require('../../database'); const db = require('../../database');
const user = require('../../user'); const user = require('../../user');
const categories = require('../../categories');
const groups = require('../../groups'); const groups = require('../../groups');
const meta = require('../../meta'); const meta = require('../../meta');
const pagination = require('../../pagination'); const pagination = require('../../pagination');
@ -23,22 +22,19 @@ groupsController.list = async function (req, res) {
const stop = start + groupsPerPage - 1; const stop = start + groupsPerPage - 1;
groupNames = groupNames.slice(start, stop + 1); groupNames = groupNames.slice(start, stop + 1);
const allCategories = await categories.buildForSelectAll();
const groupData = await groups.getGroupsData(groupNames); const groupData = await groups.getGroupsData(groupNames);
res.render('admin/manage/groups', { res.render('admin/manage/groups', {
groups: groupData, groups: groupData,
pagination: pagination.create(page, pageCount), pagination: pagination.create(page, pageCount),
yourid: req.uid, yourid: req.uid,
categories: allCategories,
}); });
}; };
groupsController.get = async function (req, res, next) { groupsController.get = async function (req, res, next) {
const groupName = req.params.name; const groupName = req.params.name;
const [groupNames, group, allCategories] = await Promise.all([ const [groupNames, group] = await Promise.all([
getGroupNames(), getGroupNames(),
groups.get(groupName, { uid: req.uid, truncateUserList: true, userListCount: 20 }), groups.get(groupName, { uid: req.uid, truncateUserList: true, userListCount: 20 }),
categories.buildForSelectAll(),
]); ]);
if (!group || groupName === groups.BANNED_USERS) { if (!group || groupName === groups.BANNED_USERS) {
@ -60,7 +56,6 @@ groupsController.get = async function (req, res, next) {
allowPrivateGroups: meta.config.allowPrivateGroups, allowPrivateGroups: meta.config.allowPrivateGroups,
maximumGroupNameLength: meta.config.maximumGroupNameLength, maximumGroupNameLength: meta.config.maximumGroupNameLength,
maximumGroupTitleLength: meta.config.maximumGroupTitleLength, maximumGroupTitleLength: meta.config.maximumGroupTitleLength,
categories: allCategories,
}); });
}; };

@ -9,19 +9,14 @@ privilegesController.get = async function (req, res) {
const cid = req.params.cid ? parseInt(req.params.cid, 10) || 0 : 0; const cid = req.params.cid ? parseInt(req.params.cid, 10) || 0 : 0;
const isAdminPriv = req.params.cid === 'admin'; const isAdminPriv = req.params.cid === 'admin';
let method; let privilegesData;
if (cid > 0) { if (cid > 0) {
method = privileges.categories.list.bind(null, cid); privilegesData = await privileges.categories.list(cid);
} else if (cid === 0) { } 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([ const categoriesData = [{
method(isAdminPriv ? req.uid : undefined),
categories.buildForSelectAll(),
]);
categoriesData.unshift({
cid: 0, cid: 0,
name: '[[admin/manage/privileges:global]]', name: '[[admin/manage/privileges:global]]',
icon: 'fa-list', icon: 'fa-list',
@ -29,7 +24,7 @@ privilegesController.get = async function (req, res) {
cid: 'admin', // what do? cid: 'admin', // what do?
name: '[[admin/manage/privileges:admin]]', name: '[[admin/manage/privileges:admin]]',
icon: 'fa-lock', icon: 'fa-lock',
}); }];
let selectedCategory; let selectedCategory;
categoriesData.forEach(function (category) { 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 : ''; const group = req.query.group ? req.query.group : '';
res.render('admin/manage/privileges', { res.render('admin/manage/privileges', {
privileges: privilegesData, privileges: privilegesData,

@ -28,7 +28,7 @@ categoriesController.list = async function (req, res) {
const stop = start + meta.config.categoriesPerPage - 1; const stop = start + meta.config.categoriesPerPage - 1;
const pageCids = rootCids.slice(start, stop + 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 childCids = await privileges.categories.filterCids('find', allChildCids, req.uid);
const categoryData = await categories.getCategories(pageCids.concat(childCids), req.uid); const categoryData = await categories.getCategories(pageCids.concat(childCids), req.uid);
const tree = categories.getTree(categoryData, 0); const tree = categories.getTree(categoryData, 0);
@ -36,26 +36,15 @@ categoriesController.list = async function (req, res) {
const data = { const data = {
title: meta.config.homePageTitle || '[[pages:home]]', title: meta.config.homePageTitle || '[[pages:home]]',
selectCategoryLabel: '[[pages:categories]]',
categories: tree, categories: tree,
pagination: pagination.create(page, pageCount, req.query), pagination: pagination.create(page, pageCount, req.query),
}; };
data.categories.forEach(function (category) { data.categories.forEach(function (category) {
if (category) { if (category) {
if (Array.isArray(category.children)) { helpers.trimChildren(category);
category.children = category.children.slice(0, category.subCategoriesPerPage); helpers.setCategoryTeaser(category);
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,
};
}
} }
}); });

@ -105,11 +105,15 @@ categoryController.get = async function (req, res, next) {
categoryData.nextSubCategoryStart = categoryData.subCategoriesPerPage; categoryData.nextSubCategoryStart = categoryData.subCategoriesPerPage;
categoryData.children = categoryData.children.slice(0, categoryData.subCategoriesPerPage); categoryData.children = categoryData.children.slice(0, categoryData.subCategoriesPerPage);
categoryData.children.forEach(function (child) { categoryData.children.forEach(function (child) {
child.children = undefined; if (child) {
helpers.trimChildren(child);
helpers.setCategoryTeaser(child);
}
}); });
} }
categoryData.title = translator.escape(categoryData.name); categoryData.title = translator.escape(categoryData.name);
categoryData.selectCategoryLabel = '[[category:subcategories]]';
categoryData.description = translator.escape(categoryData.description); categoryData.description = translator.escape(categoryData.description);
categoryData.privileges = userPrivileges; categoryData.privileges = userPrivileges;
categoryData.showSelect = userPrivileges.editable; categoryData.showSelect = userPrivileges.editable;

@ -244,36 +244,9 @@ async function getCategoryData(cids, uid, selectedCid, states, privilege) {
selectedCid = [selectedCid]; selectedCid = [selectedCid];
} }
selectedCid = selectedCid && selectedCid.map(String); selectedCid = selectedCid && selectedCid.map(String);
states = states || [categories.watchStates.watching, categories.watchStates.notwatching];
const [allowed, watchState, categoryData, isAdmin] = await Promise.all([ const visibleCategories = await helpers.getVisibleCategories({
privileges.categories.isUserAllowedTo(privilege, cids, uid), cids, uid, states, privilege, showLinks: false,
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 categoriesData = categories.buildForSelectCategories(visibleCategories, ['disabledClass']); 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) { function checkVisibleChildren(c, cidToAllowed, cidToWatchState, states) {
if (!c || !Array.isArray(c.children)) { if (!c || !Array.isArray(c.children)) {
return false; 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) (cidToAllowed[c.cid] && states.includes(cidToWatchState[c.cid])) || checkVisibleChildren(c, cidToAllowed, cidToWatchState, states)
)); ));
} }
helpers.getHomePageRoutes = async function (uid) { 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 = [ const routes = [
{ {
route: 'categories', route: 'categories',
@ -349,13 +408,15 @@ helpers.getHomePageRoutes = async function (uid) {
route: 'popular', route: 'popular',
name: 'Popular', name: 'Popular',
}, },
].concat(categoryRoutes, [
{ {
route: 'custom', route: 'custom',
name: '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; return data.routes;
}; };

@ -21,12 +21,6 @@ modsController.flags.list = async function (req, res, next) {
const validFilters = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'cid', 'quick', 'page', 'perPage']; const validFilters = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'cid', 'quick', 'page', 'perPage'];
const validSorts = ['newest', 'oldest', 'reports', 'upvotes', 'downvotes', 'replies']; 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([ const results = await Promise.all([
user.isAdminOrGlobalMod(req.uid), user.isAdminOrGlobalMod(req.uid),
user.getModeratedCids(req.uid), user.getModeratedCids(req.uid),
@ -41,30 +35,21 @@ modsController.flags.list = async function (req, res, next) {
} }
if (!isAdminOrGlobalMod && moderatedCids.length) { 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 // Parse query string params for filters, eliminate non-valid filters
filters = filters.reduce(function (memo, cur) { filters = filters.reduce(function (memo, cur) {
if (req.query.hasOwnProperty(cur)) { if (req.query.hasOwnProperty(cur)) {
if (req.query[cur] === '') { if (req.query[cur] !== '') {
if (req.session.hasOwnProperty('flags_filters')) {
delete req.session.flags_filters[cur];
}
} else {
memo[cur] = req.query[cur]; memo[cur] = req.query[cur];
} }
} }
return memo; return memo;
}, {}); }, {});
let hasFilter = !!Object.keys(filters).length;
if (!hasFilter && req.session.hasOwnProperty('flags_filters')) { let hasFilter = !!Object.keys(filters).length;
// Load filters from session object
filters = req.session.flags_filters;
hasFilter = true;
}
if (res.locals.cids) { if (res.locals.cids) {
if (!filters.cid) { if (!filters.cid) {
@ -89,9 +74,7 @@ modsController.flags.list = async function (req, res, next) {
// Parse sort from query string // Parse sort from query string
let sort; let sort;
if (!req.query.sort && req.session.hasOwnProperty('flags_sort')) { if (req.query.sort) {
sort = req.session.flags_sort;
} else {
sort = sorts.includes(req.query.sort) ? req.query.sort : null; sort = sorts.includes(req.query.sort) ? req.query.sort : null;
} }
if (sort === 'newest') { if (sort === 'newest') {
@ -99,26 +82,20 @@ modsController.flags.list = async function (req, res, next) {
} }
hasFilter = hasFilter || !!sort; hasFilter = hasFilter || !!sort;
// Save filters and sorting into session unless removed const [flagsData, analyticsData, selectData] = await Promise.all([
if (hasFilter) {
req.session.flags_filters = filters;
}
req.session.flags_sort = sort;
const [flagsData, analyticsData, categoriesData] = await Promise.all([
flags.list({ flags.list({
filters: filters, filters: filters,
sort: sort, sort: sort,
uid: req.uid, uid: req.uid,
}), }),
analytics.getDailyStatsForSet('analytics:flags', Date.now(), 30), analytics.getDailyStatsForSet('analytics:flags', Date.now(), 30),
categories.buildForSelect(req.uid, 'read'), helpers.getSelectedCategory(filters.cid),
]); ]);
res.render('flags/list', { res.render('flags/list', {
flags: flagsData.flags, flags: flagsData.flags,
analytics: analyticsData, analytics: analyticsData,
categories: filterCategories(res.locals.cids, categoriesData), selectedCategory: selectData.selectedCategory,
hasFilter: hasFilter, hasFilter: hasFilter,
filters: filters, filters: filters,
sort: sort || 'newest', 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) { modsController.postQueue = async function (req, res, next) {
// Admins, global mods, and individual mods only // Admins, global mods, and individual mods only
const isPrivileged = await user.isPrivileged(req.uid); 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 page = parseInt(req.query.page, 10) || 1;
const postsPerPage = 20; 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), db.getSortedSetRange('post:queue', 0, -1),
user.isAdminOrGlobalMod(req.uid), user.isAdminOrGlobalMod(req.uid),
user.getModeratedCids(req.uid), user.getModeratedCids(req.uid),
categories.buildForSelect(req.uid, 'find', ['disabled', 'link', 'slug']), helpers.getSelectedCategory(cid),
helpers.getCategoriesByStates(req.uid, cid, null, 'moderate'),
]); ]);
if (cid && !moderatedCids.includes(String(cid)) && !isAdminOrGlobalMod) { if (cid && !moderatedCids.includes(String(cid)) && !isAdminOrGlobalMod) {
return next(); return next();
} }
allCategories.forEach((c) => {
c.disabledClass = !isAdminOrGlobalMod && !moderatedCids.includes(String(c.cid));
});
let postData = await getQueuedPosts(ids); let postData = await getQueuedPosts(ids);
postData = postData.filter(p => p && postData = postData.filter(p => p &&
@ -234,7 +186,6 @@ modsController.postQueue = async function (req, res, next) {
res.render('post-queue', { res.render('post-queue', {
title: '[[pages:post-queue]]', title: '[[pages:post-queue]]',
posts: postData, posts: postData,
allCategories: allCategories,
...categoriesData, ...categoriesData,
allCategoriesUrl: 'post-queue' + helpers.buildQueryString(req.query, 'cid', ''), allCategoriesUrl: 'post-queue' + helpers.buildQueryString(req.query, 'cid', ''),
pagination: pagination.create(page, pageCount), pagination: pagination.create(page, pageCount),

@ -32,14 +32,9 @@ recentController.getData = async function (req, url, sort) {
} }
term = term || 'alltime'; 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([ const [settings, categoryData, rssToken, canPost, isPrivileged] = await Promise.all([
user.getSettings(req.uid), user.getSettings(req.uid),
helpers.getCategoriesByStates(req.uid, cid, states), helpers.getSelectedCategory(cid),
user.auth.getFeedToken(req.uid), user.auth.getFeedToken(req.uid),
canPostTopic(req.uid), canPostTopic(req.uid),
user.isPrivileged(req.uid), user.isPrivileged(req.uid),
@ -49,7 +44,7 @@ recentController.getData = async function (req, url, sort) {
const stop = start + settings.topicsPerPage - 1; const stop = start + settings.topicsPerPage - 1;
const data = await topics.getSortedTopics({ const data = await topics.getSortedTopics({
cids: cid || categoryData.categories.map(c => c.cid), cids: cid,
uid: req.uid, uid: req.uid,
start: start, start: start,
stop: stop, stop: stop,
@ -63,7 +58,6 @@ recentController.getData = async function (req, url, sort) {
data.canPost = canPost; data.canPost = canPost;
data.showSelect = isPrivileged; data.showSelect = isPrivileged;
data.showTopicTools = isPrivileged; data.showTopicTools = isPrivileged;
data.categories = categoryData.categories;
data.allCategoriesUrl = url + helpers.buildQueryString(req.query, 'cid', ''); data.allCategoriesUrl = url + helpers.buildQueryString(req.query, 'cid', '');
data.selectedCategory = categoryData.selectedCategory; data.selectedCategory = categoryData.selectedCategory;
data.selectedCids = categoryData.selectedCids; data.selectedCids = categoryData.selectedCids;

@ -30,16 +30,12 @@ tagsController.getTag = async function (req, res) {
]); ]);
const start = Math.max(0, (page - 1) * settings.topicsPerPage); const start = Math.max(0, (page - 1) * settings.topicsPerPage);
const stop = start + settings.topicsPerPage - 1; 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.getTagTopicCount(tag, cids),
topics.getTagTidsByCids(tag, cids, start, stop), topics.getTagTidsByCids(tag, cids, start, stop),
helpers.getCategoriesByStates(req.uid, '', states),
]); ]);
templateData.categories = categoriesData.categories;
templateData.topics = await topics.getTopics(tids, req.uid); templateData.topics = await topics.getTopics(tids, req.uid);
topics.calculateTopicIndices(templateData.topics, start); topics.calculateTopicIndices(templateData.topics, start);
res.locals.metaTags = [ res.locals.metaTags = [

@ -7,9 +7,7 @@ const querystring = require('querystring');
const meta = require('../meta'); const meta = require('../meta');
const pagination = require('../pagination'); const pagination = require('../pagination');
const user = require('../user'); const user = require('../user');
const categories = require('../categories');
const topics = require('../topics'); const topics = require('../topics');
const plugins = require('../plugins');
const helpers = require('./helpers'); const helpers = require('./helpers');
const unreadController = module.exports; const unreadController = module.exports;
@ -18,8 +16,8 @@ unreadController.get = async function (req, res) {
const cid = req.query.cid; const cid = req.query.cid;
const filter = req.query.filter || ''; const filter = req.query.filter || '';
const [watchedCategories, userSettings, isPrivileged] = await Promise.all([ const [categoryData, userSettings, isPrivileged] = await Promise.all([
getWatchedCategories(req.uid, cid, filter), helpers.getSelectedCategory(cid),
user.getSettings(req.uid), user.getSettings(req.uid),
user.isPrivileged(req.uid), user.isPrivileged(req.uid),
]); ]);
@ -47,10 +45,9 @@ unreadController.get = async function (req, res) {
} }
data.showSelect = true; data.showSelect = true;
data.showTopicTools = isPrivileged; data.showTopicTools = isPrivileged;
data.categories = watchedCategories.categories;
data.allCategoriesUrl = 'unread' + helpers.buildQueryString(req.query, 'cid', ''); data.allCategoriesUrl = 'unread' + helpers.buildQueryString(req.query, 'cid', '');
data.selectedCategory = watchedCategories.selectedCategory; data.selectedCategory = categoryData.selectedCategory;
data.selectedCids = watchedCategories.selectedCids; data.selectedCids = categoryData.selectedCids;
if (req.originalUrl.startsWith(nconf.get('relative_path') + '/api/unread') || req.originalUrl.startsWith(nconf.get('relative_path') + '/unread')) { if (req.originalUrl.startsWith(nconf.get('relative_path') + '/api/unread') || req.originalUrl.startsWith(nconf.get('relative_path') + '/unread')) {
data.title = '[[pages:unread]]'; data.title = '[[pages:unread]]';
data.breadcrumbs = helpers.buildBreadcrumbs([{ text: '[[unread:title]]' }]); data.breadcrumbs = helpers.buildBreadcrumbs([{ text: '[[unread:title]]' }]);
@ -63,17 +60,6 @@ unreadController.get = async function (req, res) {
res.render('unread', data); 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) { unreadController.unreadTotal = async function (req, res, next) {
const filter = req.query.filter || ''; const filter = req.query.filter || '';
try { try {

@ -32,6 +32,26 @@ module.exports = function (module) {
cache.del(key); 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) { module.setObjectField = async function (key, field, value) {
if (!field) { if (!field) {
return; return;

@ -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) { module.setObjectField = async function (key, field, value) {
if (!field) { if (!field) {
return; return;

@ -36,6 +36,16 @@ module.exports = function (module) {
cache.del(key); 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) { module.setObjectField = async function (key, field, value) {
if (!field) { if (!field) {
return; return;

@ -76,7 +76,8 @@ function modifyGroup(group, fields) {
group.icon = validator.escape(String(group.icon || '')); group.icon = validator.escape(String(group.icon || ''));
group.createtimeISO = utils.toISOString(group.createtime); group.createtimeISO = utils.toISOString(group.createtime);
group.private = ([null, undefined].includes(group.private)) ? 1 : group.private; 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']; group['cover:thumb:url'] = group['cover:thumb:url'] || group['cover:url'];

@ -1,7 +1,6 @@
'use strict'; 'use strict';
const user = require('../user'); const user = require('../user');
const categories = require('../categories');
const db = require('../database'); const db = require('../database');
const plugins = require('../plugins'); const plugins = require('../plugins');
const slugify = require('../slugify'); const slugify = require('../slugify');
@ -121,10 +120,9 @@ Groups.get = async function (groupName, options) {
stop = (parseInt(options.userListCount, 10) || 4) - 1; 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.getGroupData(groupName),
Groups.getOwnersAndMembers(groupName, options.uid, 0, stop), Groups.getOwnersAndMembers(groupName, options.uid, 0, stop),
categories.buildForSelect(groupName, 'topics:read', []),
Groups.getUsersFromSet('group:' + groupName + ':pending', ['username', 'userslug', 'picture']), Groups.getUsersFromSet('group:' + groupName + ':pending', ['username', 'userslug', 'picture']),
Groups.getUsersFromSet('group:' + groupName + ':invited', ['username', 'userslug', 'picture']), Groups.getUsersFromSet('group:' + groupName + ':invited', ['username', 'userslug', 'picture']),
Groups.isMember(options.uid, groupName), 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); const descriptionParsed = await plugins.hooks.fire('filter:parse.raw', groupData.description);
groupData.descriptionParsed = descriptionParsed; groupData.descriptionParsed = descriptionParsed;
groupData.categories = selectCategories.map((category) => {
category.selected = groupData.memberPostCids.includes(category.cid);
return category;
});
groupData.members = members; groupData.members = members;
groupData.membersNextStart = stop + 1; groupData.membersNextStart = stop + 1;
groupData.pending = pending.filter(Boolean); groupData.pending = pending.filter(Boolean);

@ -15,8 +15,10 @@ module.exports = function (Groups) {
groupNames = groupNames[0]; groupNames = groupNames[0];
// Only process those groups that have the cid in its memberPostCids setting (or no setting at all) // Only process those groups that have the cid in its memberPostCids setting (or no setting at all)
const groupsCids = await groups.getGroupsFields(groupNames, ['memberPostCids']); const groupData = await groups.getGroupsFields(groupNames, ['memberPostCids']);
groupNames = groupNames.filter((groupName, idx) => !groupsCids[idx].memberPostCids.length || groupsCids[idx].memberPostCids.includes(postData.cid)); groupNames = groupNames.filter(
(groupName, idx) => !groupData[idx].memberPostCidsArray.length || groupData[idx].memberPostCidsArray.includes(postData.cid)
);
const keys = groupNames.map(groupName => 'group:' + groupName + ':member:pids'); const keys = groupNames.map(groupName => 'group:' + groupName + ':member:pids');
await db.sortedSetsAdd(keys, postData.timestamp, postData.pid); await db.sortedSetsAdd(keys, postData.timestamp, postData.pid);

@ -77,7 +77,8 @@ module.exports = function (Groups) {
if (values.hasOwnProperty('memberPostCids')) { if (values.hasOwnProperty('memberPostCids')) {
const validCids = await categories.getCidsByPrivilege('categories:cid', groupName, 'topics:read'); 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); await db.setObject('group:' + groupName, payload);

@ -77,6 +77,9 @@ module.exports = function (privileges) {
}; };
privileges.categories.isUserAllowedTo = async function (privilege, cid, uid) { privileges.categories.isUserAllowedTo = async function (privilege, cid, uid) {
if ((Array.isArray(privilege) && !privilege.length) || (Array.isArray(cid) && !cid.length)) {
return [];
}
if (!cid) { if (!cid) {
return false; return false;
} }

@ -9,6 +9,8 @@ const sockets = require('.');
const SocketCategories = module.exports; const SocketCategories = module.exports;
require('./categories/search')(SocketCategories);
SocketCategories.getRecentReplies = async function (socket, cid) { SocketCategories.getRecentReplies = async function (socket, cid) {
return await categories.getRecentReplies(cid, socket.uid, 4); return await categories.getRecentReplies(cid, socket.uid, 4);
}; };
@ -148,7 +150,7 @@ SocketCategories.isModerator = async function (socket, cid) {
}; };
SocketCategories.getCategory = 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 api.categories.get(socket, { cid });
// return await apiController.getCategoryData(cid, socket.uid); // return await apiController.getCategoryData(cid, socket.uid);
}; };

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

@ -11,8 +11,6 @@ const privileges = require('../privileges');
module.exports = function (User) { module.exports = function (User) {
User.bans = {}; User.bans = {};
const systemGroups = groups.systemGroups.filter(group => group !== groups.BANNED_USERS);
User.bans.ban = async function (uid, until, reason) { User.bans.ban = async function (uid, until, reason) {
// "until" (optional) is unix timestamp in milliseconds // "until" (optional) is unix timestamp in milliseconds
// "reason" (optional) is a string // "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 // 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.leave(systemGroups, uid);
await groups.join(groups.BANNED_USERS, uid); await groups.join(groups.BANNED_USERS, uid);
await db.sortedSetAdd('users:banned', now, uid); await db.sortedSetAdd('users:banned', now, uid);

@ -38,26 +38,28 @@
<br/> <br/>
{{{ each categories }}} <!-- IMPORT partials/category-selector.tpl -->
<div class="categories category-wrapper category-depth-{categories.depth}">
<h4><!-- IF categories.icon --><i class="fa {categories.icon}"></i> <!-- ENDIF categories.icon -->[[admin/manage/admins-mods:moderators-of-category, {categories.name}]]{{{if categories.disabled}}}<span class="badge badge-primary">[[admin/manage/admins-mods:disabled]]</span>{{{end}}}</h4> {{{ each categoryMods }}}
<div class="moderator-area" data-cid="{categories.cid}"> <div class="categories category-wrapper category-depth-{categoryMods.depth}">
{{{ each categories.moderators }}} <h4>{{{ if categoryMods.icon }}}<i class="fa {categoryMods.icon}"></i> {{{ end }}}[[admin/manage/admins-mods:moderators-of-category, {categoryMods.name}]]{{{if categoryMods.disabled}}}<span class="badge badge-primary">[[admin/manage/admins-mods:disabled]]</span>{{{end}}}</h4>
<div class="user-card pull-left" data-uid="{categories.moderators.uid}"> <div class="moderator-area" data-cid="{categoryMods.cid}">
<!-- IF categories.moderators.picture --> {{{ each categoryMods.moderators }}}
<img class="avatar avatar-sm" src="{categories.moderators.picture}" /> <div class="user-card pull-left" data-uid="{categoryMods.moderators.uid}">
<!-- ELSE --> {{{ if categoryMods.moderators.picture }}}
<div class="avatar avatar-sm" style="background-color: {categories.moderators.icon:bgColor};">{categories.moderators.icon:text}</div> <img class="avatar avatar-sm" src="{categoryMods.moderators.picture}" />
<!-- ENDIF categories.moderators.picture --> {{{ else }}}
<a href="{config.relative_path}/user/{categories.moderators.userslug}">{categories.moderators.username}</a> <div class="avatar avatar-sm" style="background-color: {categoryMods.moderators.icon:bgColor};">{categoryMods.moderators.icon:text}</div>
{{{ end }}}
<a href="{config.relative_path}/user/{categoryMods.moderators.userslug}">{categoryMods.moderators.username}</a>
<i class="remove-user-icon fa fa-times" role="button"></i> <i class="remove-user-icon fa fa-times" role="button"></i>
</div> </div>
{{{ end }}} {{{ end }}}
</div> </div>
<div data-cid="{categories.cid}" class="no-moderator-warning <!-- IF categories.moderators.length -->hidden<!-- ENDIF categories.moderators.length -->">[[admin/manage/admins-mods:no-moderators]]</div> <div data-cid="{categoryMods.cid}" class="no-moderator-warning {{{ if categoryMods.moderators.length }}}hidden{{{ end }}}">[[admin/manage/admins-mods:no-moderators]]</div>
<input data-cid="{categories.cid}" class="form-control moderator-search" placeholder="[[admin/manage/admins-mods:add-moderator]]" /> <input data-cid="{categoryMods.cid}" class="form-control moderator-search" placeholder="[[admin/manage/admins-mods:add-moderator]]" />
</div> </div>
<br/> <br/>
{{{ end }}} {{{ end }}}

@ -1,11 +1,14 @@
<!-- IMPORT partials/breadcrumbs.tpl -->
<div class="row"> <div class="row">
<div class="col-lg-9"> <div class="col-lg-12">
<button id="collapse-all" class="btn btn-default">[[admin/manage/categories:collapse-all]]</button> <button id="expand-all" class="btn btn-default">[[admin/manage/categories:expand-all]]</button> <div class="category btn-group">
</div> <!-- IMPORT partials/category-selector.tpl -->
<div class="col-lg-3"> </div>
<div class="input-group"> <div class="btn-group">
<input type="text" class="form-control" placeholder="[[global:search]]" id="category-search"> <button id="collapse-all" class="btn btn-default">[[admin/manage/categories:collapse-all]]</button>
<span class="input-group-addon search-button"><i class="fa fa-search"></i></span> </div>
<div class="btn-group">
<button id="expand-all" class="btn btn-default">[[admin/manage/categories:expand-all]]</button>
</div> </div>
</div> </div>
</div> </div>
@ -14,7 +17,9 @@
<hr/> <hr/>
<div component="category/no-matches" class="hidden">[[admin/manage/categories:no-matches]]</div> <div component="category/no-matches" class="hidden">[[admin/manage/categories:no-matches]]</div>
<div class="categories"></div> <div class="categories"></div>
<div>
<!-- IMPORT partials/paginator.tpl -->
</div>
<button data-action="create" class="floating-button mdl-button mdl-js-button mdl-button--fab mdl-js-ripple-effect mdl-button--colored"> <button data-action="create" class="floating-button mdl-button mdl-js-button mdl-button--fab mdl-js-ripple-effect mdl-button--colored">
<i class="material-icons">add</i> <i class="material-icons">add</i>
</button> </button>

@ -99,14 +99,14 @@
<div class="form-group"> <div class="form-group">
<label for="memberPostCids">[[groups:details.member-post-cids]]</label> <label for="memberPostCids">[[groups:details.member-post-cids]]</label>
<select multiple="true" name="memberPostCids" id="memberPostCids" class="form-control" size="15"> <div class="row">
{{{each group.categories}}} <div class="col-md-9">
<option value="{categories.cid}"{{{ if ../selected }}} selected{{{ end }}}> <input id="memberPostCids" type="text" class="form-control" value="{group.memberPostCids}">
{../level}{../name} </div>
</option> <div class="col-md-3 member-post-cids-selector">
{{{end}}} <!-- IMPORT partials/category-selector.tpl -->
</select> </div>
<p class="help-block">[[groups:details.member-post-cids-help]]</p> </div>
</div> </div>
<hr /> <hr />
@ -143,7 +143,7 @@
</ul> </ul>
</div> </div>
<hr /> <hr />
<div class="well"> <div class="well edit-privileges-selector">
<strong class="pull-left">[[admin/manage/privileges:edit-privileges]]</strong><br /> <strong class="pull-left">[[admin/manage/privileges:edit-privileges]]</strong><br />
<!-- IMPORT partials/category-selector.tpl --> <!-- IMPORT partials/category-selector.tpl -->
</div> </div>

@ -41,6 +41,9 @@
{{{end}}} {{{end}}}
</a> </a>
</li> </li>
<li>
<a href="#" class="set-order" data-cid="{categories.cid}" data-order="{categories.order}">[[admin/manage/categories:set-order]]</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>

@ -5,7 +5,7 @@
[[admin/settings/homepage:description]] [[admin/settings/homepage:description]]
</p> </p>
<form class="row"> <form class="row">
<div class="col-sm-6"> <div class="col-sm-12">
<label>[[admin/settings/homepage:home-page-route]]</label> <label>[[admin/settings/homepage:home-page-route]]</label>
<select class="form-control" data-field="homePageRoute"> <select class="form-control" data-field="homePageRoute">
<!-- BEGIN routes --> <!-- BEGIN routes -->
@ -16,6 +16,7 @@
<br> <br>
<label>[[admin/settings/homepage:custom-route]]</label> <label>[[admin/settings/homepage:custom-route]]</label>
<input type="text" class="form-control" data-field="homePageCustom"/> <input type="text" class="form-control" data-field="homePageCustom"/>
<p class="help-block">[[user:custom_route_help]]</p>
</div> </div>
<br> <br>
<div class="checkbox"> <div class="checkbox">

@ -585,9 +585,7 @@ describe('Admin Controllers', function () {
assert.ifError(err); assert.ifError(err);
assert(body); assert(body);
assert(body.flags); assert(body.flags);
assert(body.categories);
assert(body.filters); assert(body.filters);
assert.equal(body.categories[cid], 'Test Category');
assert.equal(body.filters.cid.indexOf(cid), -1); assert.equal(body.filters.cid.indexOf(cid), -1);
done(); done();
}); });

@ -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 () { describe('setObjectField()', function () {

@ -40,6 +40,11 @@ const relativePath = urlObject.pathname !== '/' ? urlObject.pathname : '';
nconf.set('relative_path', relativePath); nconf.set('relative_path', relativePath);
nconf.set('upload_path', path.join(nconf.get('base_dir'), nconf.get('upload_path'))); nconf.set('upload_path', path.join(nconf.get('base_dir'), nconf.get('upload_path')));
nconf.set('upload_url', '/assets/uploads'); 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 // cookies don't provide isolation by port: http://stackoverflow.com/a/16328399/122353
const domain = nconf.get('cookieDomain') || urlObject.hostname; 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 // Parse out the relative_url and other goodies from the configured URL
const urlObject = url.parse(nconf.get('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('core_templates_path', path.join(__dirname, '../../src/views'));
nconf.set('base_templates_path', path.join(nconf.get('themes_path'), 'nodebb-theme-persona/templates')); nconf.set('base_templates_path', path.join(nconf.get('themes_path'), 'nodebb-theme-persona/templates'));

Loading…
Cancel
Save