diff --git a/package.json b/package.json index b449297adc..1efe0580c4 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "socket.io-redis": "^0.1.3", "socketio-wildcard": "~0.1.1", "string": "^3.0.0", - "templates.js": "0.1.9", + "templates.js": "0.1.10", "uglify-js": "git+https://github.com/julianlam/UglifyJS2.git", "underscore": "~1.7.0", "validator": "~3.26.0", diff --git a/public/images/cover-default.png b/public/images/cover-default.png new file mode 100644 index 0000000000..8b1d392408 Binary files /dev/null and b/public/images/cover-default.png differ diff --git a/public/language/en_GB/global.json b/public/language/en_GB/global.json index 78998f6f0e..92ef56998f 100644 --- a/public/language/en_GB/global.json +++ b/public/language/en_GB/global.json @@ -33,6 +33,7 @@ "header.tags": "Tags", "header.popular": "Popular", "header.users": "Users", + "header.groups": "Groups", "header.chats": "Chats", "header.notifications": "Notifications", "header.search": "Search", diff --git a/public/language/en_GB/groups.json b/public/language/en_GB/groups.json index cc4aeb8997..eb9a45f994 100644 --- a/public/language/en_GB/groups.json +++ b/public/language/en_GB/groups.json @@ -1,9 +1,25 @@ { "groups": "Groups", "view_group": "View Group", + "owner": "Group Owner", + "new_group": "Create New Group", + "no_groups_found": "There are no groups to see", + + "cover-instructions": "Drag and Drop a photo, drag to position, and hit Save", + "cover-change": "Change", + "cover-save": "Save", + "cover-saving": "Saving", "details.title": "Group Details", "details.members": "Member List", + "details.pending": "Pending Members", "details.has_no_posts": "This group's members have not made any posts.", - "details.latest_posts": "Latest Posts" + "details.latest_posts": "Latest Posts", + "details.private": "Private Group", + "details.public": "Public Group", + + "details.owner_options": "Group Administration", + + "event.updated": "Group details have been updated", + "event.deleted": "The group \"%1\" has been deleted" } \ No newline at end of file diff --git a/public/src/admin/manage/categories.js b/public/src/admin/manage/categories.js index 5bf1bfe945..54385b6d0c 100644 --- a/public/src/admin/manage/categories.js +++ b/public/src/admin/manage/categories.js @@ -3,7 +3,7 @@ define('admin/manage/categories', [ 'uploader', - 'admin/modules/iconSelect', + 'iconSelect', 'admin/modules/colorpicker' ], function(uploader, iconSelect, colorpicker) { var Categories = {}; diff --git a/public/src/admin/manage/groups.js b/public/src/admin/manage/groups.js index 4ab72c8570..dad2c5208b 100644 --- a/public/src/admin/manage/groups.js +++ b/public/src/admin/manage/groups.js @@ -2,7 +2,7 @@ /*global define, templates, socket, ajaxify, app, admin, bootbox*/ define('admin/manage/groups', [ - 'admin/modules/iconSelect', + 'iconSelect', 'admin/modules/colorpicker' ], function(iconSelect, colorpicker) { var Groups = {}; @@ -49,7 +49,7 @@ define('admin/manage/groups', [ }, errorText; - socket.emit('admin.groups.create', submitObj, function(err, data) { + socket.emit('admin.groups.create', submitObj, function(err) { if (err) { switch (err) { case 'group-exists': @@ -68,7 +68,7 @@ define('admin/manage/groups', [ createModalError.addClass('hide'); createGroupName.val(''); createModal.on('hidden.bs.modal', function() { - ajaxify.go('admin/manage/groups'); + ajaxify.refresh(); }); createModal.modal('hide'); } @@ -102,12 +102,14 @@ define('admin/manage/groups', [ case 'delete': bootbox.confirm('Are you sure you wish to delete this group?', function(confirm) { if (confirm) { - socket.emit('admin.groups.delete', groupName, function(err, data) { + socket.emit('groups.delete', { + groupName: groupName + }, function(err, data) { if(err) { return app.alertError(err.message); } - ajaxify.go('admin/manage/groups'); + ajaxify.refresh(); }); } }); diff --git a/public/src/client/groups/details.js b/public/src/client/groups/details.js index 12ca74884a..62371368e8 100644 --- a/public/src/client/groups/details.js +++ b/public/src/client/groups/details.js @@ -1,17 +1,242 @@ "use strict"; +/* globals define, socket, ajaxify, app, bootbox, RELATIVE_PATH */ -define('forum/groups/details', function() { - var Details = {}; +define('forum/groups/details', ['iconSelect', 'vendor/colorpicker/colorpicker', 'vendor/jquery/draggable-background/backgroundDraggable'], function(iconSelect) { + var Details = { + cover: {} + }; Details.init = function() { - var memberListEl = $('.groups.details .members'); + var detailsPage = $('.groups'), + settingsFormEl = detailsPage.find('form'); - memberListEl.on('click', '[data-slug]', function() { - var slug = this.getAttribute('data-slug'); - ajaxify.go('user/' + slug); - }); + if (ajaxify.variables.get('is_owner') === 'true') { + Details.prepareSettings(); + Details.initialiseCover(); + } $('.latest-posts .content img').addClass('img-responsive'); + + detailsPage.on('click', '[data-action]', function() { + var btnEl = $(this), + userRow = btnEl.parents('tr'), + ownerFlagEl = userRow.find('.member-name i'), + isOwner = !ownerFlagEl.hasClass('invisible') ? true : false, + uid = userRow.attr('data-uid'), + action = btnEl.attr('data-action'); + + switch(action) { + case 'toggleOwnership': + socket.emit('groups.' + (isOwner ? 'rescind' : 'grant'), { + toUid: uid, + groupName: ajaxify.variables.get('group_name') + }, function(err) { + if (!err) { + ownerFlagEl.toggleClass('invisible'); + } else { + app.alertError(err); + } + }); + break; + + case 'update': + Details.update(); + break; + + case 'delete': + Details.deleteGroup(); + break; + + case 'join': // intentional fall-throughs! + case 'leave': + case 'accept': + case 'reject': + socket.emit('groups.' + action, { + toUid: uid, + groupName: ajaxify.variables.get('group_name') + }, function(err) { + if (!err) { + ajaxify.refresh(); + } else { + app.alertError(err); + } + }); + break; + } + }); + }; + + Details.prepareSettings = function() { + var settingsFormEl = $('.groups form'), + colorBtn = settingsFormEl.find('[data-action="color-select"]'), + colorValueEl = settingsFormEl.find('[name="labelColor"]'), + iconBtn = settingsFormEl.find('[data-action="icon-select"]'), + previewEl = settingsFormEl.find('.label'), + previewIcon = previewEl.find('i'), + previewValueEl = settingsFormEl.find('[name="icon"]'); + + // Add color picker to settings form + colorBtn.ColorPicker({ + color: colorValueEl.val() || '#000', + onChange: function(hsb, hex) { + colorValueEl.val('#' + hex); + previewEl.css('background-color', '#' + hex); + }, + onShow: function(colpkr) { + $(colpkr).css('z-index', 1051); + } + }); + + // Add icon selection interface + iconBtn.on('click', function() { + iconSelect.init(previewIcon, function() { + previewValueEl.val(previewIcon.val()); + }); + }); + }; + + Details.update = function() { + var settingsFormEl = $('.groups form'), + checkboxes = settingsFormEl.find('input[type="checkbox"][name]'); + + if (settingsFormEl.length) { + require(['vendor/jquery/serializeObject/jquery.ba-serializeobject.min'], function() { + var settings = settingsFormEl.serializeObject(); + + // Fix checkbox values + checkboxes.each(function(idx, inputEl) { + inputEl = $(inputEl); + if (inputEl.length) { + settings[inputEl.attr('name')] = inputEl.prop('checked'); + } + }); + + socket.emit('groups.update', { + groupName: ajaxify.variables.get('group_name'), + values: settings + }, function(err) { + if (err) { + return app.alertError(err.message); + } + + if (settings.name) { + ajaxify.go('groups/' + encodeURIComponent(settings.name)); + } else { + ajaxify.refresh(); + } + + app.alertSuccess('[[groups:event.updated]]'); + }); + }); + } + }; + + Details.deleteGroup = function() { + bootbox.confirm('Are you sure you want to delete the group: ' + ajaxify.variables.get('group_name'), function(confirm) { + if (confirm) { + bootbox.prompt('Please enter the name of this group in order to delete it:', function(response) { + if (response === ajaxify.variables.get('group_name')) { + socket.emit('groups.delete', { + groupName: ajaxify.variables.get('group_name') + }, function(err) { + if (!err) { + app.alertSuccess('[[groups:event.deleted, ' + ajaxify.variables.get('group_name') + ']]'); + ajaxify.go('groups'); + } else { + app.alertError(err.message); + } + }); + } + }); + } + }); + }; + + // Cover Photo Handling Code + + Details.initialiseCover = function() { + var coverEl = $('.group-cover'); + coverEl.find('.change').on('click', function() { + coverEl.toggleClass('active', 1); + coverEl.backgroundDraggable({ + axis: 'y' + }); + coverEl.on('dragover', Details.cover.onDragOver); + coverEl.on('drop', Details.cover.onDrop); + }); + + coverEl.find('.save').on('click', Details.cover.save); + coverEl.addClass('initialised'); + }; + + Details.cover.load = function() { + socket.emit('groups.cover.get', { + groupName: ajaxify.variables.get('group_name') + }, function(err, data) { + if (!err) { + var coverEl = $('.group-cover'); + if (data['cover:url']) { + coverEl.css('background-image', 'url(' + data['cover:url'] + ')'); + } + + if (data['cover:position']) { + coverEl.css('background-position', data['cover:position']); + } + + delete Details.cover.newCover; + } else { + app.alertError(err.message); + } + }); + }; + + Details.cover.onDragOver = function(e) { + e.stopPropagation(); + e.preventDefault(); + e.originalEvent.dataTransfer.dropEffect = 'copy'; + }; + + Details.cover.onDrop = function(e) { + var coverEl = $('.group-cover'); + e.stopPropagation(); + e.preventDefault(); + + var files = e.originalEvent.dataTransfer.files, + reader = new FileReader(); + + if (files.length && files[0].type.match('image.*')) { + reader.onload = function(e) { + coverEl.css('background-image', 'url(' + e.target.result + ')'); + coverEl.backgroundDraggable(); + Details.cover.newCover = e.target.result; + }; + + reader.readAsDataURL(files[0]); + } + }; + + Details.cover.save = function() { + var coverEl = $('.group-cover'); + + coverEl.addClass('saving'); + + socket.emit('groups.cover.update', { + groupName: ajaxify.variables.get('group_name'), + imageData: Details.cover.newCover || undefined, + position: $('.group-cover').css('background-position') + }, function(err) { + if (!err) { + coverEl.toggleClass('active', 0); + coverEl.backgroundDraggable('disable'); + coverEl.off('dragover', Details.cover.onDragOver); + coverEl.off('drop', Details.cover.onDrop); + Details.cover.load(); + } else { + app.alertError(err.message); + } + + coverEl.removeClass('saving'); + }); }; return Details; diff --git a/public/src/client/groups/list.js b/public/src/client/groups/list.js new file mode 100644 index 0000000000..3fc76a4962 --- /dev/null +++ b/public/src/client/groups/list.js @@ -0,0 +1,35 @@ +"use strict"; +/* globals app, define, ajaxify, socket, bootbox */ + +define('forum/groups/list', function() { + var Groups = {}; + + Groups.init = function() { + var groupsEl = $('.groups.row'); + + groupsEl.on('click', '.list-cover', function() { + var groupName = $(this).parents('[data-group]').attr('data-group'); + + ajaxify.go('groups/' + groupName); + }); + + // Group creation + $('button[data-action="new"]').on('click', function() { + bootbox.prompt('Group Name:', function(name) { + if (name && name.length) { + socket.emit('groups.create', { + name: name + }, function(err) { + if (!err) { + ajaxify.go('groups/' + name); + } else { + app.alertError(err.message); + } + }); + } + }); + }); + }; + + return Groups; +}); \ No newline at end of file diff --git a/public/src/modules/helpers.js b/public/src/modules/helpers.js index 838ad7a4da..bc40bde658 100644 --- a/public/src/modules/helpers.js +++ b/public/src/modules/helpers.js @@ -26,7 +26,7 @@ if (groupObj.isMember) { return ''; } else { - if (groupObj.pending) { + if (groupObj.isPending) { return ''; } else { return ''; diff --git a/public/src/admin/modules/iconSelect.js b/public/src/modules/iconSelect.js similarity index 83% rename from public/src/admin/modules/iconSelect.js rename to public/src/modules/iconSelect.js index 286009bf0f..88ab1903ed 100644 --- a/public/src/admin/modules/iconSelect.js +++ b/public/src/modules/iconSelect.js @@ -7,7 +7,9 @@ define(function() { iconSelect.init = function(el, onModified) { onModified = onModified || function() {}; - var selected = el.attr('class').replace('fa-2x', '').replace('fa', '').replace(/\s+/g, ''); + var doubleSize = el.hasClass('fa-2x'), + selected = el.attr('class').replace('fa-2x', '').replace('fa', '').replace(/\s+/g, ''); + $('#icons .selected').removeClass('selected'); if (selected === '') { @@ -25,7 +27,7 @@ define(function() { categoryIconClass = ''; } - el.attr('class', 'fa fa-2x ' + categoryIconClass); + el.attr('class', 'fa ' + (doubleSize ? 'fa-2x ' : '') + categoryIconClass); el.val(categoryIconClass); el.attr('value', categoryIconClass); diff --git a/public/vendor/colorpicker/colorpicker.css b/public/vendor/colorpicker/colorpicker.css index b5b2158534..5ee96ba682 100644 --- a/public/vendor/colorpicker/colorpicker.css +++ b/public/vendor/colorpicker/colorpicker.css @@ -3,7 +3,7 @@ height: 176px; overflow: hidden; position: absolute; - background: url(./images/custom_background.png); + background: url(./vendor/colorpicker/images/custom_background.png); font-family: Arial, Helvetica, sans-serif; display: none; } @@ -23,7 +23,7 @@ left: 0; width: 150px; height: 150px; - background: url(./images/colorpicker_overlay.png); + background: url(./vendor/colorpicker/images/colorpicker_overlay.png); } .colorpicker_color div div { position: absolute; @@ -32,7 +32,7 @@ width: 11px; height: 11px; overflow: hidden; - background: url(./images/colorpicker_select.gif); + background: url(./vendor/colorpicker/images/colorpicker_select.gif); margin: -5px 0 0 -5px; } .colorpicker_hue { @@ -48,7 +48,7 @@ width: 35px; height: 9px; overflow: hidden; - background: url(./images/custom_indic.gif) left top; + background: url(./vendor/colorpicker/images/custom_indic.gif) left top; margin: -4px 0 0 0; left: 0px; } @@ -86,7 +86,7 @@ position: absolute; width: 72px; height: 22px; - background: url(./images/custom_hex.png) top; + background: url(./vendor/colorpicker/images/custom_hex.png) top; left: 212px; top: 142px; } @@ -109,32 +109,32 @@ cursor: n-resize; } .colorpicker_rgb_r { - background-image: url(./images/custom_rgb_r.png); + background-image: url(./vendor/colorpicker/images/custom_rgb_r.png); top: 52px; left: 212px; } .colorpicker_rgb_g { - background-image: url(./images/custom_rgb_g.png); + background-image: url(./vendor/colorpicker/images/custom_rgb_g.png); top: 82px; left: 212px; } .colorpicker_rgb_b { - background-image: url(./images/custom_rgb_b.png); + background-image: url(./vendor/colorpicker/images/custom_rgb_b.png); top: 112px; left: 212px; } .colorpicker_hsb_h { - background-image: url(./images/custom_hsb_h.png); + background-image: url(./vendor/colorpicker/images/custom_hsb_h.png); top: 52px; left: 282px; } .colorpicker_hsb_s { - background-image: url(./images/custom_hsb_s.png); + background-image: url(./vendor/colorpicker/images/custom_hsb_s.png); top: 82px; left: 282px; } .colorpicker_hsb_b { - background-image: url(./images/custom_hsb_b.png); + background-image: url(./vendor/colorpicker/images/custom_hsb_b.png); top: 112px; left: 282px; } @@ -143,7 +143,7 @@ position: absolute; width: 22px; height: 22px; - background: url(./images/custom_submit.png) top; + background: url(./vendor/colorpicker/images/custom_submit.png) top; left: 322px; top: 142px; overflow: hidden; diff --git a/public/vendor/jquery/draggable-background/backgroundDraggable.js b/public/vendor/jquery/draggable-background/backgroundDraggable.js new file mode 100644 index 0000000000..baae4d0f95 --- /dev/null +++ b/public/vendor/jquery/draggable-background/backgroundDraggable.js @@ -0,0 +1,157 @@ +/** + * Draggable Background plugin for jQuery + * + * v1.2.4 + * + * Copyright (c) 2014 Kenneth Chung + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/mit-license.php + */ +;(function($) { + var $window = $(window); + + // Helper function to guarantee a value between low and hi unless bool is false + var limit = function(low, hi, value, bool) { + if (arguments.length === 3 || bool) { + if (value < low) return low; + if (value > hi) return hi; + } + return value; + }; + + // Adds clientX and clientY properties to the jQuery's event object from touch + var modifyEventForTouch = function(e) { + e.clientX = e.originalEvent.touches[0].clientX; + e.clientY = e.originalEvent.touches[0].clientY; + }; + + var getBackgroundImageDimensions = function($el) { + var bgSrc = ($el.css('background-image').match(/^url\(['"]?(.*?)['"]?\)$/i) || [])[1]; + if (!bgSrc) return; + + var imageDimensions = { width: 0, height: 0 }, + image = new Image(); + + image.onload = function() { + if ($el.css('background-size') == "cover") { + var elementWidth = $el.innerWidth(), + elementHeight = $el.innerHeight(), + elementAspectRatio = elementWidth / elementHeight; + imageAspectRatio = image.width / image.height, + scale = 1; + + if (imageAspectRatio >= elementAspectRatio) { + scale = elementHeight / image.height; + } else { + scale = elementWidth / image.width; + } + + imageDimensions.width = image.width * scale; + imageDimensions.height = image.height * scale; + } else { + imageDimensions.width = image.width; + imageDimensions.height = image.height; + } + }; + + image.src = bgSrc; + + return imageDimensions; + }; + + function Plugin(element, options) { + this.element = element; + this.options = options; + this.init(); + } + + Plugin.prototype.init = function() { + var $el = $(this.element), + bgSrc = ($el.css('background-image').match(/^url\(['"]?(.*?)['"]?\)$/i) || [])[1], + options = this.options; + + if (!bgSrc) return; + + // Get the image's width and height if bound + var imageDimensions = { width: 0, height: 0 }; + if (options.bound) { + imageDimensions = getBackgroundImageDimensions($el); + } + + $el.on('mousedown.dbg touchstart.dbg', function(e) { + if (e.target !== $el[0]) { + return; + } + e.preventDefault(); + + if (e.originalEvent.touches) { + modifyEventForTouch(e); + } else if (e.which !== 1) { + return; + } + + var x0 = e.clientX, + y0 = e.clientY, + pos = $el.css('background-position').match(/(-?\d+).*?\s(-?\d+)/) || [], + xPos = parseInt(pos[1]) || 0, + yPos = parseInt(pos[2]) || 0; + + $window.on('mousemove.dbg touchmove.dbg', function(e) { + e.preventDefault(); + + if (e.originalEvent.touches) { + modifyEventForTouch(e); + } + + var x = e.clientX, + y = e.clientY; + + xPos = options.axis === 'y' ? xPos : limit($el.innerWidth()-imageDimensions.width, 0, xPos+x-x0, options.bound); + yPos = options.axis === 'x' ? yPos : limit($el.innerHeight()-imageDimensions.height, 0, yPos+y-y0, options.bound); + x0 = x; + y0 = y; + + $el.css('background-position', xPos + 'px ' + yPos + 'px'); + }); + + $window.on('mouseup.dbg touchend.dbg mouseleave.dbg', function() { + if (options.done) { + options.done(); + } + + $window.off('mousemove.dbg touchmove.dbg'); + $window.off('mouseup.dbg touchend.dbg mouseleave.dbg'); + }); + }); + }; + + Plugin.prototype.disable = function() { + var $el = $(this.element); + $el.off('mousedown.dbg touchstart.dbg'); + $window.off('mousemove.dbg touchmove.dbg mouseup.dbg touchend.dbg mouseleave.dbg'); + } + + $.fn.backgroundDraggable = function(options) { + var options = options; + var args = Array.prototype.slice.call(arguments, 1); + + return this.each(function() { + var $this = $(this); + + if (typeof options == 'undefined' || typeof options == 'object') { + options = $.extend({}, $.fn.backgroundDraggable.defaults, options); + var plugin = new Plugin(this, options); + $this.data('dbg', plugin); + } else if (typeof options == 'string' && $this.data('dbg')) { + var plugin = $this.data('dbg'); + Plugin.prototype[options].apply(plugin, args); + } + }); + }; + + $.fn.backgroundDraggable.defaults = { + bound: true, + axis: undefined + }; +}(jQuery)); \ No newline at end of file diff --git a/src/controllers/admin.js b/src/controllers/admin.js index 5a43e1ba6c..5ccfa3a6df 100644 --- a/src/controllers/admin.js +++ b/src/controllers/admin.js @@ -237,6 +237,9 @@ adminController.extend.widgets = function(req, res, next) { { name: 'Global Sidebar', template: 'global', location: 'sidebar' }, { name: 'Global Header', template: 'global', location: 'header' }, { name: 'Global Footer', template: 'global', location: 'footer' }, + + { name: 'Group Page (Left)', template: 'groups/details.tpl', location: 'left'}, + { name: 'Group Page (Right)', template: 'groups/details.tpl', location: 'right'} ]; plugins.fireHook('filter:widgets.getAreas', defaultAreas, next); diff --git a/src/controllers/groups.js b/src/controllers/groups.js index 3843672a09..2d2ce8f050 100644 --- a/src/controllers/groups.js +++ b/src/controllers/groups.js @@ -9,7 +9,8 @@ var groups = require('../groups'), groupsController.list = function(req, res, next) { groups.list({ truncateUserList: true, - expand: true + expand: true, + uid: req.user ? req.user.uid : 0 }, function(err, groups) { if (err) { return next(err); @@ -22,10 +23,12 @@ groupsController.list = function(req, res, next) { groupsController.details = function(req, res, next) { var uid = req.user ? parseInt(req.user.uid, 10) : 0; + async.parallel({ group: function(next) { groups.get(req.params.name, { - expand: true + expand: true, + uid: uid }, next); }, posts: function(next) { diff --git a/src/controllers/uploads.js b/src/controllers/uploads.js index 237ae5c453..b7e590b6bf 100644 --- a/src/controllers/uploads.js +++ b/src/controllers/uploads.js @@ -72,6 +72,10 @@ uploadsController.uploadThumb = function(req, res, next) { }, next); }; +uploadsController.uploadGroupCover = function(data, next) { + uploadImage(0/*req.user.uid*/, data, next); +}; + function uploadImage(uid, image, callback) { if (plugins.hasListeners('filter:uploadImage')) { return plugins.fireHook('filter:uploadImage', {image: image, uid: uid}, callback); diff --git a/src/groups.js b/src/groups.js index 47e7002db9..a3799b4bcf 100644 --- a/src/groups.js +++ b/src/groups.js @@ -3,14 +3,20 @@ var async = require('async'), winston = require('winston'), _ = require('underscore'), + crypto = require('crypto'), + path = require('path'), + nconf = require('nconf'), + fs = require('fs'), + user = require('./user'), meta = require('./meta'), db = require('./database'), plugins = require('./plugins'), posts = require('./posts'), privileges = require('./privileges'), - utils = require('../public/src/utils'); + utils = require('../public/src/utils'), + uploadsController = require('./controllers/uploads'); (function(Groups) { @@ -60,7 +66,12 @@ var async = require('async'), } return groups; - } + }/*, + fixImageUrl: function(url) { + if (url) { + return url.indexOf('http') === -1 ? nconf.get('relative_path') + url : url; + } + }*/ }; Groups.list = function(options, callback) { @@ -104,35 +115,132 @@ var async = require('async'), } } + if (options.expand) { + async.waterfall([ + async.apply(async.map, uids, user.getUserData), + function(users, next) { + // Filter out non-matches + users = users.filter(Boolean); + + async.mapLimit(users, 10, function(userObj, next) { + Groups.ownership.isOwner(userObj.uid, groupName, function(err, isOwner) { + if (err) { + winston.warn('[groups.get] Could not determine ownership in group `' + groupName + '` for uid `' + userObj.uid + '`: ' + err.message); + return next(null, userObj); + } + + userObj.isOwner = isOwner; + next(null, userObj); + }); + }, next); + } + ], next); + } else { + next(err, uids); + } + }); + }, + pending: function (next) { + db.getSetMembers('group:' + groupName + ':pending', function (err, uids) { + if (err) { + return next(err); + } + if (options.expand) { async.map(uids, user.getUserData, next); } else { next(err, uids); } }); + }, + isMember: function(next) { + // Retrieve group membership state, if uid is passed in + if (!options.uid) { + return next(); + } + + Groups.isMember(options.uid, groupName, function(err, isMember) { + if (err) { + winston.warn('[groups.get] Could not determine membership in group `' + groupName + '` for uid `' + options.uid + '`: ' + err.message); + return next(); + } + + next(null, isMember); + }); + }, + isPending: function(next) { + // Retrieve group membership state, if uid is passed in + if (!options.uid) { + return next(); + } + + db.isSetMember('group:' + groupName + ':pending', options.uid, next); + }, + isOwner: function(next) { + // Retrieve group ownership state, if uid is passed in + if (!options.uid) { + return next(); + } + + Groups.ownership.isOwner(options.uid, groupName, function(err, isOwner) { + if (err) { + winston.warn('[groups.get] Could not determine ownership in group `' + groupName + '` for uid `' + options.uid + '`: ' + err.message); + return next(); + } + + next(null, isOwner); + }); } }, function (err, results) { if (err || !results.base) { return callback(err); } - results.base.members = results.users.filter(function(user) { - return typeof user !== 'undefined'; - }); + // Default image + if (!results.base['cover:url']) { + results.base['cover:url'] = nconf.get('relative_path') + '/images/cover-default.png'; + results.base['cover:position'] = '50% 50%'; + } + results.base.members = results.users.filter(Boolean); + results.base.pending = results.pending.filter(Boolean); results.base.count = numUsers || results.base.members.length; results.base.memberCount = numUsers || results.base.members.length; - results.base.deleted = !!parseInt(results.base.deleted, 10); results.base.hidden = !!parseInt(results.base.hidden, 10); results.base.system = !!parseInt(results.base.system, 10); + results.base.private = results.base.private ? !!parseInt(results.base.private, 10) : true; results.base.deletable = !results.base.system; results.base.truncated = truncated; + results.base.isMember = results.isMember; + results.base.isPending = results.isPending; + results.base.isOwner = results.isOwner; callback(err, results.base); }); }; + Groups.getGroupFields = function(groupName, fields, callback) { + db.getObjectFields('group:' + groupName, fields, callback); + }; + + Groups.setGroupField = function(groupName, field, value, callback) { + plugins.fireHook('action:group.set', {field: field, value: value, type: 'set'}); + db.setObjectField('group:' + groupName, field, value, callback); + }; + + Groups.isPrivate = function(groupName, callback) { + db.getObjectField('group:' + groupName, 'private', function(err, isPrivate) { + isPrivate = isPrivate || isPrivate === null; + + if (typeof isPrivate === 'string') { + isPrivate = (isPrivate === '0' ? false : true); + } + + callback(err, isPrivate); // Private, if not set at all + }); + }; + Groups.getMembers = function(groupName, callback) { db.getSetMembers('group:' + groupName + ':members', callback); }; @@ -283,16 +391,16 @@ var async = require('async'), } }; - Groups.create = function(name, description, callback) { - if (name.length === 0) { + Groups.create = function(data, callback) { + if (data.name.length === 0) { return callback(new Error('[[error:group-name-too-short]]')); } - if (name === 'administrators' || name === 'registered-users') { + if (data.name === 'administrators' || data.name === 'registered-users') { var system = true; } - meta.userOrGroupExists(name, function (err, exists) { + meta.userOrGroupExists(data.name, function (err, exists) { if (err) { return callback(err); } @@ -302,24 +410,25 @@ var async = require('async'), } var groupData = { - name: name, - userTitle: name, - description: description, - deleted: '0', - hidden: '0', - system: system ? '1' : '0' - }; - - async.parallel([ - function(next) { - db.setAdd('groups', name, next); + name: data.name, + userTitle: data.name, + description: data.description || '', + deleted: '0', + hidden: '0', + system: system ? '1' : '0', + 'private': data.private || '1' }, - function(next) { - db.setObject('group:' + name, groupData, function(err) { - Groups.get(name, {}, next); - }); - } - ], callback); + tasks = [ + async.apply(db.setAdd, 'groups', data.name), + async.apply(db.setObject, 'group:' + data.name, groupData) + ]; + + if (data.hasOwnProperty('ownerUid')) { + tasks.push(async.apply(db.setAdd, 'group:' + data.name + ':owners', data.ownerUid)); + tasks.push(async.apply(db.setAdd, 'group:' + data.name + ':members', data.ownerUid)); + } + + async.parallel(tasks, callback); }); }; @@ -340,7 +449,8 @@ var async = require('async'), description: values.description || '', icon: values.icon || '', labelColor: values.labelColor || '#000000', - hidden: values.hidden || '0' + hidden: values.hidden || '0', + 'private': values.private === false ? '0' : '1' }, function(err) { if (err) { return callback(err); @@ -425,15 +535,11 @@ var async = require('async'), Groups.destroy = function(groupName, callback) { async.parallel([ - function(next) { - db.delete('group:' + groupName, next); - }, - function(next) { - db.setRemove('groups', groupName, next); - }, - function(next) { - db.delete('group:' + groupName + ':members', next); - }, + async.apply(db.delete, 'group:' + groupName), + async.apply(db.setRemove, 'groups', groupName), + async.apply(db.delete, 'group:' + groupName + ':members'), + async.apply(db.delete, 'group:' + groupName + ':pending'), + async.apply(db.delete, 'group:' + groupName + ':owners'), function(next) { db.getSetMembers('groups', function(err, groups) { if (err) { @@ -458,7 +564,10 @@ var async = require('async'), uid: uid }); } else { - Groups.create(groupName, '', function(err) { + Groups.create({ + name: groupName, + description: '' + }, function(err) { if (err && err.message !== '[[error:group-already-exists]]') { winston.error('[groups.join] Could not create new hidden group: ' + err.message); return callback(err); @@ -475,6 +584,25 @@ var async = require('async'), }); }; + Groups.requestMembership = function(groupName, uid, callback) { + db.setAdd('group:' + groupName + ':pending', uid, callback); + plugins.fireHook('action:groups.requestMembership', { + groupName: groupName, + uid: uid + }); + }; + + Groups.acceptMembership = function(groupName, uid, callback) { + // Note: For simplicity, this method intentially doesn't check the caller uid for ownership! + db.setRemove('group:' + groupName + ':pending', uid, callback); + Groups.join.apply(Groups, arguments); + }; + + Groups.rejectMembership = function(groupName, uid, callback) { + // Note: For simplicity, this method intentially doesn't check the caller uid for ownership! + db.setRemove('group:' + groupName + ':pending', uid, callback); + }; + Groups.leave = function(groupName, uid, callback) { callback = callback || function() {}; @@ -585,4 +713,88 @@ var async = require('async'), }); }); }; + + Groups.updateCoverPosition = function(groupName, position, callback) { + Groups.setGroupField(groupName, 'cover:position', position, callback); + }; + + Groups.updateCover = function(data, callback) { + var tempPath, md5sum, url; + + // Position only? That's fine + if (!data.imageData && data.position) { + return Groups.updateCoverPosition(data.groupName, data.position, callback); + } + + async.series([ + function(next) { + // Calculate md5sum of image + // This is required because user data can be private + md5sum = crypto.createHash('md5'); + md5sum.update(data.imageData); + md5sum = md5sum.digest('hex'); + next(); + }, + function(next) { + // Save image + tempPath = path.join(nconf.get('base_dir'), nconf.get('upload_path'), md5sum); + var buffer = new Buffer(data.imageData.slice(data.imageData.indexOf('base64') + 7), 'base64'); + + fs.writeFile(tempPath, buffer, { + encoding: 'base64' + }, next); + }, + function(next) { + uploadsController.uploadGroupCover({ + path: tempPath + }, function(err, uploadData) { + if (err) { + return next(err); + } + + url = uploadData.url; + next(); + }); + }, + function(next) { + Groups.setGroupField(data.groupName, 'cover:url', url, next); + }, + function(next) { + fs.unlink(tempPath, next); // Delete temporary file + } + ], function(err) { + if (err) { + return callback(err); + } + + Groups.updateCoverPosition(data.groupName, data.position, callback); + }); + } + + Groups.ownership = {}; + + Groups.ownership.isOwner = function(uid, groupName, callback) { + // Note: All admins are also owners + async.waterfall([ + async.apply(db.isSetMember, 'group:' + groupName + ':owners', uid), + function(isOwner, next) { + if (isOwner) { + return next(null, isOwner); + } + + user.isAdministrator(uid, next); + } + ], callback); + }; + + Groups.ownership.grant = function(toUid, groupName, callback) { + // Note: No ownership checking is done here on purpose! + db.setAdd('group:' + groupName + ':owners', toUid, callback); + }; + + Groups.ownership.rescind = function(toUid, groupName, callback) { + // Note: No ownership checking is done here on purpose! + db.setRemove('group:' + groupName + ':owners', toUid, callback); + }; + }(module.exports)); diff --git a/src/meta/css.js b/src/meta/css.js index 5021e9b302..9c00ba8633 100644 --- a/src/meta/css.js +++ b/src/meta/css.js @@ -49,9 +49,11 @@ module.exports = function(Meta) { source += '\n@import (inline) "..' + path.sep + '..' + path.sep + 'public/vendor/jquery/css/smoothness/jquery-ui-1.10.4.custom.min.css";'; source += '\n@import (inline) "..' + path.sep + '..' + path.sep + 'public/vendor/jquery/bootstrap-tagsinput/bootstrap-tagsinput.css";'; - + source += '\n@import (inline) "..' + path.sep + '..' + path.sep + 'public/vendor/colorpicker/colorpicker.css";'; acpSource = '\n@import "..' + path.sep + 'public/less/admin/admin";\n' + source; + acpSource += '\n@import (inline) "..' + path.sep + 'public/vendor/colorpicker/colorpicker.css";'; + source = '@import "./theme";\n' + source; async.parallel([ diff --git a/src/socket.io/admin/groups.js b/src/socket.io/admin/groups.js index 9aec355986..9700bee3ca 100644 --- a/src/socket.io/admin/groups.js +++ b/src/socket.io/admin/groups.js @@ -8,13 +8,11 @@ Groups.create = function(socket, data, callback) { return callback(new Error('[[error:invalid-data]]')); } - groups.create(data.name, data.description, function(err, groupObj) { - callback(err, groupObj || undefined); - }); -}; - -Groups.delete = function(socket, groupName, callback) { - groups.destroy(groupName, callback); + groups.create({ + name: data.name, + description: data.description, + ownerUid: socket.uid + }, callback); }; Groups.get = function(socket, groupName, callback) { @@ -39,6 +37,7 @@ Groups.leave = function(socket, data, callback) { groups.leave(data.groupName, data.uid, callback); }; +// Possibly remove this and call SocketGroups.update instead Groups.update = function(socket, data, callback) { if(!data) { return callback(new Error('[[error:invalid-data]]')); diff --git a/src/socket.io/groups.js b/src/socket.io/groups.js new file mode 100644 index 0000000000..c4b4535e82 --- /dev/null +++ b/src/socket.io/groups.js @@ -0,0 +1,151 @@ +"use strict"; + +var groups = require('../groups'), + meta = require('../meta'), + + SocketGroups = {}; + +SocketGroups.join = function(socket, data, callback) { + if (!data) { + return callback(new Error('[[error:invalid-data]]')); + } + + if (meta.config.allowPrivateGroups !== '0') { + groups.isPrivate(data.groupName, function(err, isPrivate) { + if (isPrivate) { + groups.requestMembership(data.groupName, socket.uid, callback); + } else { + groups.join(data.groupName, socket.uid, callback); + } + }); + } else { + groups.join(data.groupName, socket.uid, callback); + } +}; + +SocketGroups.leave = function(socket, data, callback) { + if (!data) { + return callback(new Error('[[error:invalid-data]]')); + } + + groups.leave(data.groupName, socket.uid, callback); +}; + +SocketGroups.grant = function(socket, data, callback) { + if (!data) { + return callback(new Error('[[error:invalid-data]]')); + } + + groups.ownership.isOwner(socket.uid, data.groupName, function(err, isOwner) { + if (!isOwner) { + return callback(new Error('[[error:no-privileges]]')); + } + + groups.ownership.grant(data.toUid, data.groupName, callback); + }); +}; + +SocketGroups.rescind = function(socket, data, callback) { + if (!data) { + return callback(new Error('[[error:invalid-data]]')); + } + + groups.ownership.isOwner(socket.uid, data.groupName, function(err, isOwner) { + if (!isOwner) { + return callback(new Error('[[error:no-privileges]]')); + } + + groups.ownership.rescind(data.toUid, data.groupName, callback); + }); +}; + +SocketGroups.accept = function(socket, data, callback) { + if (!data) { + return callback(new Error('[[error:invalid-data]]')); + } + + groups.ownership.isOwner(socket.uid, data.groupName, function(err, isOwner) { + if (!isOwner) { + return callback(new Error('[[error:no-privileges]]')); + } + + groups.acceptMembership(data.groupName, data.toUid, callback); + }); +}; + +SocketGroups.reject = function(socket, data, callback) { + if (!data) { + return callback(new Error('[[error:invalid-data]]')); + } + + groups.ownership.isOwner(socket.uid, data.groupName, function(err, isOwner) { + if (!isOwner) { + return callback(new Error('[[error:no-privileges]]')); + } + + groups.rejectMembership(data.groupName, data.toUid, callback); + }); +}; + +SocketGroups.update = function(socket, data, callback) { + if(!data) { + return callback(new Error('[[error:invalid-data]]')); + } + + groups.ownership.isOwner(socket.uid, data.groupName, function(err, isOwner) { + if (!isOwner) { + return callback(new Error('[[error:no-privileges]]')); + } + + groups.update(data.groupName, data.values, callback); + }); +}; + +SocketGroups.create = function(socket, data, callback) { + if(!data) { + return callback(new Error('[[error:invalid-data]]')); + } else if (socket.uid === 0) { + return callback(new Error('[[error:no-privileges]]')); + } + + data.ownerUid = socket.uid; + groups.create(data, callback); +}; + +SocketGroups.delete = function(socket, data, callback) { + if(!data) { + return callback(new Error('[[error:invalid-data]]')); + } + + groups.ownership.isOwner(socket.uid, data.groupName, function(err, isOwner) { + if (!isOwner) { + return callback(new Error('[[error:no-privileges]]')); + } + + groups.destroy(data.groupName, callback); + }); +}; + +SocketGroups.cover = {}; + +SocketGroups.cover.get = function(socket, data, callback) { + groups.getGroupFields(data.groupName, ['cover:url', 'cover:position'], callback); +}; + +SocketGroups.cover.update = function(socket, data, callback) { + if(!data) { + return callback(new Error('[[error:invalid-data]]')); + } else if (socket.uid === 0) { + return callback(new Error('[[error:no-privileges]]')); + } + + groups.ownership.isOwner(socket.uid, data.groupName, function(err, isOwner) { + if (!isOwner) { + return callback(new Error('[[error:no-privileges]]')); + } + + groups.updateCover(data, callback); + }); +}; + +module.exports = SocketGroups; diff --git a/src/views/admin/extend/widgets.tpl b/src/views/admin/extend/widgets.tpl index 4571f70d1f..1f504fbdf1 100644 --- a/src/views/admin/extend/widgets.tpl +++ b/src/views/admin/extend/widgets.tpl @@ -10,7 +10,7 @@ -
+
diff --git a/src/views/admin/header.tpl b/src/views/admin/header.tpl index dfa2dc9c89..9bd680936a 100644 --- a/src/views/admin/header.tpl +++ b/src/views/admin/header.tpl @@ -5,7 +5,6 @@ - diff --git a/src/views/admin/manage/categories.tpl b/src/views/admin/manage/categories.tpl index b3b9427abb..2c0dcf48da 100644 --- a/src/views/admin/manage/categories.tpl +++ b/src/views/admin/manage/categories.tpl @@ -149,7 +149,7 @@
- +
diff --git a/src/views/admin/manage/groups.tpl b/src/views/admin/manage/groups.tpl index ee8f8f24ea..69f8bad37b 100644 --- a/src/views/admin/manage/groups.tpl +++ b/src/views/admin/manage/groups.tpl @@ -136,7 +136,7 @@
- +
diff --git a/src/views/admin/partials/fontawesome.tpl b/src/views/admin/partials/fontawesome.tpl deleted file mode 100644 index b0263d95a9..0000000000 --- a/src/views/admin/partials/fontawesome.tpl +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/views/admin/partials/menu.tpl b/src/views/admin/partials/menu.tpl index 7059ad6ba0..29f049fa03 100644 --- a/src/views/admin/partials/menu.tpl +++ b/src/views/admin/partials/menu.tpl @@ -23,6 +23,7 @@
  • Reputation
  • Email
  • User
  • +
  • Group
  • Guest
  • Post
  • Pagination
  • diff --git a/src/views/admin/settings/group.tpl b/src/views/admin/settings/group.tpl new file mode 100644 index 0000000000..7da62689ce --- /dev/null +++ b/src/views/admin/settings/group.tpl @@ -0,0 +1,22 @@ + + +
    +
    General
    +
    +
    +
    + +
    +
    +
    +
    + + \ No newline at end of file