Initial commit
This commit is contained in:
39
lib/assets/javascripts/builder/embed/embed-banner-view.js
Normal file
39
lib/assets/javascripts/builder/embed/embed-banner-view.js
Normal file
@@ -0,0 +1,39 @@
|
||||
var CoreView = require('backbone/core-view');
|
||||
var template = require('./embed-banner.tpl');
|
||||
|
||||
const SIGN_UP_URL = 'https://carto.com/signup';
|
||||
const REMOVE_BANNER_URL = 'https://carto.com/help/building-maps/remove-banner-from-map/';
|
||||
|
||||
var EmbedBannerView = CoreView.extend({
|
||||
|
||||
className: 'CDB-Embed-banner--inner',
|
||||
|
||||
events: {
|
||||
'click .js-close': '_close'
|
||||
},
|
||||
|
||||
initialize: function () {
|
||||
this.template = template;
|
||||
},
|
||||
|
||||
render: function () {
|
||||
this.$el.html(this.template({
|
||||
startNowUrl: this._getStartNowUrl(),
|
||||
removeBannerUrl: REMOVE_BANNER_URL
|
||||
}));
|
||||
return this;
|
||||
},
|
||||
|
||||
_getStartNowUrl: function () {
|
||||
return SIGN_UP_URL +
|
||||
'?utm_source=embed' +
|
||||
'&utm_medium=referral' +
|
||||
'&utm_campaing=Free_Public_Map_Banner';
|
||||
},
|
||||
|
||||
_close: function () {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = EmbedBannerView;
|
||||
6
lib/assets/javascripts/builder/embed/embed-banner.tpl
Normal file
6
lib/assets/javascripts/builder/embed/embed-banner.tpl
Normal file
@@ -0,0 +1,6 @@
|
||||
<div <div class="CDB-Embed-banner__inner">
|
||||
<div class="CDB-Embed-banner__close js-close"></div>
|
||||
<p class="CDB-Embed-banner__title">Want to create maps like this?</p>
|
||||
<a class="CDB-Embed-banner__button" href="<%= startNowUrl %>" target="_blank"><span>Start now</span></a>
|
||||
<a class="CDB-Embed-banner__link" href="<%= removeBannerUrl %>" target="_blank">Remove this banner</a>
|
||||
</div>
|
||||
82
lib/assets/javascripts/builder/embed/embed-integrations.js
Normal file
82
lib/assets/javascripts/builder/embed/embed-integrations.js
Normal file
@@ -0,0 +1,82 @@
|
||||
var checkAndBuildOpts = require('builder/helpers/required-opts');
|
||||
var LegendManager = require('./legend-manager');
|
||||
|
||||
var REQUIRED_OPTS = [
|
||||
'deepInsightsDashboard',
|
||||
'layerStyleCollection'
|
||||
];
|
||||
|
||||
/**
|
||||
* Integration between embed view with cartodb.js and deep-insights.
|
||||
*/
|
||||
var EmbedIntegrations = function (opts) {
|
||||
checkAndBuildOpts(opts, REQUIRED_OPTS, this);
|
||||
|
||||
this._getWidgets().each(function (model) {
|
||||
this._bindWidgetChanges(model);
|
||||
}, this);
|
||||
|
||||
LegendManager.trackLegends(this._getLayers());
|
||||
};
|
||||
|
||||
EmbedIntegrations.prototype._bindWidgetChanges = function (model) {
|
||||
// wait until the widget has the state from the url
|
||||
model.bind('change:hasInitialState', function (model) {
|
||||
model.bind('change:autoStyle', this._onWidgetAutoStyleChanged, this);
|
||||
|
||||
// In order to trigger legend update, we wait until the cartocss from autostyle is applied
|
||||
if (model.isAutoStyle()) {
|
||||
if (!model.layerModel.get('initialStyle')) {
|
||||
model.layerModel.once('change:initialStyle', function () {
|
||||
this._onWidgetAutoStyleChanged(model);
|
||||
}, this);
|
||||
} else {
|
||||
this._onWidgetAutoStyleChanged(model);
|
||||
}
|
||||
}
|
||||
}, this);
|
||||
};
|
||||
|
||||
EmbedIntegrations.prototype._onWidgetAutoStyleChanged = function (model) {
|
||||
var isAnyAutoStyleApplied = this._getWidgets().any(function (model) {
|
||||
return model.isAutoStyle();
|
||||
}, this);
|
||||
|
||||
var isAutoStyleApplied = model.isAutoStyle();
|
||||
var layerId = model.layerModel.id;
|
||||
var layerStyle;
|
||||
|
||||
if (isAutoStyleApplied) {
|
||||
LegendManager.updateLegends(layerId);
|
||||
|
||||
layerStyle = this._layerStyleCollection.findById(layerId);
|
||||
// setting this in order to restore initial style when disable autostyle
|
||||
if (model.layerModel) {
|
||||
model.layerModel.attributes.initialStyle = layerStyle.get('cartocss');
|
||||
}
|
||||
} else if (!isAnyAutoStyleApplied) {
|
||||
LegendManager.resetLegends(layerId);
|
||||
}
|
||||
};
|
||||
|
||||
EmbedIntegrations.prototype._getLayer = function (model) {
|
||||
return this.visMap().getLayerById(model.id);
|
||||
};
|
||||
|
||||
EmbedIntegrations.prototype._getLayers = function (model) {
|
||||
return this.visMap().layers;
|
||||
};
|
||||
|
||||
EmbedIntegrations.prototype.visMap = function () {
|
||||
return this._vis().map;
|
||||
};
|
||||
|
||||
EmbedIntegrations.prototype._getWidgets = function () {
|
||||
return this._deepInsightsDashboard._dashboard.widgets._widgetsCollection;
|
||||
};
|
||||
|
||||
EmbedIntegrations.prototype._vis = function () {
|
||||
return this._deepInsightsDashboard.getMap();
|
||||
};
|
||||
|
||||
module.exports = EmbedIntegrations;
|
||||
85
lib/assets/javascripts/builder/embed/embed-overlay-view.js
Normal file
85
lib/assets/javascripts/builder/embed/embed-overlay-view.js
Normal file
@@ -0,0 +1,85 @@
|
||||
var _ = require('underscore');
|
||||
var Backbone = require('backbone');
|
||||
var CoreView = require('backbone/core-view');
|
||||
var embedOverlayTemplate = require('./embed-overlay.tpl');
|
||||
var checkAndBuildOpts = require('builder/helpers/required-opts');
|
||||
|
||||
var REQUIRED_OPTS = [
|
||||
'title',
|
||||
'description',
|
||||
'showMenu'
|
||||
];
|
||||
|
||||
var EmbedOverlayView = CoreView.extend({
|
||||
className: 'CDB-Embed-overlay',
|
||||
|
||||
events: {
|
||||
'click .js-toggle': '_toggle'
|
||||
},
|
||||
|
||||
initialize: function (options) {
|
||||
checkAndBuildOpts(options, REQUIRED_OPTS, this);
|
||||
|
||||
if (!_.isUndefined(options.template)) {
|
||||
this._template = options.template;
|
||||
}
|
||||
|
||||
this._initModels();
|
||||
this._initBinds();
|
||||
},
|
||||
|
||||
render: function () {
|
||||
this.$el.empty();
|
||||
|
||||
var opts = {
|
||||
title: this._title,
|
||||
description: this._description,
|
||||
legends: true,
|
||||
showMenu: this._showMenu
|
||||
};
|
||||
|
||||
var content = this._renderOverlay(opts);
|
||||
|
||||
if (this._template) {
|
||||
content = this._renderTemplate(opts);
|
||||
}
|
||||
|
||||
this.$el.html(content);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
_renderOverlay: function (opts) {
|
||||
return embedOverlayTemplate(opts);
|
||||
},
|
||||
|
||||
_renderTemplate: function (opts) {
|
||||
return this._template({
|
||||
content: this._renderOverlay(_.extend(opts, { legends: false }))
|
||||
});
|
||||
},
|
||||
|
||||
_initModels: function () {
|
||||
this.model = new Backbone.Model({
|
||||
collapsed: false
|
||||
});
|
||||
},
|
||||
|
||||
_initBinds: function () {
|
||||
this.listenTo(this.model, 'change:collapsed', this._toggleCollapsed, this);
|
||||
},
|
||||
|
||||
_toggle: function () {
|
||||
this.model.set('collapsed', !this.model.get('collapsed'));
|
||||
},
|
||||
|
||||
_toggleCollapsed: function () {
|
||||
var collapsed = this.model.get('collapsed');
|
||||
|
||||
this.$('.CDB-Overlay-title').toggleClass('is-collapsed', collapsed);
|
||||
this.$('.CDB-ArrowToogle').toggleClass('is-down', !collapsed);
|
||||
this.$('.CDB-Overlay-inner').toggleClass('is-active', !collapsed);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = EmbedOverlayView;
|
||||
17
lib/assets/javascripts/builder/embed/embed-overlay.tpl
Normal file
17
lib/assets/javascripts/builder/embed/embed-overlay.tpl
Normal file
@@ -0,0 +1,17 @@
|
||||
<div class="CDB-Overlay-title<% if (!description && !legends) { %> is-collapsed<% } %>">
|
||||
<h1 class="CDB-Text CDB-Size-large u-ellipsis" title="<%= title %>"><%= title %></h1>
|
||||
|
||||
<% if (description || legends) { %>
|
||||
<div class="CDB-Overlay-options">
|
||||
<button class="CDB-Shape js-toggle u-lSpace">
|
||||
<div class="CDB-ArrowToogle is-blue is-down is-mini"></div>
|
||||
</button>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (description || legends) { %>
|
||||
<div class="CDB-Overlay-inner is-active<% if (!legends) { %> is-description<% } %>">
|
||||
<% if (description) { %><div class="CDB-Embed-description<% if (legends) { %> is-legends<% } %> CDB-Text CDB-Size-medium u-altTextColor" title="<%= description %>"><%= description %></div><% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
2
lib/assets/javascripts/builder/embed/embed-title.tpl
Normal file
2
lib/assets/javascripts/builder/embed/embed-title.tpl
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1 class="CDB-Text CDB-Size-large u-ellipsis" title="<%= title %>"><%= title %></h1>
|
||||
<% if (description) { %><div class="CDB-Embed-description CDB-Text CDB-Size-medium u-altTextColor" title="<%= description %>"><%= description %></div><% } %>
|
||||
161
lib/assets/javascripts/builder/embed/embed-view.js
Normal file
161
lib/assets/javascripts/builder/embed/embed-view.js
Normal file
@@ -0,0 +1,161 @@
|
||||
var Backbone = require('backbone');
|
||||
var _ = require('underscore');
|
||||
var Ps = require('perfect-scrollbar');
|
||||
var CoreView = require('backbone/core-view');
|
||||
var checkAndBuildOpts = require('builder/helpers/required-opts');
|
||||
var TabsView = require('./tabs/tabs-view');
|
||||
var template = require('./embed.tpl');
|
||||
var EmbedOverlayView = require('./embed-overlay-view');
|
||||
var EmbedBannerView = require('./embed-banner-view');
|
||||
|
||||
var TAB_NAMES = {
|
||||
map: 'map',
|
||||
legends: 'legends'
|
||||
};
|
||||
|
||||
var TAB_TITLES = {
|
||||
map: 'map',
|
||||
legends: 'legends',
|
||||
layers: 'layers'
|
||||
};
|
||||
|
||||
var MOBILE_VIEWPORT_BREAKPOINT = 599;
|
||||
var FULLSCREEN_VIEWPORT_BREAKPOINT = 1199;
|
||||
|
||||
var REQUIRED_OPTS = [
|
||||
'title',
|
||||
'description',
|
||||
'showMenu',
|
||||
'showLegends',
|
||||
'showLayerSelector',
|
||||
'showBanner'
|
||||
];
|
||||
|
||||
var EmbedView = CoreView.extend({
|
||||
module: 'embed:embed-view',
|
||||
|
||||
className: 'CDB-Embed-view',
|
||||
|
||||
initialize: function (options) {
|
||||
checkAndBuildOpts(options, REQUIRED_OPTS, this);
|
||||
|
||||
this._onlyLayerSelector = !this._showLegends && this._showLayerSelector;
|
||||
this._showLegends = this._showLegends || this._showLayerSelector;
|
||||
|
||||
if (this._showLegends) {
|
||||
var selectedTab = _.find(this._tabs, function (tab) { return tab.isSelected; });
|
||||
|
||||
this._tabsModel = new Backbone.Model({
|
||||
selected: selectedTab ? selectedTab.name : TAB_NAMES.map
|
||||
});
|
||||
|
||||
this._initBinds();
|
||||
}
|
||||
},
|
||||
|
||||
render: function () {
|
||||
this.$el.html(template({
|
||||
title: this._title,
|
||||
description: this._description,
|
||||
showMenu: this._showMenu,
|
||||
showLegends: this._showLegends,
|
||||
showBanner: this._showBanner
|
||||
}));
|
||||
|
||||
var mapTabConfig = {
|
||||
name: TAB_NAMES.map,
|
||||
title: TAB_TITLES.map,
|
||||
isSelected: true
|
||||
};
|
||||
var legendTabConfig = {
|
||||
name: TAB_NAMES.legends,
|
||||
title: this._onlyLayerSelector
|
||||
? TAB_TITLES.layers
|
||||
: TAB_NAMES.legends
|
||||
};
|
||||
|
||||
if (this._showLegends) {
|
||||
var tabsView = new TabsView({
|
||||
model: this._tabsModel,
|
||||
tabs: [ mapTabConfig, legendTabConfig ]
|
||||
});
|
||||
this.addView(tabsView);
|
||||
|
||||
this.$('.js-tabs').replaceWith(tabsView.render().$el);
|
||||
}
|
||||
|
||||
if (this._showBanner) {
|
||||
var embedView = new EmbedBannerView();
|
||||
this.$('.js-embed-banner').append(embedView.render().el);
|
||||
this.addView(embedView);
|
||||
}
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
_initBinds: function () {
|
||||
this.listenTo(this._tabsModel, 'change:selected', this._onSelectedTabChanged);
|
||||
|
||||
window.addEventListener('resize', this.onWindowResized.bind(this), { passive: true });
|
||||
},
|
||||
|
||||
_onSelectedTabChanged: function () {
|
||||
var contentId = '.js-embed-' + this._tabsModel.get('selected');
|
||||
var content = this.$(contentId);
|
||||
|
||||
content.siblings().removeClass('is-active');
|
||||
content.addClass('is-active');
|
||||
|
||||
// Update PerfectScrollbar
|
||||
var perfectScrollbarContainer = content.find('.ps-container').get(0);
|
||||
if (perfectScrollbarContainer) {
|
||||
Ps.update(perfectScrollbarContainer);
|
||||
}
|
||||
},
|
||||
|
||||
injectTitle: function (mapEl) {
|
||||
var legendsEl = mapEl.find('.CDB-Legends-canvasInner');
|
||||
this.$scrollShadowTop = mapEl.find('.CDB-Legends-canvasShadow--top');
|
||||
this.$scrollShadowBottom = mapEl.find('.CDB-Legends-canvasShadow--bottom');
|
||||
|
||||
this.titleView = new EmbedOverlayView({
|
||||
title: this._title,
|
||||
description: this._description,
|
||||
showMenu: this._showMenu
|
||||
});
|
||||
this.addView(this.titleView);
|
||||
this.listenTo(this.titleView.model, 'change:collapsed', function (model) {
|
||||
// Hack because PS is not triggering events
|
||||
if (model.get('collapsed')) {
|
||||
this.$scrollShadowTop.removeClass('is-visible');
|
||||
this.$scrollShadowBottom.removeClass('is-visible');
|
||||
// This is the correct way of doing it, the lines above are a hack, because PS is not triggering events
|
||||
legendsEl.scrollTop(0);
|
||||
Ps.update(legendsEl.get(0));
|
||||
} else {
|
||||
// Hack as well, see comment above
|
||||
if (legendsEl.get(0).scrollHeight > legendsEl.height()) {
|
||||
this.$scrollShadowBottom.addClass('is-visible');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
legendsEl.prepend(this.titleView.render().el);
|
||||
|
||||
legendsEl.find('.CDB-LayerLegends')
|
||||
.detach()
|
||||
.appendTo(legendsEl.find('.CDB-Overlay-inner'));
|
||||
},
|
||||
|
||||
onWindowResized: function () {
|
||||
if (window.innerWidth > MOBILE_VIEWPORT_BREAKPOINT && this._tabsModel.get('selected') !== TAB_NAMES.map) {
|
||||
this._tabsModel.set({ selected: TAB_NAMES.map });
|
||||
}
|
||||
|
||||
if (this.titleView && window.innerWidth > FULLSCREEN_VIEWPORT_BREAKPOINT) {
|
||||
this.titleView.model.set('collapsed', false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = EmbedView;
|
||||
21
lib/assets/javascripts/builder/embed/embed.tpl
Normal file
21
lib/assets/javascripts/builder/embed/embed.tpl
Normal file
@@ -0,0 +1,21 @@
|
||||
<% if (showMenu && showLegends) { %>
|
||||
<header class="CDB-Embed-header <% if (!showLegends) { %> CDB-Embed-header--shadow <% } %>">
|
||||
<h1 class="CDB-Text CDB-Size-large u-ellipsis" title="<%= title %>"><%= title %></h1>
|
||||
<% if (description) { %><div class="CDB-Embed-description CDB-Text CDB-Size-medium u-altTextColor" title="<%= description %>"><%= description %></div><% } %>
|
||||
</header>
|
||||
<% } %>
|
||||
|
||||
<% if (showLegends) { %>
|
||||
<div class="CDB-Embed-tabs CDB-NavMenu js-tabs"></div>
|
||||
<% } %>
|
||||
|
||||
<div class="CDB-Embed-content">
|
||||
<div class="CDB-Embed-tab <% if (showMenu) { %> CDB-Embed-tab--menu <% } %> is-active js-embed-map"></div>
|
||||
|
||||
<% if (showLegends) { %>
|
||||
<div class="CDB-Embed-tab CDB-Embed-legends js-embed-legends"></div>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (showBanner) { %>
|
||||
<div class="CDB-Embed-banner js-embed-banner"></div>
|
||||
<% } %>
|
||||
49
lib/assets/javascripts/builder/embed/legend-manager.js
Normal file
49
lib/assets/javascripts/builder/embed/legend-manager.js
Normal file
@@ -0,0 +1,49 @@
|
||||
var _ = require('underscore');
|
||||
var legendsMetadata = require('builder/data/legends/legends-metadata');
|
||||
|
||||
var legends;
|
||||
|
||||
var LEGENDS_METADATA = _.keys(legendsMetadata);
|
||||
var LEGENDS_COLOR = ['category', 'choropleth'];
|
||||
|
||||
var LegendManager = {
|
||||
trackLegends: function (layers) {
|
||||
legends = {};
|
||||
|
||||
_.each(layers.models, function (layerModel) {
|
||||
var legendsLayer;
|
||||
var options = {};
|
||||
if (layerModel.legends != null) {
|
||||
legendsLayer = layerModel.legends;
|
||||
_.each(LEGENDS_METADATA, function (legend) {
|
||||
var legendModel = legendsLayer[legend];
|
||||
if (legendModel.get('visible') === true) {
|
||||
options[legend] = legendModel;
|
||||
}
|
||||
});
|
||||
legends[layerModel.id] = options;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getLegends: function () {
|
||||
return legends;
|
||||
},
|
||||
|
||||
updateLegends: function (layerId) {
|
||||
var legendsLayer = legends[layerId];
|
||||
_.each(LEGENDS_COLOR, function (legend) {
|
||||
var legendModel = legendsLayer[legend];
|
||||
legendModel && legendModel.set({visible: false});
|
||||
});
|
||||
},
|
||||
|
||||
resetLegends: function (layerId) {
|
||||
var legendsLayer = legends[layerId];
|
||||
_.each(legendsLayer, function (legend) {
|
||||
legend.set({visible: true});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = LegendManager;
|
||||
21
lib/assets/javascripts/builder/embed/style-collection.js
Normal file
21
lib/assets/javascripts/builder/embed/style-collection.js
Normal file
@@ -0,0 +1,21 @@
|
||||
var Backbone = require('backbone');
|
||||
module.exports = Backbone.Collection.extend({
|
||||
model: function (d, opts) {
|
||||
var attrs = {};
|
||||
attrs.id = d.id;
|
||||
if (d.options.tile_style) {
|
||||
attrs.cartocss = d.options.tile_style;
|
||||
}
|
||||
return new Backbone.Model(attrs);
|
||||
},
|
||||
|
||||
resetByLayersData: function (data) {
|
||||
this.reset(data, {
|
||||
silent: true
|
||||
});
|
||||
},
|
||||
|
||||
findById: function (id) {
|
||||
return this.findWhere({id: id});
|
||||
}
|
||||
});
|
||||
3
lib/assets/javascripts/builder/embed/tabs/tab-item.tpl
Normal file
3
lib/assets/javascripts/builder/embed/tabs/tab-item.tpl
Normal file
@@ -0,0 +1,3 @@
|
||||
<li class="CDB-NavMenu-item js-tab<% if (isSelected) { %> is-selected<% } %>" data-tab="<%= name %>">
|
||||
<button class="CDB-NavMenu-link u-upperCase"><%= title %></button>
|
||||
</li>
|
||||
61
lib/assets/javascripts/builder/embed/tabs/tabs-view.js
Normal file
61
lib/assets/javascripts/builder/embed/tabs/tabs-view.js
Normal file
@@ -0,0 +1,61 @@
|
||||
var CoreView = require('backbone/core-view');
|
||||
var template = require('./tabs.tpl');
|
||||
var tabItemTemplate = require('./tab-item.tpl');
|
||||
var checkAndBuildOpts = require('builder/helpers/required-opts');
|
||||
|
||||
var REQUIRED_OPTS = [
|
||||
'tabs'
|
||||
];
|
||||
|
||||
var TabsView = CoreView.extend({
|
||||
module: 'embed:tabs:tabs-view',
|
||||
|
||||
tagName: 'nav',
|
||||
|
||||
className: 'CDB-Embed-tabs CDB-NavMenu js-tabs',
|
||||
|
||||
events: {
|
||||
'click .js-tab': '_onTabClicked'
|
||||
},
|
||||
|
||||
initialize: function (options) {
|
||||
checkAndBuildOpts(options, REQUIRED_OPTS, this);
|
||||
|
||||
this.listenTo(this.model, 'change:selected', this._onSelectedTabChanged);
|
||||
},
|
||||
|
||||
render: function () {
|
||||
this.$el.empty();
|
||||
|
||||
this.$el.html(template());
|
||||
|
||||
this._tabs.map(function (tab) {
|
||||
this.$('.js-tabs-container').append(tabItemTemplate({
|
||||
name: tab.name,
|
||||
title: tab.title || tab.name,
|
||||
isSelected: tab.isSelected
|
||||
}));
|
||||
}.bind(this));
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
_onSelectedTabChanged: function () {
|
||||
var tabName = this.model.get('selected');
|
||||
var tab = this.$('[data-tab="' + tabName + '"]');
|
||||
|
||||
tab.siblings().removeClass('is-selected');
|
||||
tab.addClass('is-selected');
|
||||
},
|
||||
|
||||
_onTabClicked: function (event) {
|
||||
var tab = this.$(event.currentTarget);
|
||||
var tabName = tab.data('tab');
|
||||
|
||||
this.model.set({
|
||||
selected: tabName
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = TabsView;
|
||||
1
lib/assets/javascripts/builder/embed/tabs/tabs.tpl
Normal file
1
lib/assets/javascripts/builder/embed/tabs/tabs.tpl
Normal file
@@ -0,0 +1 @@
|
||||
<ul class="CDB-NavMenu-inner CDB-NavMenu-inner--legends CDB-Text is-semibold CDB-Size-medium js-tabs-container"></ul>
|
||||
Reference in New Issue
Block a user