Merge branch 'private-groups', closed #2588
commit
50ac813388
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
@ -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 <strong>Save</strong>",
|
||||
"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"
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
});
|
@ -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));
|
@ -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;
|
File diff suppressed because one or more lines are too long
@ -0,0 +1,22 @@
|
||||
<!-- IMPORT admin/settings/header.tpl -->
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">General</div>
|
||||
<div class="panel-body">
|
||||
<form role="form">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" data-field="allowPrivateGroups" checked> <strong>Private Groups</strong>
|
||||
<p class="help-block">
|
||||
If enabled, joining of groups requires the approval of the group owner <em>(Default: enabled)</em>
|
||||
</p>
|
||||
<p class="help-block">
|
||||
<strong>Beware!</strong> If this option is disabled and you have private groups, they automatically become public.
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IMPORT admin/settings/footer.tpl -->
|
Loading…
Reference in New Issue