Merge branch 'master' into beta
1
.gitignore
vendored
@ -78,6 +78,7 @@ discourse.sublime-workspace
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
*.swm
|
||||
|
||||
# don't check in multisite config
|
||||
config/multisite.yml
|
||||
|
||||
2
Gemfile
@ -185,3 +185,5 @@ if ENV["IMPORT"] == "1"
|
||||
gem 'ruby-bbcode-to-md', github: 'nlalonde/ruby-bbcode-to-md'
|
||||
gem 'reverse_markdown'
|
||||
end
|
||||
|
||||
gem 'webpush', require: false
|
||||
|
||||
@ -131,6 +131,7 @@ GEM
|
||||
hashie (3.5.5)
|
||||
highline (1.7.8)
|
||||
hiredis (0.6.1)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http_accept_language (2.0.5)
|
||||
i18n (0.8.6)
|
||||
@ -164,7 +165,7 @@ GEM
|
||||
mail (2.7.1.rc1)
|
||||
mini_mime (>= 0.1.1)
|
||||
memory_profiler (0.9.10)
|
||||
message_bus (2.1.4)
|
||||
message_bus (2.1.5)
|
||||
rack (>= 1.1.3)
|
||||
metaclass (0.0.4)
|
||||
method_source (0.8.2)
|
||||
@ -251,7 +252,7 @@ GEM
|
||||
public_suffix (2.0.5)
|
||||
puma (3.9.1)
|
||||
r2 (0.2.6)
|
||||
rack (2.0.4)
|
||||
rack (2.0.5)
|
||||
rack-mini-profiler (1.0.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-openid (1.3.1)
|
||||
@ -388,6 +389,9 @@ GEM
|
||||
addressable (>= 2.3.6)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff
|
||||
webpush (0.3.2)
|
||||
hkdf (~> 0.2)
|
||||
jwt
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
@ -498,6 +502,7 @@ DEPENDENCIES
|
||||
unf
|
||||
unicorn
|
||||
webmock
|
||||
webpush
|
||||
|
||||
BUNDLED WITH
|
||||
1.16.1
|
||||
|
||||
BIN
app/assets/images/push-notifications/check.png
Normal file
|
After Width: | Height: | Size: 844 B |
BIN
app/assets/images/push-notifications/discourse.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
app/assets/images/push-notifications/group_mentioned.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/assets/images/push-notifications/linked.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/assets/images/push-notifications/mentioned.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/assets/images/push-notifications/posted.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/assets/images/push-notifications/private_message.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/assets/images/push-notifications/quoted.png
Normal file
|
After Width: | Height: | Size: 1000 B |
BIN
app/assets/images/push-notifications/replied.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
@ -1,4 +1,5 @@
|
||||
export default Ember.Component.extend({
|
||||
allTime: true,
|
||||
tagName: 'tr',
|
||||
reverseColors: Ember.computed.match('report.type', /^(time_to_first_response|topics_with_no_response)$/),
|
||||
classNameBindings: ['reverseColors']
|
||||
|
||||
@ -1,23 +1,38 @@
|
||||
import { ajax } from 'discourse/lib/ajax';
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import Report from "admin/models/report";
|
||||
import AsyncReport from "admin/mixins/async-report";
|
||||
|
||||
export default Ember.Component.extend(AsyncReport, {
|
||||
classNames: ["dashboard-table", "dashboard-inline-table", "fixed"],
|
||||
isLoading: true,
|
||||
help: null,
|
||||
helpPage: null,
|
||||
|
||||
fetchReport() {
|
||||
this.set("isLoading", true);
|
||||
loadReport(report_json) {
|
||||
return Report.create(report_json);
|
||||
},
|
||||
|
||||
ajax(this.get("dataSource"))
|
||||
.then((response) => {
|
||||
this._setPropertiesFromReport(Report.create(response.report));
|
||||
}).finally(() => {
|
||||
if (!Ember.isEmpty(this.get("report.data"))) {
|
||||
this.set("isLoading", false);
|
||||
};
|
||||
});
|
||||
fetchReport() {
|
||||
this._super();
|
||||
|
||||
let payload = { data: { cache: true, facets: ["total", "prev30Days"] } };
|
||||
|
||||
if (this.get("startDate")) {
|
||||
payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ");
|
||||
}
|
||||
|
||||
if (this.get("endDate")) {
|
||||
payload.data.end_date = this.get("endDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ");
|
||||
}
|
||||
|
||||
if (this.get("limit")) {
|
||||
payload.data.limit = this.get("limit");
|
||||
}
|
||||
|
||||
return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => {
|
||||
return ajax(dataSource, payload)
|
||||
.then(response => {
|
||||
this.get("reports").pushObject(this.loadReport(response.report));
|
||||
});
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,123 +1,151 @@
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
import AsyncReport from "admin/mixins/async-report";
|
||||
import Report from "admin/models/report";
|
||||
import { number } from 'discourse/lib/formatter';
|
||||
import loadScript from "discourse/lib/load-script";
|
||||
import { registerTooltip, unregisterTooltip } from "discourse/lib/tooltip";
|
||||
|
||||
function collapseWeekly(data, average) {
|
||||
let aggregate = [];
|
||||
let bucket, i;
|
||||
let offset = data.length % 7;
|
||||
for(i = offset; i < data.length; i++) {
|
||||
|
||||
if (bucket && (i % 7 === offset)) {
|
||||
if (average) {
|
||||
bucket.y = parseFloat((bucket.y / 7.0).toFixed(2));
|
||||
}
|
||||
aggregate.push(bucket);
|
||||
bucket = null;
|
||||
}
|
||||
|
||||
bucket = bucket || { x: data[i].x, y: 0 };
|
||||
bucket.y += data[i].y;
|
||||
}
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
export default Ember.Component.extend(AsyncReport, {
|
||||
classNames: ["dashboard-mini-chart"],
|
||||
classNameBindings: ["thirtyDayTrend", "oneDataPoint"],
|
||||
isLoading: true,
|
||||
thirtyDayTrend: Ember.computed.alias("report.thirtyDayTrend"),
|
||||
oneDataPoint: false,
|
||||
backgroundColor: "rgba(200,220,240,0.3)",
|
||||
borderColor: "#08C",
|
||||
average: false,
|
||||
classNames: ["chart", "dashboard-mini-chart"],
|
||||
total: 0,
|
||||
|
||||
willDestroyEelement() {
|
||||
init() {
|
||||
this._super();
|
||||
|
||||
this.messageBus.unsubscribe(this.get("dataSource"));
|
||||
this._colorsPool = ["rgb(0,136,204)", "rgb(235,83,148)"];
|
||||
},
|
||||
|
||||
@computed("dataSourceName")
|
||||
dataSource(dataSourceName) {
|
||||
if (dataSourceName) {
|
||||
return `/admin/reports/${dataSourceName}`;
|
||||
}
|
||||
didRender() {
|
||||
this._super();
|
||||
registerTooltip($(this.element).find("[data-tooltip]"));
|
||||
},
|
||||
|
||||
@computed("thirtyDayTrend")
|
||||
trendIcon(thirtyDayTrend) {
|
||||
switch (thirtyDayTrend) {
|
||||
case "trending-up":
|
||||
return "angle-up";
|
||||
case "trending-down":
|
||||
return "angle-down";
|
||||
case "high-trending-up":
|
||||
return "angle-double-up";
|
||||
case "high-trending-down":
|
||||
return "angle-double-down";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
willDestroyElement() {
|
||||
this._super();
|
||||
unregisterTooltip($(this.element).find("[data-tooltip]"));
|
||||
},
|
||||
|
||||
pickColorAtIndex(index) {
|
||||
return this._colorsPool[index] || this._colorsPool[0];
|
||||
},
|
||||
|
||||
fetchReport() {
|
||||
this.set("isLoading", true);
|
||||
this._super();
|
||||
|
||||
let payload = {
|
||||
data: { async: true }
|
||||
data: { cache: true, facets: ["prev_period"] }
|
||||
};
|
||||
|
||||
if (this.get("startDate")) {
|
||||
payload.data.start_date = this.get("startDate").format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ');
|
||||
payload.data.start_date = this.get("startDate").locale('en').format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ');
|
||||
}
|
||||
|
||||
if (this.get("endDate")) {
|
||||
payload.data.end_date = this.get("endDate").format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ');
|
||||
payload.data.end_date = this.get("endDate").locale('en').format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ');
|
||||
}
|
||||
|
||||
ajax(this.get("dataSource"), payload)
|
||||
.then((response) => {
|
||||
// if (!Ember.isEmpty(response.report.data)) {
|
||||
this._setPropertiesFromReport(Report.create(response.report));
|
||||
// }
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.get("oneDataPoint")) {
|
||||
this.set("isLoading", false);
|
||||
return;
|
||||
}
|
||||
if (this._chart) {
|
||||
this._chart.destroy();
|
||||
this._chart = null;
|
||||
}
|
||||
|
||||
if (!Ember.isEmpty(this.get("report.data"))) {
|
||||
this.set("isLoading", false);
|
||||
this.renderReport();
|
||||
}
|
||||
});
|
||||
return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => {
|
||||
return ajax(dataSource, payload)
|
||||
.then(response => {
|
||||
this.get("reports").pushObject(this.loadReport(response.report));
|
||||
});
|
||||
}));
|
||||
},
|
||||
|
||||
loadReport(report, previousReport) {
|
||||
Report.fillMissingDates(report);
|
||||
|
||||
if (report.data && report.data.length > 40) {
|
||||
report.data = collapseWeekly(report.data, report.average);
|
||||
}
|
||||
|
||||
if (previousReport && previousReport.color.length) {
|
||||
report.color = previousReport.color;
|
||||
} else {
|
||||
const dataSourceNameIndex = this.get("dataSourceNames").split(",").indexOf(report.type);
|
||||
report.color = this.pickColorAtIndex(dataSourceNameIndex);
|
||||
}
|
||||
|
||||
return Report.create(report);
|
||||
},
|
||||
|
||||
renderReport() {
|
||||
if (!this.element || this.isDestroying || this.isDestroyed) { return; }
|
||||
if (this.get("oneDataPoint")) return;
|
||||
this._super();
|
||||
|
||||
Ember.run.schedule("afterRender", () => {
|
||||
const $chartCanvas = this.$(".chart-canvas");
|
||||
|
||||
if (!$chartCanvas.length) return;
|
||||
const context = $chartCanvas[0].getContext("2d");
|
||||
|
||||
const reportsForPeriod = this.get("reportsForPeriod");
|
||||
|
||||
const labels = Ember.makeArray(reportsForPeriod.get("firstObject.data")).map(d => d.x);
|
||||
|
||||
const data = {
|
||||
labels: this.get("labels"),
|
||||
datasets: [{
|
||||
data: Ember.makeArray(this.get("values")),
|
||||
backgroundColor: this.get("backgroundColor"),
|
||||
borderColor: this.get("borderColor")
|
||||
}]
|
||||
labels,
|
||||
datasets: reportsForPeriod.map(report => {
|
||||
return {
|
||||
data: Ember.makeArray(report.data).map(d => d.y),
|
||||
backgroundColor: "rgba(200,220,240,0.3)",
|
||||
borderColor: report.color
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
this._chart = new window.Chart(context, this._buildChartConfig(data));
|
||||
if (this._chart) {
|
||||
this._chart.destroy();
|
||||
this._chart = null;
|
||||
}
|
||||
|
||||
loadScript("/javascripts/Chart.min.js").then(() => {
|
||||
if (this._chart) {
|
||||
this._chart.destroy();
|
||||
}
|
||||
this._chart = new window.Chart(context, this._buildChartConfig(data));
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
_setPropertiesFromReport(report) {
|
||||
const oneDataPoint = (this.get("startDate") && this.get("endDate")) &&
|
||||
this.get("startDate").isSame(this.get("endDate"), "day");
|
||||
|
||||
report.set("average", this.get("average"));
|
||||
|
||||
this.setProperties({ oneDataPoint, report });
|
||||
},
|
||||
|
||||
_buildChartConfig(data) {
|
||||
return {
|
||||
type: "line",
|
||||
data,
|
||||
options: {
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
title: (context) => moment(context[0].xLabel, "YYYY-MM-DD").format("LL")
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
@ -133,6 +161,7 @@ export default Ember.Component.extend(AsyncReport, {
|
||||
}],
|
||||
xAxes: [{
|
||||
display: true,
|
||||
gridLines: { display: false },
|
||||
type: "time",
|
||||
time: {
|
||||
parser: "YYYY-MM-DD"
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
import DashboardTable from "admin/components/dashboard-table";
|
||||
import AsyncReport from "admin/mixins/async-report";
|
||||
|
||||
export default DashboardTable.extend(AsyncReport, {
|
||||
layoutName: "admin/templates/components/dashboard-table",
|
||||
|
||||
classNames: ["dashboard-table", "dashboard-table-trending-search"]
|
||||
});
|
||||
@ -1,50 +0,0 @@
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import Report from "admin/models/report";
|
||||
import AsyncReport from "admin/mixins/async-report";
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
import { number } from 'discourse/lib/formatter';
|
||||
|
||||
export default Ember.Component.extend(AsyncReport, {
|
||||
classNames: ["dashboard-table"],
|
||||
help: null,
|
||||
helpPage: null,
|
||||
|
||||
@computed("report")
|
||||
values(report) {
|
||||
if (!report) return;
|
||||
return Ember.makeArray(report.data)
|
||||
.sort((a, b) => a.x >= b.x)
|
||||
.map(x => {
|
||||
return [ x[0], number(x[1]), number(x[2]) ];
|
||||
});
|
||||
},
|
||||
|
||||
@computed("report")
|
||||
labels(report) {
|
||||
if (!report) return;
|
||||
return Ember.makeArray(report.labels);
|
||||
},
|
||||
|
||||
fetchReport() {
|
||||
this.set("isLoading", true);
|
||||
|
||||
let payload = { data: { async: true } };
|
||||
|
||||
if (this.get("startDate")) {
|
||||
payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ");
|
||||
}
|
||||
|
||||
if (this.get("endDate")) {
|
||||
payload.data.end_date = this.get("endDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ");
|
||||
}
|
||||
|
||||
ajax(this.get("dataSource"), payload)
|
||||
.then((response) => {
|
||||
this._setPropertiesFromReport(Report.create(response.report));
|
||||
}).finally(() => {
|
||||
if (!Ember.isEmpty(this.get("report.data"))) {
|
||||
this.set("isLoading", false);
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -1,15 +1,39 @@
|
||||
import { setting } from "discourse/lib/computed";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
import AdminDashboardNext from "admin/models/admin-dashboard-next";
|
||||
import Report from "admin/models/report";
|
||||
import VersionCheck from "admin/models/version-check";
|
||||
|
||||
const PROBLEMS_CHECK_MINUTES = 1;
|
||||
|
||||
export default Ember.Controller.extend({
|
||||
queryParams: ["period"],
|
||||
period: "all",
|
||||
period: "monthly",
|
||||
isLoading: false,
|
||||
dashboardFetchedAt: null,
|
||||
exceptionController: Ember.inject.controller("exception"),
|
||||
showVersionChecks: setting("version_checks"),
|
||||
diskSpace: Ember.computed.alias("model.attributes.disk_space"),
|
||||
logSearchQueriesEnabled: setting("log_search_queries"),
|
||||
availablePeriods: ["yearly", "quarterly", "monthly", "weekly"],
|
||||
|
||||
@computed("problems.length")
|
||||
foundProblems(problemsLength) {
|
||||
return this.currentUser.get("admin") && (problemsLength || 0) > 0;
|
||||
},
|
||||
|
||||
@computed("foundProblems")
|
||||
thereWereProblems(foundProblems) {
|
||||
if (!this.currentUser.get("admin")) { return false; }
|
||||
|
||||
if (foundProblems) {
|
||||
this.set("hadProblems", true);
|
||||
return true;
|
||||
} else {
|
||||
return this.get("hadProblems") || false;
|
||||
}
|
||||
},
|
||||
|
||||
fetchDashboard() {
|
||||
if (this.get("isLoading")) return;
|
||||
@ -17,7 +41,14 @@ export default Ember.Controller.extend({
|
||||
if (!this.get("dashboardFetchedAt") || moment().subtract(30, "minutes").toDate() > this.get("dashboardFetchedAt")) {
|
||||
this.set("isLoading", true);
|
||||
|
||||
const versionChecks = this.siteSettings.version_checks;
|
||||
|
||||
AdminDashboardNext.find().then(adminDashboardNextModel => {
|
||||
|
||||
if (versionChecks) {
|
||||
this.set("versionCheck", VersionCheck.create(adminDashboardNextModel.version_check));
|
||||
}
|
||||
|
||||
this.setProperties({
|
||||
dashboardFetchedAt: new Date(),
|
||||
model: adminDashboardNextModel,
|
||||
@ -30,34 +61,57 @@ export default Ember.Controller.extend({
|
||||
this.set("isLoading", false);
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.get("problemsFetchedAt") || moment().subtract(PROBLEMS_CHECK_MINUTES, "minutes").toDate() > this.get("problemsFetchedAt")) {
|
||||
this.loadProblems();
|
||||
}
|
||||
},
|
||||
|
||||
loadProblems() {
|
||||
this.set("loadingProblems", true);
|
||||
this.set("problemsFetchedAt", new Date());
|
||||
AdminDashboardNext.fetchProblems().then(d => {
|
||||
this.set("problems", d.problems);
|
||||
}).finally(() => {
|
||||
this.set("loadingProblems", false);
|
||||
});
|
||||
},
|
||||
|
||||
@computed("problemsFetchedAt")
|
||||
problemsTimestamp(problemsFetchedAt) {
|
||||
return moment(problemsFetchedAt).locale("en").format("LLL");
|
||||
},
|
||||
|
||||
@computed("period")
|
||||
startDate(period) {
|
||||
let fullDay = moment().locale("en").utc().subtract(1, "day");
|
||||
|
||||
switch (period) {
|
||||
case "yearly":
|
||||
return moment().subtract(1, "year").startOf("day");
|
||||
return fullDay.subtract(1, "year").startOf("day");
|
||||
break;
|
||||
case "quarterly":
|
||||
return moment().subtract(3, "month").startOf("day");
|
||||
return fullDay.subtract(3, "month").startOf("day");
|
||||
break;
|
||||
case "weekly":
|
||||
return moment().subtract(1, "week").startOf("day");
|
||||
return fullDay.subtract(1, "week").startOf("day");
|
||||
break;
|
||||
case "monthly":
|
||||
return moment().subtract(1, "month").startOf("day");
|
||||
break;
|
||||
case "daily":
|
||||
return moment().startOf("day");
|
||||
return fullDay.subtract(1, "month").startOf("day");
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
return fullDay.subtract(1, "month").startOf("day");
|
||||
}
|
||||
},
|
||||
|
||||
@computed("period")
|
||||
endDate(period) {
|
||||
return period === "all" ? null : moment().endOf("day");
|
||||
@computed()
|
||||
lastWeek() {
|
||||
return moment().locale("en").utc().endOf("day").subtract(1, "week");
|
||||
},
|
||||
|
||||
@computed()
|
||||
endDate() {
|
||||
return moment().locale("en").utc().subtract(1, "day").endOf("day");
|
||||
},
|
||||
|
||||
@computed("updated_at")
|
||||
@ -73,10 +127,13 @@ export default Ember.Controller.extend({
|
||||
actions: {
|
||||
changePeriod(period) {
|
||||
DiscourseURL.routeTo(this._reportsForPeriodURL(period));
|
||||
}
|
||||
},
|
||||
refreshProblems() {
|
||||
this.loadProblems();
|
||||
},
|
||||
},
|
||||
|
||||
_reportsForPeriodURL(period) {
|
||||
return `/admin/dashboard-next?period=${period}`;
|
||||
return Discourse.getURL(`/admin?period=${period}`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
import { setting } from 'discourse/lib/computed';
|
||||
import AdminDashboard from 'admin/models/admin-dashboard';
|
||||
import VersionCheck from 'admin/models/version-check';
|
||||
import Report from 'admin/models/report';
|
||||
import AdminUser from 'admin/models/admin-user';
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
const PROBLEMS_CHECK_MINUTES = 1;
|
||||
|
||||
const ATTRIBUTES = [ 'disk_space','admins', 'moderators', 'silenced', 'suspended', 'top_traffic_sources',
|
||||
'top_referred_topics', 'updated_at'];
|
||||
@ -18,35 +15,13 @@ export default Ember.Controller.extend({
|
||||
loading: null,
|
||||
versionCheck: null,
|
||||
dashboardFetchedAt: null,
|
||||
showVersionChecks: setting('version_checks'),
|
||||
exceptionController: Ember.inject.controller('exception'),
|
||||
|
||||
@computed('problems.length')
|
||||
foundProblems(problemsLength) {
|
||||
return this.currentUser.get('admin') && (problemsLength || 0) > 0;
|
||||
},
|
||||
|
||||
@computed('foundProblems')
|
||||
thereWereProblems(foundProblems) {
|
||||
if (!this.currentUser.get('admin')) { return false; }
|
||||
|
||||
if (foundProblems) {
|
||||
this.set('hadProblems', true);
|
||||
return true;
|
||||
} else {
|
||||
return this.get('hadProblems') || false;
|
||||
}
|
||||
},
|
||||
|
||||
fetchDashboard() {
|
||||
if (!this.get('dashboardFetchedAt') || moment().subtract(30, 'minutes').toDate() > this.get('dashboardFetchedAt')) {
|
||||
this.set('loading', true);
|
||||
const versionChecks = this.siteSettings.version_checks;
|
||||
AdminDashboard.find().then(d => {
|
||||
this.set('dashboardFetchedAt', new Date());
|
||||
if (versionChecks) {
|
||||
this.set('versionCheck', VersionCheck.create(d.version_check));
|
||||
}
|
||||
|
||||
REPORTS.forEach(name => this.set(name, d[name].map(r => Report.create(r))));
|
||||
|
||||
@ -64,26 +39,8 @@ export default Ember.Controller.extend({
|
||||
this.set('loading', false);
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.get('problemsFetchedAt') || moment().subtract(PROBLEMS_CHECK_MINUTES, 'minutes').toDate() > this.get('problemsFetchedAt')) {
|
||||
this.loadProblems();
|
||||
}
|
||||
},
|
||||
|
||||
loadProblems() {
|
||||
this.set('loadingProblems', true);
|
||||
this.set('problemsFetchedAt', new Date());
|
||||
AdminDashboard.fetchProblems().then(d => {
|
||||
this.set('problems', d.problems);
|
||||
}).finally(() => {
|
||||
this.set('loadingProblems', false);
|
||||
});
|
||||
},
|
||||
|
||||
@computed('problemsFetchedAt')
|
||||
problemsTimestamp(problemsFetchedAt) {
|
||||
return moment(problemsFetchedAt).format('LLL');
|
||||
},
|
||||
|
||||
@computed('updated_at')
|
||||
updatedTimestamp(updatedAt) {
|
||||
@ -91,9 +48,6 @@ export default Ember.Controller.extend({
|
||||
},
|
||||
|
||||
actions: {
|
||||
refreshProblems() {
|
||||
this.loadProblems();
|
||||
},
|
||||
showTrafficReport() {
|
||||
this.set("showTrafficReport", true);
|
||||
}
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
|
||||
export default Ember.Controller.extend({
|
||||
categoryNameKey: null,
|
||||
adminSiteSettings: Ember.inject.controller(),
|
||||
|
||||
filteredContent: function() {
|
||||
if (!this.get('categoryNameKey')) { return []; }
|
||||
|
||||
const category = (this.get('adminSiteSettings.model') || []).findBy('nameKey', this.get('categoryNameKey'));
|
||||
if (category) {
|
||||
return category.siteSettings;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}.property('adminSiteSettings.model', 'categoryNameKey')
|
||||
@computed("adminSiteSettings.model", "categoryNameKey")
|
||||
category(categories, nameKey) {
|
||||
return (categories || []).findBy("nameKey", nameKey);
|
||||
},
|
||||
|
||||
@computed("category")
|
||||
filteredContent(category) {
|
||||
return category ? category.siteSettings : [];
|
||||
}
|
||||
});
|
||||
|
||||
@ -3,7 +3,6 @@ import debounce from 'discourse/lib/debounce';
|
||||
export default Ember.Controller.extend({
|
||||
filter: null,
|
||||
onlyOverridden: false,
|
||||
filtered: Ember.computed.notEmpty('filter'),
|
||||
|
||||
filterContentNow(category) {
|
||||
// If we have no content, don't bother filtering anything
|
||||
@ -14,9 +13,9 @@ export default Ember.Controller.extend({
|
||||
filter = this.get('filter').toLowerCase();
|
||||
}
|
||||
|
||||
if ((filter === undefined || filter.length < 1) && !this.get('onlyOverridden')) {
|
||||
if ((!filter || 0 === filter.length) && !this.get('onlyOverridden')) {
|
||||
this.set('model', this.get('allSiteSettings'));
|
||||
this.transitionToRoute("adminSiteSettings");
|
||||
this.transitionToRoute('adminSiteSettings');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -28,11 +27,11 @@ export default Ember.Controller.extend({
|
||||
const siteSettings = settingsCategory.siteSettings.filter(item => {
|
||||
if (this.get('onlyOverridden') && !item.get('overridden')) return false;
|
||||
if (filter) {
|
||||
if (item.get('setting').toLowerCase().indexOf(filter) > -1) return true;
|
||||
if (item.get('setting').toLowerCase().replace(/_/g, ' ').indexOf(filter) > -1) return true;
|
||||
if (item.get('description').toLowerCase().indexOf(filter) > -1) return true;
|
||||
if ((item.get('value') || '').toLowerCase().indexOf(filter) > -1) return true;
|
||||
return false;
|
||||
const setting = item.get('setting').toLowerCase();
|
||||
return setting.includes(filter) ||
|
||||
setting.replace(/_/g, ' ').includes(filter) ||
|
||||
item.get('description').toLowerCase().includes(filter) ||
|
||||
(item.get('value') || '').toLowerCase().includes(filter);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
@ -49,15 +48,16 @@ export default Ember.Controller.extend({
|
||||
});
|
||||
|
||||
all.siteSettings.pushObjects(matches.slice(0, 30));
|
||||
all.count = matches.length;
|
||||
all.hasMore = matches.length > 30;
|
||||
all.count = all.hasMore ? '30+' : matches.length;
|
||||
|
||||
this.set('model', matchesGroupedByCategory);
|
||||
this.transitionToRoute("adminSiteSettingsCategory", category || "all_results");
|
||||
this.transitionToRoute('adminSiteSettingsCategory', category || 'all_results');
|
||||
},
|
||||
|
||||
filterContent: debounce(function() {
|
||||
if (this.get("_skipBounce")) {
|
||||
this.set("_skipBounce", false);
|
||||
if (this.get('_skipBounce')) {
|
||||
this.set('_skipBounce', false);
|
||||
} else {
|
||||
this.filterContentNow();
|
||||
}
|
||||
|
||||
@ -5,6 +5,24 @@ import { popupAjaxError } from 'discourse/lib/ajax-error';
|
||||
|
||||
const THEME_FIELD_VARIABLE_TYPE_IDS = [2, 3, 4];
|
||||
|
||||
const SCSS_VARIABLE_NAMES = [
|
||||
// common/foundation/colors.scss
|
||||
"primary", "secondary", "tertiary", "quaternary", "header_background",
|
||||
"header_primary", "highlight", "danger", "success", "love",
|
||||
// common/foundation/math.scss
|
||||
"E", "PI", "LN2", "SQRT2",
|
||||
// common/foundation/variables.scss
|
||||
"small-width", "medium-width", "large-width",
|
||||
"google", "instagram", "facebook", "cas", "twitter", "yahoo", "github",
|
||||
"base-font-size", "base-line-height", "base-font-family",
|
||||
"primary-low", "primary-medium",
|
||||
"secondary-low", "secondary-medium",
|
||||
"tertiary-low", "quaternary-low",
|
||||
"highlight-low", "highlight-medium",
|
||||
"danger-low", "danger-medium",
|
||||
"success-low", "love-low",
|
||||
];
|
||||
|
||||
export default Ember.Controller.extend(ModalFunctionality, {
|
||||
adminCustomizeThemesShow: Ember.inject.controller(),
|
||||
|
||||
@ -19,10 +37,23 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
||||
disabled: Em.computed.not('enabled'),
|
||||
|
||||
@computed('name', 'adminCustomizeThemesShow.model.theme_fields')
|
||||
nameValid(name, themeFields) {
|
||||
return name &&
|
||||
name.match(/^[a-z_][a-z0-9_-]*$/i) &&
|
||||
!themeFields.some(tf => THEME_FIELD_VARIABLE_TYPE_IDS.includes(tf.type_id) && name === tf.name);
|
||||
errorMessage(name, themeFields) {
|
||||
if (name) {
|
||||
if (!name.match(/^[a-z_][a-z0-9_-]*$/i)) {
|
||||
return I18n.t("admin.customize.theme.variable_name_error.invalid_syntax");
|
||||
} else if (SCSS_VARIABLE_NAMES.includes(name.toLowerCase())) {
|
||||
return I18n.t("admin.customize.theme.variable_name_error.no_overwrite");
|
||||
} else if (themeFields.some(tf => THEME_FIELD_VARIABLE_TYPE_IDS.includes(tf.type_id) && name === tf.name)) {
|
||||
return I18n.t("admin.customize.theme.variable_name_error.must_be_unique");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
@computed('errorMessage')
|
||||
nameValid(errorMessage) {
|
||||
return null === errorMessage;
|
||||
},
|
||||
|
||||
@observes('name')
|
||||
|
||||
@ -1,68 +1,80 @@
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
import Report from "admin/models/report";
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
|
||||
export default Ember.Mixin.create({
|
||||
classNameBindings: ["isLoading"],
|
||||
|
||||
report: null,
|
||||
reports: null,
|
||||
isLoading: false,
|
||||
dataSourceNames: "",
|
||||
title: null,
|
||||
|
||||
init() {
|
||||
this._super();
|
||||
this.set("reports", []);
|
||||
},
|
||||
|
||||
this.messageBus.subscribe(this.get("dataSource"), report => {
|
||||
const formatDate = (date) => moment(date).format("YYYYMMDD");
|
||||
@computed("dataSourceNames")
|
||||
dataSources(dataSourceNames) {
|
||||
return dataSourceNames.split(",").map(source => `/admin/reports/${source}`);
|
||||
},
|
||||
|
||||
// this check is done to avoid loading a chart after period has changed
|
||||
if (
|
||||
(this.get("startDate") && formatDate(report.start_date) === formatDate(this.get("startDate"))) &&
|
||||
(this.get("endDate") && formatDate(report.end_date) === formatDate(this.get("endDate")))
|
||||
) {
|
||||
this._setPropertiesFromReport(Report.create(report));
|
||||
this.set("isLoading", false);
|
||||
this.renderReport();
|
||||
@computed("reports.[]", "startDate", "endDate", "dataSourceNames")
|
||||
reportsForPeriod(reports, startDate, endDate, dataSourceNames) {
|
||||
// on a slow network fetchReport could be called multiple times between
|
||||
// T and T+x, and all the ajax responses would occur after T+(x+y)
|
||||
// to avoid any inconsistencies we filter by period and make sure
|
||||
// the array contains only unique values
|
||||
reports = reports.uniqBy("report_key");
|
||||
|
||||
|
||||
const sort = (r) => {
|
||||
if (r.length > 1) {
|
||||
return dataSourceNames
|
||||
.split(",")
|
||||
.map(name => r.findBy("type", name));
|
||||
} else {
|
||||
this._setPropertiesFromReport(Report.create(report));
|
||||
this.set("isLoading", false);
|
||||
this.renderReport();
|
||||
return r;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return sort(reports);
|
||||
}
|
||||
|
||||
|
||||
return sort(reports.filter(report => {
|
||||
return report.report_key.includes(startDate.format("YYYYMMDD")) &&
|
||||
report.report_key.includes(endDate.format("YYYYMMDD"));
|
||||
}));
|
||||
},
|
||||
|
||||
didInsertElement() {
|
||||
this._super();
|
||||
|
||||
Ember.run.later(this, function() {
|
||||
this.fetchReport();
|
||||
}, 500);
|
||||
this.fetchReport()
|
||||
.finally(() => {
|
||||
this.renderReport();
|
||||
});
|
||||
},
|
||||
|
||||
didUpdateAttrs() {
|
||||
this._super();
|
||||
|
||||
this.fetchReport();
|
||||
this.fetchReport()
|
||||
.finally(() => {
|
||||
this.renderReport();
|
||||
});
|
||||
},
|
||||
|
||||
renderReport() {},
|
||||
|
||||
@computed("dataSourceName")
|
||||
dataSource(dataSourceName) {
|
||||
return `/admin/reports/${dataSourceName}`;
|
||||
renderReport() {
|
||||
if (!this.element || this.isDestroying || this.isDestroyed) return;
|
||||
this.set("title", this.get("reportsForPeriod").map(r => r.title).join(", "));
|
||||
this.set("isLoading", false);
|
||||
},
|
||||
|
||||
@computed("report")
|
||||
labels(report) {
|
||||
if (!report) return;
|
||||
return Ember.makeArray(report.data).map(r => r.x);
|
||||
},
|
||||
loadReport() {},
|
||||
|
||||
@computed("report")
|
||||
values(report) {
|
||||
if (!report) return;
|
||||
return Ember.makeArray(report.data).map(r => r.y);
|
||||
fetchReport() {
|
||||
this.set("reports", []);
|
||||
this.set("isLoading", true);
|
||||
},
|
||||
|
||||
_setPropertiesFromReport(report) {
|
||||
if (!this.element || this.isDestroying || this.isDestroyed) { return; }
|
||||
this.setProperties({ report });
|
||||
}
|
||||
});
|
||||
|
||||
@ -13,10 +13,13 @@ AdminDashboardNext.reopenClass({
|
||||
@return {jqXHR} a jQuery Promise object
|
||||
**/
|
||||
find() {
|
||||
|
||||
return ajax("/admin/dashboard-next.json").then(function(json) {
|
||||
|
||||
var model = AdminDashboardNext.create();
|
||||
|
||||
model.set("reports", json.reports);
|
||||
model.set("version_check", json.version_check);
|
||||
|
||||
const attributes = {};
|
||||
ATTRIBUTES.forEach(a => attributes[a] = json[a]);
|
||||
@ -24,6 +27,25 @@ AdminDashboardNext.reopenClass({
|
||||
|
||||
model.set("loaded", true);
|
||||
|
||||
return model;
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
Only fetch the list of problems that should be rendered on the dashboard.
|
||||
The model will only have its "problems" attribute set.
|
||||
|
||||
@method fetchProblems
|
||||
@return {jqXHR} a jQuery Promise object
|
||||
**/
|
||||
fetchProblems: function() {
|
||||
return ajax("/admin/dashboard/problems.json", {
|
||||
type: 'GET',
|
||||
dataType: 'json'
|
||||
}).then(function(json) {
|
||||
var model = AdminDashboardNext.create(json);
|
||||
model.set('loaded', true);
|
||||
return model;
|
||||
});
|
||||
}
|
||||
|
||||
@ -19,23 +19,6 @@ AdminDashboard.reopenClass({
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
Only fetch the list of problems that should be rendered on the dashboard.
|
||||
The model will only have its "problems" attribute set.
|
||||
|
||||
@method fetchProblems
|
||||
@return {jqXHR} a jQuery Promise object
|
||||
**/
|
||||
fetchProblems: function() {
|
||||
return ajax("/admin/dashboard/problems.json", {
|
||||
type: 'GET',
|
||||
dataType: 'json'
|
||||
}).then(function(json) {
|
||||
var model = AdminDashboard.create(json);
|
||||
model.set('loaded', true);
|
||||
return model;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default AdminDashboard;
|
||||
|
||||
@ -300,7 +300,8 @@ const AdminUser = Discourse.User.extend({
|
||||
|
||||
deactivate() {
|
||||
return ajax('/admin/users/' + this.id + '/deactivate', {
|
||||
type: 'PUT'
|
||||
type: 'PUT',
|
||||
data: { context: document.location.pathname }
|
||||
}).then(function() {
|
||||
window.location.reload();
|
||||
}).catch(function(e) {
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
import { ajax } from 'discourse/lib/ajax';
|
||||
import round from "discourse/lib/round";
|
||||
import { fmt } from 'discourse/lib/computed';
|
||||
import { fillMissingDates } from 'discourse/lib/utilities';
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
const Report = Discourse.Model.extend({
|
||||
average: false,
|
||||
percent: false,
|
||||
|
||||
reportUrl: fmt("type", "/admin/reports/%@"),
|
||||
@computed("type", "start_date", "end_date")
|
||||
reportUrl(type, start_date, end_date) {
|
||||
start_date = moment(start_date).locale('en').format("YYYY-MM-DD");
|
||||
end_date = moment(end_date).locale('en').format("YYYY-MM-DD");
|
||||
return Discourse.getURL(`/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}`);
|
||||
},
|
||||
|
||||
valueAt(numDaysAgo) {
|
||||
if (this.data) {
|
||||
const wantedDate = moment().subtract(numDaysAgo, "days").format("YYYY-MM-DD");
|
||||
const wantedDate = moment().subtract(numDaysAgo, "days").locale('en').format("YYYY-MM-DD");
|
||||
const item = this.data.find(d => d.x === wantedDate);
|
||||
if (item) {
|
||||
return item.y;
|
||||
@ -90,6 +95,50 @@ const Report = Discourse.Model.extend({
|
||||
}
|
||||
},
|
||||
|
||||
@computed('data')
|
||||
currentTotal(data){
|
||||
return _.reduce(data, (cur, pair) => cur + pair.y, 0);
|
||||
},
|
||||
|
||||
@computed('data', 'currentTotal')
|
||||
currentAverage(data, total) {
|
||||
return Ember.makeArray(data).length === 0 ? 0 : parseFloat((total / parseFloat(data.length)).toFixed(1));
|
||||
},
|
||||
|
||||
@computed("trend")
|
||||
trendIcon(trend) {
|
||||
switch (trend) {
|
||||
case "trending-up":
|
||||
return "angle-up";
|
||||
case "trending-down":
|
||||
return "angle-down";
|
||||
case "high-trending-up":
|
||||
return "angle-double-up";
|
||||
case "high-trending-down":
|
||||
return "angle-double-down";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
@computed('prev_period', 'currentTotal', 'currentAverage')
|
||||
trend(prev, currentTotal, currentAverage) {
|
||||
const total = this.get('average') ? currentAverage : currentTotal;
|
||||
const change = ((total - prev) / total) * 100;
|
||||
|
||||
if (change > 50) {
|
||||
return "high-trending-up";
|
||||
} else if (change > 0) {
|
||||
return "trending-up";
|
||||
} else if (change === 0) {
|
||||
return "no-change";
|
||||
} else if (change < -50) {
|
||||
return "high-trending-down";
|
||||
} else if (change < 0) {
|
||||
return "trending-down";
|
||||
}
|
||||
},
|
||||
|
||||
@computed('prev30Days', 'lastThirtyDaysCount')
|
||||
thirtyDayTrend(prev30Days, lastThirtyDaysCount) {
|
||||
const currentPeriod = lastThirtyDaysCount;
|
||||
@ -110,6 +159,9 @@ const Report = Discourse.Model.extend({
|
||||
|
||||
@computed('type')
|
||||
icon(type) {
|
||||
if (type.indexOf("message") > -1) {
|
||||
return "envelope";
|
||||
}
|
||||
switch (type) {
|
||||
case "flags": return "flag";
|
||||
case "likes": return "heart";
|
||||
@ -138,6 +190,22 @@ const Report = Discourse.Model.extend({
|
||||
}
|
||||
},
|
||||
|
||||
@computed('prev_period', 'currentTotal', 'currentAverage')
|
||||
trendTitle(prev, currentTotal, currentAverage) {
|
||||
let current = this.get('average') ? currentAverage : currentTotal;
|
||||
let percent = this.percentChangeString(current, prev);
|
||||
|
||||
if (this.get('average')) {
|
||||
prev = prev ? prev.toFixed(1) : "0";
|
||||
if (this.get('percent')) {
|
||||
current += '%';
|
||||
prev += '%';
|
||||
}
|
||||
}
|
||||
|
||||
return I18n.t('admin.dashboard.reports.trend_title', {percent: percent, prev: prev, current: current});
|
||||
},
|
||||
|
||||
changeTitle(val1, val2, prevPeriodString) {
|
||||
const percentChange = this.percentChangeString(val1, val2);
|
||||
var title = "";
|
||||
@ -176,6 +244,15 @@ const Report = Discourse.Model.extend({
|
||||
|
||||
Report.reopenClass({
|
||||
|
||||
fillMissingDates(report) {
|
||||
if (_.isArray(report.data)) {
|
||||
|
||||
const startDateFormatted = moment.utc(report.start_date).locale('en').format('YYYY-MM-DD');
|
||||
const endDateFormatted = moment.utc(report.end_date).locale('en').format('YYYY-MM-DD');
|
||||
report.data = fillMissingDates(report.data, startDateFormatted, endDateFormatted);
|
||||
}
|
||||
},
|
||||
|
||||
find(type, startDate, endDate, categoryId, groupId) {
|
||||
return ajax("/admin/reports/" + type, {
|
||||
data: {
|
||||
@ -186,11 +263,7 @@ Report.reopenClass({
|
||||
}
|
||||
}).then(json => {
|
||||
// Add zero values for missing dates
|
||||
if (json.report.data.length > 0) {
|
||||
const startDateFormatted = moment(json.report.start_date).utc().format('YYYY-MM-DD');
|
||||
const endDateFormatted = moment(json.report.end_date).utc().format('YYYY-MM-DD');
|
||||
json.report.data = fillMissingDates(json.report.data, startDateFormatted, endDateFormatted);
|
||||
}
|
||||
Report.fillMissingDates(json.report);
|
||||
|
||||
const model = Report.create({ type: type });
|
||||
model.setProperties(json.report);
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import loadScript from "discourse/lib/load-script";
|
||||
|
||||
export default Discourse.Route.extend({
|
||||
activate() {
|
||||
loadScript("/javascripts/Chart.min.js").then(() => {
|
||||
this.controllerFor('admin-dashboard-next').fetchDashboard();
|
||||
});
|
||||
this.controllerFor('admin-dashboard-next').fetchDashboard();
|
||||
}
|
||||
});
|
||||
|
||||
@ -8,12 +8,15 @@ export default Ember.Route.extend({
|
||||
const controller = this.controllerFor('adminSiteSettings');
|
||||
this.transitionTo('adminSiteSettingsCategory', 'plugins').then(() => {
|
||||
if (plugin) {
|
||||
const siteSettingFilter = plugin.get('enabled_setting_filter');
|
||||
const match = /^(.*)_enabled/.exec(plugin.get('enabled_setting'));
|
||||
if (match[1]) {
|
||||
const filter = siteSettingFilter || match[1];
|
||||
|
||||
if (filter) {
|
||||
// filterContent() is normally on a debounce from typing.
|
||||
// Because we don't want the default of "All Results", we tell it
|
||||
// to skip the next debounce.
|
||||
controller.set('filter', match[1]);
|
||||
controller.set('filter', filter);
|
||||
controller.set('_skipBounce', true);
|
||||
controller.filterContentNow('plugins');
|
||||
}
|
||||
@ -22,4 +25,3 @@ export default Ember.Route.extend({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
export default function() {
|
||||
this.route('admin', { resetNamespace: true }, function() {
|
||||
this.route('dashboard', { path: '/' });
|
||||
this.route('dashboardNext', { path: '/dashboard-next' });
|
||||
this.route('dashboard', { path: '/dashboard-old' });
|
||||
this.route('dashboardNext', { path: '/' });
|
||||
this.route('adminSiteSettings', { path: '/site_settings', resetNamespace: true }, function() {
|
||||
this.route('adminSiteSettingsCategory', { path: 'category/:category_id', resetNamespace: true} );
|
||||
});
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<div class="full-width">
|
||||
|
||||
<ul class="nav nav-pills">
|
||||
{{nav-item route='admin.dashboard' label='admin.dashboard.title'}}
|
||||
{{nav-item route='admin.dashboardNext' label='admin.dashboard.title'}}
|
||||
{{#if currentUser.admin}}
|
||||
{{nav-item route='adminSiteSettings' label='admin.site_settings.title'}}
|
||||
{{/if}}
|
||||
|
||||
@ -19,4 +19,6 @@
|
||||
{{number report.lastThirtyDaysCount}} {{d-icon "caret-up" class="up"}} {{d-icon "caret-down" class="down"}}
|
||||
</td>
|
||||
|
||||
<td class="value">{{number report.total}}</td>
|
||||
{{#if allTime}}
|
||||
<td class="value">{{number report.total}}</td>
|
||||
{{/if}}
|
||||
|
||||
@ -1,28 +1,40 @@
|
||||
{{#conditional-loading-section isLoading=isLoading title=report.title}}
|
||||
{{#conditional-loading-section isLoading=isLoading}}
|
||||
<div class="table-title">
|
||||
<h3>{{report.title}}</h3>
|
||||
<h3>{{title}}</h3>
|
||||
|
||||
{{#if help}}
|
||||
<a href="{{helpPage}}">{{i18n help}}</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{{#each labels as |label|}}
|
||||
<th>{{label}}</th>
|
||||
{{/each}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{{#each values as |value|}}
|
||||
<td>{{number value}}</td>
|
||||
{{/each}}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{#each reportsForPeriod as |report|}}
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{{#if report.labels}}
|
||||
{{#each report.labels as |label|}}
|
||||
<th>{{label}}</th>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
{{#each report.data as |data|}}
|
||||
<th>{{data.x}}</th>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#unless hasBlock}}
|
||||
{{#each report.data as |data|}}
|
||||
<tr>
|
||||
<td>{{number data.y}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
{{yield (hash report=report)}}
|
||||
{{/unless}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{/conditional-loading-section}}
|
||||
|
||||
@ -1,31 +1,33 @@
|
||||
{{#conditional-loading-section isLoading=isLoading title=report.title}}
|
||||
<div class="chart-title">
|
||||
<h3 title={{report.description}}>
|
||||
{{report.title}}
|
||||
{{#conditional-loading-section isLoading=isLoading}}
|
||||
{{#each reportsForPeriod as |report|}}
|
||||
<div class="status">
|
||||
<h4 class="title">
|
||||
<a href="{{report.reportUrl}}">
|
||||
{{report.title}}
|
||||
</a>
|
||||
|
||||
{{#if report.description}}
|
||||
{{d-icon "question-circle"}}
|
||||
{{/if}}
|
||||
</h3>
|
||||
<span class="info" data-tooltip="{{report.description}}">
|
||||
{{d-icon "question-circle"}}
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
<div class="chart-trend {{trend}}">
|
||||
<span title="{{report.thirtyDayCountTitle}}">
|
||||
{{number report.lastThirtyDaysCount}}
|
||||
</span>
|
||||
<div class="trend {{report.trend}}">
|
||||
<span class="trend-value" title="{{report.trendTitle}}">
|
||||
{{#if report.average}}
|
||||
{{number report.currentAverage}}{{#if report.percent}}%{{/if}}
|
||||
{{else}}
|
||||
{{number report.currentTotal noTitle="true"}}{{#if report.percent}}%{{/if}}
|
||||
{{/if}}
|
||||
</span>
|
||||
|
||||
{{#if trendIcon}}
|
||||
{{d-icon trendIcon}}
|
||||
{{/if}}
|
||||
{{#if report.trendIcon}}
|
||||
{{d-icon report.trendIcon class="trend-icon"}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
<div class="chart-container">
|
||||
{{#if oneDataPoint}}
|
||||
<span class="data-point">
|
||||
{{number values.lastObject.y}}
|
||||
</span>
|
||||
{{else}}
|
||||
<canvas class="chart-canvas"></canvas>
|
||||
{{/if}}
|
||||
<div class="chart-canvas-container">
|
||||
<canvas class="chart-canvas"></canvas>
|
||||
</div>
|
||||
{{/conditional-loading-section}}
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
{{#conditional-loading-section isLoading=isLoading title=report.title}}
|
||||
<div class="table-title">
|
||||
<h3>{{report.title}}</h3>
|
||||
|
||||
{{#if help}}
|
||||
<a href="{{helpPage}}">{{i18n help}}</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{{#each labels as |label|}}
|
||||
<th>{{label}}</th>
|
||||
{{/each}}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{#each values as |value|}}
|
||||
<tr>
|
||||
{{#each value as |v|}}
|
||||
<td>{{v}}</td>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{/conditional-loading-section}}
|
||||
@ -0,0 +1,36 @@
|
||||
{{#if foundProblems}}
|
||||
<div class="dashboard-stats detected-problems">
|
||||
<div class="problem-messages">
|
||||
{{#conditional-loading-spinner condition=loadingProblems}}
|
||||
<div>
|
||||
<h3>
|
||||
<span class="look-here">{{d-icon "exclamation-triangle"}}</span>
|
||||
{{i18n 'admin.dashboard.problems_found'}}</h3>
|
||||
<ul class="{{if loadingProblems 'invisible'}}">
|
||||
{{#each problems as |problem|}}
|
||||
<li>{{{problem}}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
<p class="actions">
|
||||
<small>{{i18n 'admin.dashboard.last_checked'}}: {{problemsTimestamp}}</small>
|
||||
{{d-button action="refreshProblems" class="btn-small" icon="refresh" label="admin.dashboard.refresh_problems"}}
|
||||
</p>
|
||||
{{/conditional-loading-spinner}}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if thereWereProblems}}
|
||||
<div class="dashboard-stats detected-problems">
|
||||
<div class="look-here"> </div>
|
||||
<div class="problem-messages">
|
||||
<p>
|
||||
{{i18n 'admin.dashboard.no_problems'}}
|
||||
{{d-button action="refreshProblems" class="btn-small" icon="refresh" label="admin.dashboard.refresh_problems"}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@ -1,11 +1,5 @@
|
||||
{{plugin-outlet name="admin-dashboard-top"}}
|
||||
|
||||
{{#conditional-loading-spinner condition=loading}}
|
||||
<div class="dashboard-left">
|
||||
{{#if showVersionChecks}}
|
||||
{{partial 'admin/templates/version-checks'}}
|
||||
{{/if}}
|
||||
|
||||
<div class="dashboard-stats trust-levels">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
@ -174,42 +168,6 @@
|
||||
</div>
|
||||
|
||||
<div class="dashboard-right">
|
||||
{{#if foundProblems}}
|
||||
<div class="dashboard-stats detected-problems">
|
||||
<div class="look-here">{{d-icon "exclamation-triangle"}}</div>
|
||||
<div class="problem-messages">
|
||||
{{#conditional-loading-spinner condition=loadingProblems}}
|
||||
<p>
|
||||
{{i18n 'admin.dashboard.problems_found'}}
|
||||
<ul class="{{if loadingProblems 'invisible'}}">
|
||||
{{#each problems as |problem|}}
|
||||
<li>{{{problem}}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</p>
|
||||
<p class="actions">
|
||||
<small>{{i18n 'admin.dashboard.last_checked'}}: {{problemsTimestamp}}</small>
|
||||
{{d-button action="refreshProblems" class="btn-small" icon="refresh" label="admin.dashboard.refresh_problems"}}
|
||||
</p>
|
||||
{{/conditional-loading-spinner}}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if thereWereProblems}}
|
||||
<div class="dashboard-stats detected-problems">
|
||||
<div class="look-here"> </div>
|
||||
<div class="problem-messages">
|
||||
<p>
|
||||
{{i18n 'admin.dashboard.no_problems'}}
|
||||
{{d-button action="refreshProblems" class="btn-small" icon="refresh" label="admin.dashboard.refresh_problems"}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<div class="dashboard-stats">
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
|
||||
@ -1,41 +1,50 @@
|
||||
{{plugin-outlet name="admin-dashboard-top"}}
|
||||
{{plugin-outlet name="admin-dashboard-top"}}
|
||||
|
||||
<div class="section-top">
|
||||
{{#if showVersionChecks}}
|
||||
<div class="version-checks">
|
||||
{{partial 'admin/templates/version-checks'}}
|
||||
{{partial 'admin/templates/dashboard-problems'}}
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class='clearfix'></div>
|
||||
</div>
|
||||
|
||||
<div class="community-health section">
|
||||
<div class="section-title">
|
||||
<h2>{{i18n "admin.dashboard.community_health"}}</h2>
|
||||
{{period-chooser period=period action="changePeriod"}}
|
||||
{{period-chooser period=period action="changePeriod" content=availablePeriods fullDay=true}}
|
||||
</div>
|
||||
|
||||
<div class="section-body">
|
||||
<div class="charts">
|
||||
{{dashboard-mini-chart
|
||||
dataSourceName="signups"
|
||||
dataSourceNames="signups"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
||||
{{dashboard-mini-chart
|
||||
dataSourceName="topics"
|
||||
dataSourceNames="topics"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
||||
{{dashboard-mini-chart
|
||||
dataSourceName="new_contributors"
|
||||
dataSourceNames="posts"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
||||
{{dashboard-mini-chart
|
||||
dataSourceName="dau_by_mau"
|
||||
average=true
|
||||
dataSourceNames="dau_by_mau"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
||||
{{dashboard-mini-chart
|
||||
dataSourceName="daily_engaged_users"
|
||||
dataSourceNames="daily_engaged_users"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
|
||||
{{dashboard-mini-chart
|
||||
dataSourceName="inactive_users"
|
||||
dataSourceNames="new_contributors"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
</div>
|
||||
@ -59,12 +68,11 @@
|
||||
<th>{{i18n 'admin.dashboard.reports.yesterday'}}</th>
|
||||
<th>{{i18n 'admin.dashboard.reports.last_7_days'}}</th>
|
||||
<th>{{i18n 'admin.dashboard.reports.last_30_days'}}</th>
|
||||
<th>{{i18n 'admin.dashboard.reports.all'}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each reports as |report|}}
|
||||
{{admin-report-counts report=report}}
|
||||
{{admin-report-counts report=report allTime=false}}
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
@ -72,20 +80,26 @@
|
||||
{{/conditional-loading-section}}
|
||||
</div>
|
||||
|
||||
{{dashboard-inline-table
|
||||
dataSourceName="users_by_type"
|
||||
lastRefreshedAt=lastRefreshedAt}}
|
||||
|
||||
{{dashboard-inline-table
|
||||
dataSourceName="users_by_trust_level"
|
||||
lastRefreshedAt=lastRefreshedAt}}
|
||||
{{#dashboard-inline-table dataSourceNames="users_by_type,users_by_trust_level" lastRefreshedAt=lastRefreshedAt as |context|}}
|
||||
<tr>
|
||||
{{#each context.report.data as |data|}}
|
||||
<td>
|
||||
<a href="/admin/users/list/{{data.key}}">
|
||||
{{number data.y}}
|
||||
</a>
|
||||
</td>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{/dashboard-inline-table}}
|
||||
|
||||
{{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}}
|
||||
<div class="misc">
|
||||
<div class="durability">
|
||||
{{#if currentUser.admin}}
|
||||
<div class="backups">
|
||||
<h3 class="durability-title"><a href="/admin/backups">{{i18n "admin.dashboard.backups"}}</a></h3>
|
||||
<h3 class="durability-title">
|
||||
<a href="/admin/backups">{{d-icon "archive"}} {{i18n "admin.dashboard.backups"}}</a>
|
||||
</h3>
|
||||
<p>
|
||||
{{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}})
|
||||
<br />
|
||||
@ -95,30 +109,77 @@
|
||||
{{/if}}
|
||||
|
||||
<div class="uploads">
|
||||
<h3 class="durability-title">{{i18n "admin.dashboard.uploads"}}</h3>
|
||||
<h3 class="durability-title">{{d-icon "upload"}} {{i18n "admin.dashboard.uploads"}}</h3>
|
||||
<p>
|
||||
{{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<p class="last-dashboard-update">
|
||||
{{i18n "admin.dashboard.last_updated"}} {{updatedTimestamp}}
|
||||
<div class="last-dashboard-update">
|
||||
<div>
|
||||
<h4>{{i18n "admin.dashboard.last_updated"}} </h4>
|
||||
<p>{{updatedTimestamp}}</p>
|
||||
<a rel="noopener" target="_blank" href="https://meta.discourse.org/tags/release-notes" class="btn">
|
||||
{{i18n "admin.dashboard.whats_new_in_discourse"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
{{i18n 'admin.dashboard.find_old'}} {{#link-to 'admin.dashboard'}}{{i18n "admin.dashboard.old_link"}}{{/link-to}}
|
||||
</p>
|
||||
|
||||
<a rel="noopener" target="_blank" href="https://meta.discourse.org/t/discourse-2-0-0-beta6-release-notes/85241" class="btn">
|
||||
{{i18n "admin.dashboard.whats_new_in_discourse"}}
|
||||
</a>
|
||||
</div>
|
||||
{{/conditional-loading-section}}
|
||||
</div>
|
||||
|
||||
<div class="section-column">
|
||||
{{dashboard-table-trending-search
|
||||
dataSourceName="trending_search"
|
||||
startDate=startDate
|
||||
endDate=endDate}}
|
||||
<div class="top-referred-topics">
|
||||
{{#dashboard-inline-table
|
||||
dataSourceNames="top_referred_topics"
|
||||
lastRefreshedAt=lastRefreshedAt
|
||||
limit=8
|
||||
as |context|}}
|
||||
{{#each context.report.data as |data|}}
|
||||
<tr>
|
||||
<td class='left'>
|
||||
<a href="{{data.topic_url}}">
|
||||
{{data.topic_title}}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{data.num_clicks}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/dashboard-inline-table}}
|
||||
</div>
|
||||
|
||||
<div class="trending-search">
|
||||
{{#dashboard-inline-table
|
||||
limit=8
|
||||
dataSourceNames="trending_search"
|
||||
isEnabled=logSearchQueriesEnabled
|
||||
disabledLabel="admin.dashboard.reports.trending_search.disabled"
|
||||
startDate=lastWeek
|
||||
endDate=endDate as |context|}}
|
||||
{{#each context.report.data as |data|}}
|
||||
<tr>
|
||||
<td class='left'>
|
||||
{{data.term}}
|
||||
</td>
|
||||
<td>
|
||||
{{number data.unique_searches}}
|
||||
</td>
|
||||
<td>
|
||||
{{data.ctr}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/dashboard-inline-table}}
|
||||
{{{i18n "admin.dashboard.reports.trending_search.more"}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6,9 +6,9 @@
|
||||
{{input id="name" value=name}}<br>
|
||||
</label>
|
||||
{{#if fileSelected}}
|
||||
{{#unless nameValid}}
|
||||
<span class="alert alert-error">{{i18n "admin.customize.theme.variable_name_invalid"}}</span>
|
||||
{{/unless}}
|
||||
{{#if errorMessage}}
|
||||
<span class="alert alert-error">{{errorMessage}}</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/d-modal-body}}
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
<h3>{{model.title}}</h3>
|
||||
|
||||
{{#if model.description}}
|
||||
<p>{{model.description}}</p>
|
||||
{{/if}}
|
||||
|
||||
<div class="admin-reports-filter">
|
||||
{{i18n 'admin.dashboard.reports.start_date'}} {{date-picker-past value=startDate defaultDate=startDate}}
|
||||
{{i18n 'admin.dashboard.reports.end_date'}} {{date-picker-past value=endDate defaultDate=endDate}}
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
{{#each filteredContent as |setting|}}
|
||||
{{site-setting setting=setting}}
|
||||
{{/each}}
|
||||
{{#if category.hasMore}}
|
||||
<p class="warning">{{i18n 'admin.site_settings.more_than_30_results'}}</p>
|
||||
{{/if}}
|
||||
{{/d-section}}
|
||||
{{else}}
|
||||
<br/>
|
||||
|
||||
@ -18,9 +18,7 @@
|
||||
<li class="{{category.nameKey}}">
|
||||
{{#link-to 'adminSiteSettingsCategory' category.nameKey class=category.nameKey}}
|
||||
{{category.name}}
|
||||
{{#if filtered}}
|
||||
{{#if category.count}}<span class="count">({{category.count}})</span>{{/if}}
|
||||
{{/if}}
|
||||
{{#if category.count}}<span class="count">({{category.count}})</span>{{/if}}
|
||||
{{/link-to}}
|
||||
</li>
|
||||
{{/each}}
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
{{nav-item route='adminUsersList.show' routeParam='suspended' label='admin.users.nav.suspended'}}
|
||||
{{nav-item route='adminUsersList.show' routeParam='silenced' label='admin.users.nav.silenced'}}
|
||||
{{nav-item route='adminUsersList.show' routeParam='suspect' label='admin.users.nav.suspect'}}
|
||||
{{nav-item route='adminUsersList.show' routeParam='staged' label='admin.users.nav.staged'}}
|
||||
{{nav-item route='groups' label='groups.index.title'}}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="pull-right">
|
||||
|
||||
@ -1,73 +1,83 @@
|
||||
<div class="dashboard-stats version-check {{if versionCheck.critical_updates 'critical' 'normal'}}">
|
||||
<table class="table table-condensed table-hover">
|
||||
{{custom-html name="upgrade-header" versionCheck=versionCheck tagName="thead"}}
|
||||
</table>
|
||||
<div class="section-title">
|
||||
<h2>{{i18n 'admin.dashboard.version'}}</h2>
|
||||
</div>
|
||||
<div class="dashboard-stats version-check {{if versionCheck.critical_updates 'critical' 'normal'}}">
|
||||
|
||||
<table class="table table-condensed table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>{{i18n 'admin.dashboard.installed_version'}}</th>
|
||||
<th colspan="3">{{i18n 'admin.dashboard.latest_version'}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<td class="title">{{i18n 'admin.dashboard.version'}}</td>
|
||||
<td class="version-number"><a href={{versionCheck.gitLink}} target="_blank">{{dash-if-empty versionCheck.installed_describe}}</a></td>
|
||||
<div class="version-number">
|
||||
<h4>{{i18n 'admin.dashboard.installed_version'}}</h4>
|
||||
<h3><a href={{versionCheck.gitLink}} target="_blank">{{dash-if-empty versionCheck.installed_describe}}</a></h3>
|
||||
</div>
|
||||
|
||||
{{#if versionCheck.noCheckPerformed}}
|
||||
<td class="version-number">—</td>
|
||||
<td class="face">
|
||||
<span class="icon critical-updates-available">{{d-icon "frown-o"}}</span>
|
||||
</td>
|
||||
<td class="version-notes">
|
||||
<span class="normal-note">{{i18n 'admin.dashboard.no_check_performed'}}</span>
|
||||
</td>
|
||||
<div class="version-number">
|
||||
<h4>{{i18n 'admin.dashboard.latest_version'}}</h4>
|
||||
<h3>—</h3>
|
||||
</div>
|
||||
<div class="version-status">
|
||||
<div class="face">
|
||||
<span class="icon critical-updates-available">{{d-icon "frown-o"}}</span>
|
||||
</div>
|
||||
<div class="version-notes">
|
||||
<span class="normal-note">{{i18n 'admin.dashboard.no_check_performed'}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if versionCheck.stale_data}}
|
||||
<td class="version-number">{{#if versionCheck.version_check_pending}}{{dash-if-empty versionCheck.installed_version}}{{/if}}</td>
|
||||
<td class="face">
|
||||
{{#if versionCheck.version_check_pending}}
|
||||
<span class='icon up-to-date'>{{d-icon "smile-o"}}</span>
|
||||
{{else}}
|
||||
<span class="icon critical-updates-available">{{d-icon "frown-o"}}</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td class="version-notes">
|
||||
<span class="normal-note">
|
||||
<div class="version-number">
|
||||
<h4>{{i18n 'admin.dashboard.latest_version'}}</h4>
|
||||
<h3>{{#if versionCheck.version_check_pending}}{{dash-if-empty versionCheck.installed_version}}{{/if}}</h3>
|
||||
</div>
|
||||
<div class="version-status">
|
||||
<div class="face">
|
||||
{{#if versionCheck.version_check_pending}}
|
||||
{{i18n 'admin.dashboard.version_check_pending'}}
|
||||
<span class='icon up-to-date'>{{d-icon "smile-o"}}</span>
|
||||
{{else}}
|
||||
{{i18n 'admin.dashboard.stale_data'}}
|
||||
<span class="icon critical-updates-available">{{d-icon "frown-o"}}</span>
|
||||
{{/if}}
|
||||
</span>
|
||||
</td>
|
||||
{{else}}
|
||||
<td class="version-number">{{dash-if-empty versionCheck.latest_version}}</td>
|
||||
<td class="face">
|
||||
{{#if versionCheck.upToDate }}
|
||||
<span class='icon up-to-date'>{{d-icon "smile-o"}}</span>
|
||||
{{else}}
|
||||
<span class="icon {{if versionCheck.critical_updates 'critical-updates-available' 'updates-available'}}">
|
||||
{{#if versionCheck.behindByOneVersion}}
|
||||
{{d-icon "meh-o"}}
|
||||
</div>
|
||||
<div class="version-notes">
|
||||
<span class="normal-note">
|
||||
{{#if versionCheck.version_check_pending}}
|
||||
{{i18n 'admin.dashboard.version_check_pending'}}
|
||||
{{else}}
|
||||
{{d-icon "frown-o"}}
|
||||
{{i18n 'admin.dashboard.stale_data'}}
|
||||
{{/if}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td class="version-notes">
|
||||
{{#if versionCheck.upToDate }}
|
||||
{{i18n 'admin.dashboard.up_to_date'}}
|
||||
{{else}}
|
||||
<span class="critical-note">{{i18n 'admin.dashboard.critical_available'}}</span>
|
||||
<span class="normal-note">{{i18n 'admin.dashboard.updates_available'}}</span>
|
||||
{{i18n 'admin.dashboard.please_upgrade'}}
|
||||
{{/if}}
|
||||
</td>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="version-number">
|
||||
<h4>{{i18n 'admin.dashboard.latest_version'}}</h4>
|
||||
<h3>{{dash-if-empty versionCheck.latest_version}}</h3>
|
||||
</div>
|
||||
<div class="version-status">
|
||||
<div class="face">
|
||||
{{#if versionCheck.upToDate }}
|
||||
<span class='icon up-to-date'>{{d-icon "smile-o"}}</span>
|
||||
{{else}}
|
||||
<span class="icon {{if versionCheck.critical_updates 'critical-updates-available' 'updates-available'}}">
|
||||
{{#if versionCheck.behindByOneVersion}}
|
||||
{{d-icon "meh-o"}}
|
||||
{{else}}
|
||||
{{d-icon "frown-o"}}
|
||||
{{/if}}
|
||||
</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="version-notes">
|
||||
{{#if versionCheck.upToDate }}
|
||||
{{i18n 'admin.dashboard.up_to_date'}}
|
||||
{{else}}
|
||||
<span class="critical-note">{{i18n 'admin.dashboard.critical_available'}}</span>
|
||||
<span class="normal-note">{{i18n 'admin.dashboard.updates_available'}}</span>
|
||||
{{i18n 'admin.dashboard.please_upgrade'}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{{custom-html name="upgrade-header" versionCheck=versionCheck tagName="div" classNames="upgrade-header"}}
|
||||
|
||||
</div>
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
//= require ./deprecated
|
||||
|
||||
// Stuff we need to load first
|
||||
//= require ./discourse/helpers/parse-html
|
||||
//= require ./discourse/lib/to-markdown
|
||||
//= require ./discourse/lib/utilities
|
||||
//= require ./discourse/lib/page-visible
|
||||
//= require ./discourse/lib/logout
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
import DiscourseURL from 'discourse/lib/url';
|
||||
|
||||
export default Ember.Component.extend({
|
||||
tagName: "section",
|
||||
@ -8,5 +9,14 @@ export default Ember.Component.extend({
|
||||
anyLogos() {
|
||||
return this.get("categories").any((c) => { return !Ember.isEmpty(c.get('uploaded_logo.url')); });
|
||||
return this.get("categories").any(c => !Ember.isEmpty(c.get('uploaded_logo.url')));
|
||||
},
|
||||
|
||||
click(e) {
|
||||
if (!$(e.target).is('a')) {
|
||||
const url = $(e.target).closest('.category-box').data("url");
|
||||
if (url) {
|
||||
DiscourseURL.routeTo(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
export default Ember.Component.extend({
|
||||
classNames: ["conditional-loading-section"],
|
||||
|
||||
@ -7,8 +5,5 @@ export default Ember.Component.extend({
|
||||
|
||||
isLoading: false,
|
||||
|
||||
@computed("title")
|
||||
computedTitle(title) {
|
||||
return title || I18n.t("conditional_loading_section.loading");
|
||||
}
|
||||
title: I18n.t("conditional_loading_section.loading")
|
||||
});
|
||||
|
||||
@ -36,6 +36,9 @@ export default Ember.Component.extend({
|
||||
|
||||
@on("willDestroyElement")
|
||||
_destroy() {
|
||||
if (this._picker) {
|
||||
this._picker.destroy();
|
||||
}
|
||||
this._picker = null;
|
||||
},
|
||||
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
import KeyValueStore from 'discourse/lib/key-value-store';
|
||||
import { context } from 'discourse/lib/desktop-notifications';
|
||||
import { context, confirmNotification } from 'discourse/lib/desktop-notifications';
|
||||
import {
|
||||
subscribe as subscribePushNotification,
|
||||
unsubscribe as unsubscribePushNotification,
|
||||
isPushNotificationsSupported,
|
||||
keyValueStore as pushNotificationKeyValueStore,
|
||||
userSubscriptionKey as pushNotificationUserSubscriptionKey
|
||||
} from 'discourse/lib/push-notifications';
|
||||
|
||||
const keyValueStore = new KeyValueStore(context);
|
||||
|
||||
@ -28,11 +35,6 @@ export default Ember.Component.extend({
|
||||
return typeof window.Notification === "undefined";
|
||||
},
|
||||
|
||||
@computed("isNotSupported", "notificationsPermission")
|
||||
isDefaultPermission(isNotSupported, notificationsPermission) {
|
||||
return isNotSupported ? false : notificationsPermission === "default";
|
||||
},
|
||||
|
||||
@computed("isNotSupported", "notificationsPermission")
|
||||
isDeniedPermission(isNotSupported, notificationsPermission) {
|
||||
return isNotSupported ? false : notificationsPermission === "denied";
|
||||
@ -44,27 +46,65 @@ export default Ember.Component.extend({
|
||||
},
|
||||
|
||||
@computed("isGrantedPermission", "notificationsDisabled")
|
||||
isEnabled(isGrantedPermission, notificationsDisabled) {
|
||||
isEnabledDesktop(isGrantedPermission, notificationsDisabled) {
|
||||
return isGrantedPermission ? !notificationsDisabled : false;
|
||||
},
|
||||
|
||||
actions: {
|
||||
requestPermission() {
|
||||
Notification.requestPermission(() => this.propertyDidChange('notificationsPermission'));
|
||||
@computed
|
||||
isEnabledPush: {
|
||||
set(value) {
|
||||
const user = this.currentUser;
|
||||
if(!user) {
|
||||
return false;
|
||||
}
|
||||
pushNotificationKeyValueStore.setItem(pushNotificationUserSubscriptionKey(user), value);
|
||||
return pushNotificationKeyValueStore.getItem(pushNotificationUserSubscriptionKey(user));
|
||||
},
|
||||
get() {
|
||||
const user = this.currentUser;
|
||||
return user ? pushNotificationKeyValueStore.getItem(pushNotificationUserSubscriptionKey(user)) : false;
|
||||
}
|
||||
},
|
||||
|
||||
isEnabled: Ember.computed.or("isEnabledDesktop", "isEnabledPush"),
|
||||
|
||||
isPushNotificationsPreferred() {
|
||||
if(!this.site.mobileView) {
|
||||
return false;
|
||||
}
|
||||
return isPushNotificationsSupported(this.site.mobileView);
|
||||
},
|
||||
|
||||
actions: {
|
||||
recheckPermission() {
|
||||
this.propertyDidChange('notificationsPermission');
|
||||
},
|
||||
|
||||
turnoff() {
|
||||
this.set('notificationsDisabled', 'disabled');
|
||||
this.propertyDidChange('notificationsPermission');
|
||||
if(this.get('isEnabledDesktop')) {
|
||||
this.set('notificationsDisabled', 'disabled');
|
||||
this.propertyDidChange('notificationsPermission');
|
||||
}
|
||||
if(this.get('isEnabledPush')) {
|
||||
unsubscribePushNotification(this.currentUser, () => {
|
||||
this.set("isEnabledPush", '');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
turnon() {
|
||||
this.set('notificationsDisabled', '');
|
||||
this.propertyDidChange('notificationsPermission');
|
||||
if(this.isPushNotificationsPreferred()) {
|
||||
subscribePushNotification(() => {
|
||||
this.set("isEnabledPush", 'subscribed');
|
||||
}, this.siteSettings.vapid_public_key_bytes);
|
||||
}
|
||||
else {
|
||||
this.set('notificationsDisabled', '');
|
||||
Notification.requestPermission(() => {
|
||||
confirmNotification();
|
||||
this.propertyDidChange('notificationsPermission');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -50,7 +50,7 @@ export default Ember.Component.extend({
|
||||
this._positionPicker();
|
||||
this._scrollTo();
|
||||
this._updateSelectedDiversity();
|
||||
this._checkVisibleSection();
|
||||
this._checkVisibleSection(true);
|
||||
});
|
||||
},
|
||||
|
||||
@ -106,7 +106,7 @@ export default Ember.Component.extend({
|
||||
}
|
||||
|
||||
this._updateSelectedDiversity();
|
||||
this._checkVisibleSection();
|
||||
this._checkVisibleSection(true);
|
||||
},
|
||||
|
||||
@observes("recentEmojis")
|
||||
@ -192,7 +192,7 @@ export default Ember.Component.extend({
|
||||
_unbindEvents() {
|
||||
this.$().off();
|
||||
this.$(window).off("resize");
|
||||
this.$modal.off("click");
|
||||
clearInterval(this._refreshInterval);
|
||||
$("#reply-control").off("div-resizing");
|
||||
$('html').off("mouseup.emoji-picker");
|
||||
},
|
||||
@ -316,18 +316,27 @@ export default Ember.Component.extend({
|
||||
},
|
||||
|
||||
_bindSectionsScroll() {
|
||||
this.$list.on("scroll", () => {
|
||||
this.scrollPosition = this.$list.scrollTop();
|
||||
let onScroll = () => {
|
||||
run.debounce(this, this._checkVisibleSection, 50);
|
||||
});
|
||||
};
|
||||
|
||||
this.$list.on("scroll", onScroll);
|
||||
this._refreshInterval = setInterval(onScroll, 100);
|
||||
},
|
||||
|
||||
_checkVisibleSection() {
|
||||
_checkVisibleSection(force) {
|
||||
// make sure we stop loading if picker has been removed
|
||||
if(!this.$picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPosition = this.$list.scrollTop();
|
||||
if (newPosition === this.scrollPosition && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollPosition = newPosition;
|
||||
|
||||
const $sections = this.$list.find(".section");
|
||||
const listHeight = this.$list.innerHeight();
|
||||
let $selectedSection;
|
||||
@ -523,19 +532,31 @@ export default Ember.Component.extend({
|
||||
},
|
||||
|
||||
_setButtonBackground(button, diversity) {
|
||||
const $button = $(button);
|
||||
const code = this._codeWithDiversity(
|
||||
$button.attr("title"),
|
||||
diversity || $button.hasClass("diversity")
|
||||
);
|
||||
|
||||
// force visual reloading if needed
|
||||
if($button.css("background-image") !== "none") {
|
||||
$button.css("background-image", "");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
$button
|
||||
.attr("data-loaded", 1)
|
||||
.css("background-image", `url("${emojiUrlFor(code)}")`);
|
||||
const $button = $(button);
|
||||
button = $button[0];
|
||||
|
||||
// changing style can force layout events
|
||||
// this could slow down timers and lead to
|
||||
// chrome delaying the request
|
||||
window.requestAnimationFrame(() =>{
|
||||
const code = this._codeWithDiversity(
|
||||
$button.attr("title"),
|
||||
diversity || $button.hasClass("diversity")
|
||||
);
|
||||
|
||||
// // force visual reloading if needed
|
||||
if(button.style.backgroundImage !== "none") {
|
||||
button.style.backgroundImage = "";
|
||||
}
|
||||
|
||||
button.style.backgroundImage = `url("${emojiUrlFor(code)}")`;
|
||||
$button.attr("data-loaded", 1);
|
||||
});
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
import { default as computed } from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
import {
|
||||
keyValueStore as pushNotificationKeyValueStore
|
||||
} from 'discourse/lib/push-notifications';
|
||||
|
||||
import { default as DesktopNotificationConfig } from 'discourse/components/desktop-notification-config';
|
||||
|
||||
const userDismissedPromptKey = "dismissed-prompt";
|
||||
|
||||
export default DesktopNotificationConfig.extend({
|
||||
@computed
|
||||
bannerDismissed: {
|
||||
set(value) {
|
||||
pushNotificationKeyValueStore.setItem(userDismissedPromptKey, value);
|
||||
return pushNotificationKeyValueStore.getItem(userDismissedPromptKey);
|
||||
},
|
||||
get() {
|
||||
return pushNotificationKeyValueStore.getItem(userDismissedPromptKey);
|
||||
}
|
||||
},
|
||||
|
||||
@computed("isNotSupported", "isEnabled", "bannerDismissed", "currentUser.reply_count", "currentUser.topic_count")
|
||||
showNotificationPromptBanner(isNotSupported, isEnabled, bannerDismissed, replyCount, topicCount) {
|
||||
return (this.siteSettings.push_notifications_prompt &&
|
||||
!isNotSupported &&
|
||||
this.currentUser &&
|
||||
replyCount + topicCount > 0 &&
|
||||
Notification.permission !== "denied" &&
|
||||
Notification.permission !== "granted" &&
|
||||
!isEnabled &&
|
||||
!bannerDismissed
|
||||
);
|
||||
},
|
||||
|
||||
actions: {
|
||||
turnon() {
|
||||
this._super();
|
||||
this.set("bannerDismissed", true);
|
||||
},
|
||||
dismiss() {
|
||||
this.set("bannerDismissed", true);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -5,8 +5,12 @@ export default Ember.Component.extend({
|
||||
type : "radio",
|
||||
attributeBindings : ["name", "type", "value", "checked:checked", "disabled:disabled"],
|
||||
|
||||
click: function() {
|
||||
this.set("selection", this.$().val());
|
||||
click() {
|
||||
const value = this.$().val();
|
||||
if (this.get("selection") === value) {
|
||||
this.set("selection", undefined);
|
||||
}
|
||||
this.set("selection", value);
|
||||
},
|
||||
|
||||
@computed('value', 'selection')
|
||||
|
||||
@ -1,22 +1,23 @@
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
|
||||
export default Ember.Component.extend({
|
||||
classNameBindings: [':tag-list', 'categoryClass'],
|
||||
|
||||
isPrivateMessage: false,
|
||||
sortedTags: Ember.computed.sort('tags', 'sortProperties'),
|
||||
|
||||
title: function() {
|
||||
if (this.get('titleKey')) { return I18n.t(this.get('titleKey')); }
|
||||
}.property('titleKey'),
|
||||
@computed("titleKey")
|
||||
title(titleKey) {
|
||||
return titleKey && I18n.t(titleKey);
|
||||
},
|
||||
|
||||
category: function() {
|
||||
if (this.get('categoryId')) {
|
||||
return Discourse.Category.findById(this.get('categoryId'));
|
||||
}
|
||||
}.property('categoryId'),
|
||||
@computed("categoryId")
|
||||
category(categoryId) {
|
||||
return categoryId && Discourse.Category.findById(categoryId);
|
||||
},
|
||||
|
||||
categoryClass: function() {
|
||||
if (this.get('category')) {
|
||||
return "tag-list-" + this.get('category.fullSlug');
|
||||
}
|
||||
}.property('category')
|
||||
@computed("category.fullSlug")
|
||||
categoryClass(slug) {
|
||||
return slug && `tag-list-${slug}`;
|
||||
}
|
||||
});
|
||||
|
||||
@ -157,21 +157,18 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, U
|
||||
},
|
||||
|
||||
createAccount() {
|
||||
const self = this,
|
||||
attrs = this.getProperties('accountName', 'accountEmail', 'accountPassword', 'accountUsername', 'accountPasswordConfirm', 'accountChallenge'),
|
||||
userFields = this.get('userFields');
|
||||
const attrs = this.getProperties('accountName', 'accountEmail', 'accountPassword', 'accountUsername', 'accountPasswordConfirm', 'accountChallenge');
|
||||
const userFields = this.get('userFields');
|
||||
|
||||
// Add the userfields to the data
|
||||
if (!Ember.isEmpty(userFields)) {
|
||||
attrs.userFields = {};
|
||||
userFields.forEach(function(f) {
|
||||
attrs.userFields[f.get('field.id')] = f.get('value');
|
||||
});
|
||||
userFields.forEach(f => attrs.userFields[f.get('field.id')] = f.get('value'));
|
||||
}
|
||||
|
||||
this.set('formSubmitted', true);
|
||||
return Discourse.User.createAccount(attrs).then(function(result) {
|
||||
self.set('isDeveloper', false);
|
||||
return Discourse.User.createAccount(attrs).then(result => {
|
||||
this.set('isDeveloper', false);
|
||||
if (result.success) {
|
||||
// Trigger the browser's password manager using the hidden static login form:
|
||||
const $hidden_login_form = $('#hidden-login-form');
|
||||
@ -180,24 +177,21 @@ export default Ember.Controller.extend(ModalFunctionality, PasswordValidation, U
|
||||
$hidden_login_form.find('input[name=redirect]').val(userPath('account-created'));
|
||||
$hidden_login_form.submit();
|
||||
} else {
|
||||
self.flash(result.message || I18n.t('create_account.failed'), 'error');
|
||||
this.flash(result.message || I18n.t('create_account.failed'), 'error');
|
||||
if (result.is_developer) {
|
||||
self.set('isDeveloper', true);
|
||||
this.set('isDeveloper', true);
|
||||
}
|
||||
if (result.errors && result.errors.email && result.errors.email.length > 0 && result.values) {
|
||||
self.get('rejectedEmails').pushObject(result.values.email);
|
||||
this.get('rejectedEmails').pushObject(result.values.email);
|
||||
}
|
||||
if (result.errors && result.errors.password && result.errors.password.length > 0) {
|
||||
self.get('rejectedPasswords').pushObject(attrs.accountPassword);
|
||||
this.get('rejectedPasswords').pushObject(attrs.accountPassword);
|
||||
}
|
||||
self.set('formSubmitted', false);
|
||||
this.set('formSubmitted', false);
|
||||
}
|
||||
if (result.active && !Discourse.SiteSettings.must_approve_users) {
|
||||
return window.location.reload();
|
||||
}
|
||||
}, function() {
|
||||
self.set('formSubmitted', false);
|
||||
return self.flash(I18n.t('create_account.failed'), 'error');
|
||||
}, () => {
|
||||
this.set('formSubmitted', false);
|
||||
return this.flash(I18n.t('create_account.failed'), 'error');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,12 +15,15 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
||||
|
||||
@computed("model.closed")
|
||||
publicTimerTypes(closed) {
|
||||
return [
|
||||
let types = [
|
||||
{ id: CLOSE_STATUS_TYPE, name: I18n.t(closed ? 'topic.temp_open.title' : 'topic.auto_close.title'), },
|
||||
{ id: OPEN_STATUS_TYPE, name: I18n.t(closed ? 'topic.auto_reopen.title' : 'topic.temp_close.title') },
|
||||
{ id: PUBLISH_TO_CATEGORY_STATUS_TYPE, name: I18n.t('topic.publish_to_category.title') },
|
||||
{ id: DELETE_STATUS_TYPE, name: I18n.t('topic.auto_delete.title') }
|
||||
];
|
||||
if (this.currentUser.get("staff")) {
|
||||
types.push({ id: DELETE_STATUS_TYPE, name: I18n.t('topic.auto_delete.title') });
|
||||
}
|
||||
return types;
|
||||
},
|
||||
|
||||
@computed()
|
||||
@ -32,20 +35,12 @@ export default Ember.Controller.extend(ModalFunctionality, {
|
||||
|
||||
@computed("isPublic", 'publicTimerTypes', 'privateTimerTypes')
|
||||
selections(isPublic, publicTimerTypes, privateTimerTypes) {
|
||||
if (isPublic === 'true') {
|
||||
return publicTimerTypes;
|
||||
} else {
|
||||
return privateTimerTypes;
|
||||
}
|
||||
return "true" === isPublic ? publicTimerTypes : privateTimerTypes;
|
||||
},
|
||||
|
||||
@computed('isPublic', 'model.topic_timer', 'model.private_topic_timer')
|
||||
topicTimer(isPublic, publicTopicTimer, privateTopicTimer) {
|
||||
if (isPublic === 'true') {
|
||||
return publicTopicTimer;
|
||||
} else {
|
||||
return privateTopicTimer;
|
||||
}
|
||||
return "true" === isPublic ? publicTopicTimer : privateTopicTimer;
|
||||
},
|
||||
|
||||
_setTimer(time, statusType) {
|
||||
|
||||
@ -14,6 +14,7 @@ export default Ember.Controller.extend({
|
||||
showActions: false,
|
||||
filter: null,
|
||||
filterInput: null,
|
||||
application: Ember.inject.controller(),
|
||||
|
||||
@observes("filterInput")
|
||||
_setFilter: debounce(function() {
|
||||
@ -27,7 +28,10 @@ export default Ember.Controller.extend({
|
||||
|
||||
if (model) {
|
||||
model.findMembers(this.get('memberParams'))
|
||||
.finally(() => this.set('loading', false));
|
||||
.finally(() => {
|
||||
this.set('application.showFooter', model.members.length >= model.user_count);
|
||||
this.set('loading', false);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@ -81,7 +85,10 @@ export default Ember.Controller.extend({
|
||||
|
||||
loadMore() {
|
||||
if (this.get("loading")) { return; }
|
||||
if (this.get("model.members.length") >= this.get("model.user_count")) { return; }
|
||||
if (this.get("model.members.length") >= this.get("model.user_count")) {
|
||||
this.set("application.showFooter", true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.set("loading", true);
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ export default Ember.Controller.extend({
|
||||
group: Ember.inject.controller(),
|
||||
loading: false,
|
||||
offset: 0,
|
||||
application: Ember.inject.controller(),
|
||||
|
||||
init() {
|
||||
this._super();
|
||||
@ -27,6 +28,11 @@ export default Ember.Controller.extend({
|
||||
});
|
||||
},
|
||||
|
||||
@observes("model.all_loaded")
|
||||
_showFooter() {
|
||||
this.set("application.showFooter", this.get("model.all_loaded"));
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.setProperties({
|
||||
offset: 0,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
|
||||
export default Ember.Controller.extend({
|
||||
sortProperties: ['count:desc', 'id'],
|
||||
sortProperties: ['totalCount:desc', 'id'],
|
||||
sortedByCount: true,
|
||||
sortedByName: false,
|
||||
|
||||
@ -21,7 +21,7 @@ export default Ember.Controller.extend({
|
||||
actions: {
|
||||
sortByCount() {
|
||||
this.setProperties({
|
||||
sortProperties: ['count:desc', 'id'],
|
||||
sortProperties: ['totalCount:desc', 'id'],
|
||||
sortedByCount: true,
|
||||
sortedByName: false
|
||||
});
|
||||
|
||||
@ -131,7 +131,6 @@ export default Ember.Controller.extend(BufferedContent, {
|
||||
return this.get('model.postStream').loadPost(postId).then(post => {
|
||||
const composer = this.get('composer');
|
||||
const viewOpen = composer.get('model.viewOpen');
|
||||
|
||||
const quotedText = Quote.build(post, buffer);
|
||||
|
||||
// If we can't create a post, delegate to reply as new topic
|
||||
|
||||
@ -2,11 +2,13 @@ import debounce from 'discourse/lib/debounce';
|
||||
|
||||
export default Ember.Controller.extend({
|
||||
application: Ember.inject.controller(),
|
||||
queryParams: ["period", "order", "asc", "name"],
|
||||
queryParams: ["period", "order", "asc", "name", "group", "exclude_usernames"],
|
||||
period: "weekly",
|
||||
order: "likes_received",
|
||||
asc: null,
|
||||
name: "",
|
||||
group: null,
|
||||
exclude_usernames: null,
|
||||
|
||||
showTimeRead: Ember.computed.equal("period", "all"),
|
||||
|
||||
|
||||
@ -8,6 +8,5 @@ export default htmlHelper((user, size) => {
|
||||
}
|
||||
|
||||
const avatarTemplate = Ember.get(user, 'avatar_template');
|
||||
return avatarImg({ size, avatarTemplate });
|
||||
return avatarImg(addExtraUserClasses(user, { size, avatarTemplate }));
|
||||
});
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import { htmlHelper } from 'discourse-common/lib/helpers';
|
||||
import { iconHTML } from 'discourse-common/lib/icon-library';
|
||||
|
||||
export default htmlHelper(function(str) {
|
||||
if (Ember.isEmpty(str)) { return ""; }
|
||||
return (str.indexOf('fa-') === 0) ? iconHTML(str.replace('fa-', '')) : `<img src='${str}'>`;
|
||||
export default htmlHelper(function({ icon, image }) {
|
||||
if (!Ember.isEmpty(image)) {
|
||||
return `<img src='${image}'>`;
|
||||
}
|
||||
|
||||
if (Ember.isEmpty(icon) || icon.indexOf('fa-') !== 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return iconHTML(icon.replace('fa-', ''));
|
||||
});
|
||||
|
||||
@ -12,21 +12,29 @@ export default htmlHelper((period, options) => {
|
||||
const title = I18n.t('filters.top.' + (TITLE_SUBS[period] || 'this_week'));
|
||||
if (options.hash.showDateRange) {
|
||||
var dateString = "";
|
||||
let finish;
|
||||
|
||||
if (options.hash.fullDay) {
|
||||
finish = moment().utc().subtract(1, 'days');
|
||||
} else {
|
||||
finish = moment();
|
||||
}
|
||||
|
||||
switch(period) {
|
||||
case 'yearly':
|
||||
dateString = moment().subtract(1, 'year').format(I18n.t('dates.long_with_year_no_time')) + " - " + moment().format(I18n.t('dates.long_with_year_no_time'));
|
||||
dateString = finish.clone().subtract(1, 'year').format(I18n.t('dates.long_with_year_no_time')) + " - " + finish.format(I18n.t('dates.long_with_year_no_time'));
|
||||
break;
|
||||
case 'quarterly':
|
||||
dateString = moment().subtract(3, 'month').format(I18n.t('dates.long_no_year_no_time')) + " - " + moment().format(I18n.t('dates.long_no_year_no_time'));
|
||||
dateString = finish.clone().subtract(3, 'month').format(I18n.t('dates.long_no_year_no_time')) + " - " + finish.format(I18n.t('dates.long_no_year_no_time'));
|
||||
break;
|
||||
case 'weekly':
|
||||
dateString = moment().subtract(1, 'week').format(I18n.t('dates.long_no_year_no_time')) + " - " + moment().format(I18n.t('dates.long_no_year_no_time'));
|
||||
dateString = finish.clone().subtract(1, 'week').format(I18n.t('dates.long_no_year_no_time')) + " - " + finish.format(I18n.t('dates.long_no_year_no_time'));
|
||||
break;
|
||||
case 'monthly':
|
||||
dateString = moment().subtract(1, 'month').format(I18n.t('dates.long_no_year_no_time')) + " - " + moment().format(I18n.t('dates.long_no_year_no_time'));
|
||||
dateString = finish.clone().subtract(1, 'month').format(I18n.t('dates.long_no_year_no_time')) + " - " + finish.format(I18n.t('dates.long_no_year_no_time'));
|
||||
break;
|
||||
case 'daily':
|
||||
dateString = moment().format(I18n.t('dates.full_no_year_no_time'));
|
||||
dateString = finish.clone().format(I18n.t('dates.full_no_year_no_time'));
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@ -2,8 +2,14 @@
|
||||
import {
|
||||
init as initDesktopNotifications,
|
||||
onNotification,
|
||||
alertChannel
|
||||
alertChannel,
|
||||
disable as disableDesktopNotifications,
|
||||
} from 'discourse/lib/desktop-notifications';
|
||||
import {
|
||||
register as registerPushNotifications,
|
||||
unsubscribe as unsubscribePushNotifications,
|
||||
isPushNotificationsEnabled
|
||||
} from 'discourse/lib/push-notifications';
|
||||
|
||||
export default {
|
||||
name: 'subscribe-user-notifications',
|
||||
@ -11,14 +17,9 @@ export default {
|
||||
|
||||
initialize(container) {
|
||||
const user = container.lookup('current-user:main');
|
||||
const keyValueStore = container.lookup('key-value-store:main');
|
||||
const bus = container.lookup('message-bus:main');
|
||||
const appEvents = container.lookup('app-events:main');
|
||||
|
||||
// clear old cached notifications, we used to store in local storage
|
||||
// TODO 2017 delete this line
|
||||
keyValueStore.remove('recent-notifications');
|
||||
|
||||
if (user) {
|
||||
if (user.get('staff')) {
|
||||
bus.subscribe('/flagged_counts', data => {
|
||||
@ -87,6 +88,7 @@ export default {
|
||||
|
||||
const site = container.lookup('site:main');
|
||||
const siteSettings = container.lookup('site-settings:main');
|
||||
const router = container.lookup('router:main');
|
||||
|
||||
bus.subscribe("/categories", data => {
|
||||
_.each(data.categories, c => site.updateCategory(c));
|
||||
@ -100,9 +102,16 @@ export default {
|
||||
});
|
||||
|
||||
if (!Ember.testing) {
|
||||
if (!site.mobileView) {
|
||||
bus.subscribe(alertChannel(user), data => onNotification(data, user));
|
||||
initDesktopNotifications(bus, appEvents);
|
||||
|
||||
bus.subscribe(alertChannel(user), data => onNotification(data, user));
|
||||
initDesktopNotifications(bus, appEvents);
|
||||
|
||||
if(isPushNotificationsEnabled(user, site.mobileView)) {
|
||||
disableDesktopNotifications();
|
||||
registerPushNotifications(Discourse.User.current(), site.mobileView, router, appEvents);
|
||||
}
|
||||
else {
|
||||
unsubscribePushNotifications(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
// The binarySearch() function is licensed under the UNLICENSE
|
||||
// https://github.com/Olical/binary-search
|
||||
|
||||
// Modified for use in Discourse
|
||||
|
||||
export default function binarySearch(list, target, keyProp) {
|
||||
var min = 0;
|
||||
var max = list.length - 1;
|
||||
var guess;
|
||||
var keyProperty = keyProp || "id";
|
||||
|
||||
while (min <= max) {
|
||||
guess = Math.floor((min + max) / 2);
|
||||
|
||||
if (Em.get(list[guess], keyProperty) === target) {
|
||||
return guess;
|
||||
}
|
||||
else {
|
||||
if (Em.get(list[guess], keyProperty) < target) {
|
||||
min = guess + 1;
|
||||
}
|
||||
else {
|
||||
max = guess - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -Math.floor((min + max) / 2);
|
||||
}
|
||||
@ -55,6 +55,22 @@ function init(messageBus, appEvents) {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmNotification() {
|
||||
const notification = new Notification(I18n.t("notifications.popup.confirm_title", {site_title: Discourse.SiteSettings.title}), {
|
||||
body: I18n.t("notifications.popup.confirm_body"),
|
||||
icon: Discourse.SiteSettings.logo_small_url || Discourse.SiteSettings.logo_url,
|
||||
tag: "confirm-subscription"
|
||||
});
|
||||
|
||||
const clickEventHandler = () => notification.close();
|
||||
|
||||
notification.addEventListener('click', clickEventHandler);
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
notification.removeEventListener('click', clickEventHandler);
|
||||
}, 10 * 1000);
|
||||
}
|
||||
|
||||
// This function is only called if permission was granted
|
||||
function setupNotifications(appEvents) {
|
||||
|
||||
@ -167,4 +183,8 @@ function unsubscribe(bus, user) {
|
||||
bus.unsubscribe(alertChannel(user));
|
||||
}
|
||||
|
||||
export { context, init, onNotification, unsubscribe, alertChannel };
|
||||
function disable() {
|
||||
keyValueStore.setItem('notifications-disabled', 'disabled');
|
||||
}
|
||||
|
||||
export { context, init, onNotification, unsubscribe, alertChannel, confirmNotification, disable };
|
||||
|
||||
@ -27,7 +27,7 @@ import { registerCustomAvatarHelper } from 'discourse/helpers/user-avatar';
|
||||
import { disableNameSuppression } from 'discourse/widgets/poster-name';
|
||||
|
||||
// If you add any methods to the API ensure you bump up this number
|
||||
const PLUGIN_API_VERSION = '0.8.20';
|
||||
const PLUGIN_API_VERSION = '0.8.21';
|
||||
|
||||
class PluginApi {
|
||||
constructor(version, container) {
|
||||
@ -44,20 +44,7 @@ class PluginApi {
|
||||
return this.container.lookup('current-user:main');
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows you to overwrite or extend methods in a class.
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* ```
|
||||
* api.modifyClass('controller:composer', {
|
||||
* actions: {
|
||||
* newActionHere() { }
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
**/
|
||||
modifyClass(resolverName, changes, opts) {
|
||||
_resolveClass(resolverName, opts) {
|
||||
opts = opts || {};
|
||||
|
||||
if (this.container.cache[resolverName]) {
|
||||
@ -72,7 +59,48 @@ class PluginApi {
|
||||
return;
|
||||
}
|
||||
|
||||
klass.class.reopen(changes);
|
||||
return klass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows you to overwrite or extend methods in a class.
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* ```
|
||||
* api.modifyClass('controller:composer', {
|
||||
* actions: {
|
||||
* newActionHere() { }
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
**/
|
||||
modifyClass(resolverName, changes, opts) {
|
||||
|
||||
const klass = this._resolveClass(resolverName, opts);
|
||||
if (klass) {
|
||||
klass.class.reopen(changes);
|
||||
}
|
||||
return klass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows you to overwrite or extend static methods in a class.
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* ```
|
||||
* api.modifyClassStatic('controller:composer', {
|
||||
* superFinder: function() { return []; }
|
||||
* });
|
||||
* ```
|
||||
**/
|
||||
modifyClassStatic(resolverName, changes, opts) {
|
||||
|
||||
const klass = this._resolveClass(resolverName, opts);
|
||||
if (klass) {
|
||||
klass.class.reopenClass(changes);
|
||||
}
|
||||
return klass;
|
||||
}
|
||||
|
||||
@ -723,7 +751,12 @@ export function withPluginApi(version, apiCodeCallback, opts) {
|
||||
let _decorateId = 0;
|
||||
function decorate(klass, evt, cb) {
|
||||
const mixin = {};
|
||||
mixin["_decorate_" + (_decorateId++)] = function($elem) { cb($elem); }.on(evt);
|
||||
mixin["_decorate_" + (_decorateId++)] = function($elem) {
|
||||
$elem = $elem || this.$();
|
||||
if ($elem) {
|
||||
cb($elem);
|
||||
}
|
||||
}.on(evt);
|
||||
klass.reopen(mixin);
|
||||
}
|
||||
|
||||
|
||||
119
app/assets/javascripts/discourse/lib/push-notifications.js.es6
Normal file
@ -0,0 +1,119 @@
|
||||
import { ajax } from 'discourse/lib/ajax';
|
||||
import KeyValueStore from 'discourse/lib/key-value-store';
|
||||
|
||||
export const keyValueStore = new KeyValueStore("discourse_push_notifications_");
|
||||
|
||||
export function userSubscriptionKey(user) {
|
||||
return `subscribed-${user.get('id')}`;
|
||||
}
|
||||
|
||||
function sendSubscriptionToServer(subscription, sendConfirmation) {
|
||||
ajax('/push_notifications/subscribe', {
|
||||
type: 'POST',
|
||||
data: { subscription: subscription.toJSON(), send_confirmation: sendConfirmation }
|
||||
});
|
||||
}
|
||||
|
||||
function userAgentVersionChecker(agent, version, mobileView) {
|
||||
const uaMatch = navigator.userAgent.match(new RegExp(`${agent}\/(\\d+)\\.\\d`));
|
||||
if (uaMatch && mobileView) return false;
|
||||
if (!uaMatch || parseInt(uaMatch[1]) < version) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetIdle() {
|
||||
if('controller' in navigator.serviceWorker && navigator.serviceWorker.controller != null) {
|
||||
navigator.serviceWorker.controller.postMessage({lastAction: Date.now()});
|
||||
}
|
||||
}
|
||||
|
||||
function setupActivityListeners(appEvents) {
|
||||
window.addEventListener("focus", resetIdle);
|
||||
|
||||
if (document) {
|
||||
document.addEventListener("scroll", resetIdle);
|
||||
}
|
||||
|
||||
appEvents.on('page:changed', resetIdle);
|
||||
}
|
||||
|
||||
export function isPushNotificationsSupported(mobileView) {
|
||||
if (!(('serviceWorker' in navigator) &&
|
||||
(ServiceWorkerRegistration &&
|
||||
(typeof(Notification) !== "undefined") &&
|
||||
('showNotification' in ServiceWorkerRegistration.prototype) &&
|
||||
('PushManager' in window)))) {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((!userAgentVersionChecker('Firefox', 44, mobileView)) &&
|
||||
(!userAgentVersionChecker('Chrome', 50))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isPushNotificationsEnabled(user, mobileView) {
|
||||
return user && isPushNotificationsSupported(mobileView) && keyValueStore.getItem(userSubscriptionKey(user));
|
||||
}
|
||||
|
||||
export function register(user, mobileView, router, appEvents) {
|
||||
if (!isPushNotificationsSupported(mobileView)) return;
|
||||
if (Notification.permission === 'denied' || !user) return;
|
||||
|
||||
navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
|
||||
serviceWorkerRegistration.pushManager.getSubscription().then(subscription => {
|
||||
if (subscription) {
|
||||
sendSubscriptionToServer(subscription, false);
|
||||
// Resync localStorage
|
||||
keyValueStore.setItem(userSubscriptionKey(user), 'subscribed');
|
||||
}
|
||||
setupActivityListeners(appEvents);
|
||||
}).catch(e => Ember.Logger.error(e));
|
||||
});
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
if ('url' in event.data) {
|
||||
const url = event.data.url;
|
||||
router.handleURL(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function subscribe(callback, applicationServerKey, mobileView) {
|
||||
if (!isPushNotificationsSupported(mobileView)) return;
|
||||
|
||||
navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
|
||||
serviceWorkerRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: new Uint8Array(applicationServerKey.split("|")) // eslint-disable-line no-undef
|
||||
}).then(subscription => {
|
||||
sendSubscriptionToServer(subscription, true);
|
||||
if (callback) callback();
|
||||
}).catch(e => Ember.Logger.error(e));
|
||||
});
|
||||
}
|
||||
|
||||
export function unsubscribe(user, callback, mobileView) {
|
||||
if (!isPushNotificationsSupported(mobileView)) return;
|
||||
|
||||
keyValueStore.setItem(userSubscriptionKey(user), '');
|
||||
navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
|
||||
serviceWorkerRegistration.pushManager.getSubscription().then(subscription => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe().then((successful) => {
|
||||
if (successful) {
|
||||
ajax('/push_notifications/unsubscribe', {
|
||||
type: 'POST',
|
||||
data: { subscription: subscription.toJSON() }
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(e => Ember.Logger.error(e));
|
||||
|
||||
if (callback) callback();
|
||||
});
|
||||
}
|
||||
@ -4,41 +4,32 @@ export default {
|
||||
|
||||
// Build the BBCode quote around the selected text
|
||||
build(post, contents, opts) {
|
||||
var contents_hashed, result, sansQuotes, stripped, stripped_hashed, tmp;
|
||||
var full = opts && opts["full"];
|
||||
var raw = opts && opts["raw"];
|
||||
|
||||
if (!post) { return ""; }
|
||||
|
||||
if (!contents) contents = "";
|
||||
|
||||
sansQuotes = contents.replace(this.REGEXP, '').trim();
|
||||
const sansQuotes = contents.replace(this.REGEXP, "").trim();
|
||||
if (sansQuotes.length === 0) { return ""; }
|
||||
|
||||
// Escape the content of the quote
|
||||
sansQuotes = sansQuotes.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
// Strip the HTML from cooked
|
||||
const stripped = $("<div/>").html(post.get("cooked")).text();
|
||||
|
||||
result = "[quote=\"" + post.get('username') + ", post:" + post.get('post_number') + ", topic:" + post.get('topic_id');
|
||||
// Let's remove any non-word characters as a kind of hash.
|
||||
// Yes it's not accurate but it should work almost every time we need it to.
|
||||
// It would be unlikely that the user would quote another post that matches in exactly this way.
|
||||
const sameContent = stripped.replace(/\W/g, "") === contents.replace(/\W/g, "");
|
||||
|
||||
/* Strip the HTML from cooked */
|
||||
tmp = document.createElement('div');
|
||||
tmp.innerHTML = post.get('cooked');
|
||||
stripped = tmp.textContent || tmp.innerText || "";
|
||||
const params = [
|
||||
post.get("username"),
|
||||
`post:${post.get("post_number")}`,
|
||||
`topic:${post.get("topic_id")}`,
|
||||
];
|
||||
|
||||
/*
|
||||
Let's remove any non alphanumeric characters as a kind of hash. Yes it's
|
||||
not accurate but it should work almost every time we need it to. It would be unlikely
|
||||
that the user would quote another post that matches in exactly this way.
|
||||
*/
|
||||
stripped_hashed = stripped.replace(/[^a-zA-Z0-9]/g, '');
|
||||
contents_hashed = contents.replace(/[^a-zA-Z0-9]/g, '');
|
||||
opts = opts || {};
|
||||
|
||||
/* If the quote is the full message, attribute it as such */
|
||||
if (full || stripped_hashed === contents_hashed) result += ", full:true";
|
||||
result += "\"]\n" + (raw ? contents : sansQuotes) + "\n[/quote]\n\n";
|
||||
if (opts["full"] || sameContent) params.push("full:true");
|
||||
|
||||
return result;
|
||||
return `[quote="${params.join(", ")}"]\n${opts["raw"] ? contents : sansQuotes}\n[/quote]\n\n`;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@ -126,6 +126,14 @@ class Tag {
|
||||
decorate(text) {
|
||||
const attr = this.element.attributes;
|
||||
|
||||
if (/^mention/.test(attr.class) && "@" === text[0]) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if ("hashtag" === attr.class && "#" === text[0]) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (attr.href && text !== attr.href) {
|
||||
text = text.replace(/\n{2,}/g, "\n");
|
||||
return "[" + text + "](" + attr.href + ")";
|
||||
@ -351,7 +359,7 @@ const tags = [
|
||||
...Tag.emphases().map((e) => Tag.emphasis(e[0], e[1])),
|
||||
Tag.cell("td"), Tag.cell("th"),
|
||||
Tag.replace("br", "\n"), Tag.replace("hr", "\n---\n"), Tag.replace("head", ""),
|
||||
Tag.keep("ins"), Tag.keep("del"), Tag.keep("small"), Tag.keep("big"),
|
||||
Tag.keep("ins"), Tag.keep("del"), Tag.keep("small"), Tag.keep("big"), Tag.keep("kbd"),
|
||||
Tag.li(), Tag.link(), Tag.image(), Tag.code(), Tag.blockquote(), Tag.table(), Tag.tr(), Tag.ol(), Tag.list("ul"),
|
||||
];
|
||||
|
||||
|
||||
73
app/assets/javascripts/discourse/lib/tooltip.js.es6
Normal file
@ -0,0 +1,73 @@
|
||||
export function showTooltip() {
|
||||
const fadeSpeed = 300;
|
||||
const tooltipID = "#discourse-tooltip";
|
||||
const $this = $(this);
|
||||
const $parent = $this.offsetParent();
|
||||
const content = $this.attr("data-tooltip");
|
||||
const retina = window.devicePixelRatio && window.devicePixelRatio > 1 ? "class='retina'" : "";
|
||||
|
||||
let pos = $this.offset();
|
||||
const delta = $parent.offset();
|
||||
pos.top -= delta.top;
|
||||
pos.left -= delta.left;
|
||||
|
||||
$(tooltipID).fadeOut(fadeSpeed).remove();
|
||||
|
||||
$(this).after(`
|
||||
<div id="discourse-tooltip" ${retina}>
|
||||
<div class="tooltip-pointer"></div>
|
||||
<div class="tooltip-content">${content}</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$(window).on("click.discourse", (event) => {
|
||||
if ($(event.target).closest(tooltipID).length === 0) {
|
||||
$(tooltipID).remove();
|
||||
$(window).off("click.discourse");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const $tooltip = $(tooltipID);
|
||||
$tooltip.css({top: 0, left: 0});
|
||||
|
||||
let left = (pos.left - ($tooltip.width() / 2) + $this.width()/2);
|
||||
if (left < 0) {
|
||||
$tooltip.find(".tooltip-pointer").css({
|
||||
"margin-left": left*2 + "px"
|
||||
});
|
||||
left = 0;
|
||||
}
|
||||
|
||||
// also do a right margin fix
|
||||
const parentWidth = $parent.width();
|
||||
if (left + $tooltip.width() > parentWidth) {
|
||||
let oldLeft = left;
|
||||
left = parentWidth - $tooltip.width();
|
||||
|
||||
$tooltip.find(".tooltip-pointer").css({
|
||||
"margin-left": (oldLeft - left) * 2 + "px"
|
||||
});
|
||||
}
|
||||
|
||||
$tooltip.css({
|
||||
top: pos.top + 5 + "px",
|
||||
left: left + "px"
|
||||
});
|
||||
|
||||
$tooltip.fadeIn(fadeSpeed);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerTooltip(jqueryContext) {
|
||||
if (jqueryContext.length) {
|
||||
jqueryContext.on('click', showTooltip);
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterTooltip(jqueryContext) {
|
||||
if (jqueryContext.length) {
|
||||
jqueryContext.off('click');
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { CANCELLED_STATUS } from 'discourse/lib/autocomplete';
|
||||
import { userPath } from 'discourse/lib/url';
|
||||
import { emailValid } from 'discourse/lib/utilities';
|
||||
|
||||
var cache = {},
|
||||
cacheTopicId,
|
||||
@ -61,7 +62,7 @@ function organizeResults(r, options) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!options.disallowEmails && options.term.match(/@/)) {
|
||||
if (!options.disallowEmails && emailValid(options.term)) {
|
||||
let e = { username: options.term };
|
||||
emails = [ e ];
|
||||
results.push(e);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { escape } from 'pretty-text/sanitizer';
|
||||
import toMarkdown from 'discourse/lib/to-markdown';
|
||||
|
||||
const homepageSelector = 'meta[name=discourse_current_homepage]';
|
||||
|
||||
@ -113,12 +114,8 @@ export function selectedText() {
|
||||
$div.find(".clicks").remove();
|
||||
// replace emojis
|
||||
$div.find("img.emoji").replaceWith(function() { return this.title; });
|
||||
// replace br with newlines
|
||||
$div.find("br").replaceWith(() => "\n");
|
||||
// enforce newline at the end of paragraphs
|
||||
$div.find("p").append(() => "\n");
|
||||
|
||||
return String($div.text()).trim().replace(/(^\s*\n)+/gm, "\n");
|
||||
return toMarkdown($div.html());
|
||||
}
|
||||
|
||||
// Determine the row and col of the caret in an element
|
||||
@ -497,7 +494,7 @@ export function fillMissingDates(data, startDate, endDate) {
|
||||
|
||||
for (let i = 0; i <= countDays; i++) {
|
||||
let date = (data[i]) ? moment(data[i].x, "YYYY-MM-DD") : null;
|
||||
if (i === 0 && date.isAfter(startMoment)) {
|
||||
if (i === 0 && (!date || date.isAfter(startMoment))) {
|
||||
data.splice(i, 0, { "x" : startMoment.format("YYYY-MM-DD"), 'y': 0 });
|
||||
} else {
|
||||
if (!date || date.isAfter(moment(currentMoment))) {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { wantsNewWindow } from 'discourse/lib/intercept-click';
|
||||
import afterTransition from 'discourse/lib/after-transition';
|
||||
import DiscourseURL from 'discourse/lib/url';
|
||||
import { userPath } from 'discourse/lib/url';
|
||||
|
||||
export default Ember.Mixin.create({
|
||||
elementId: null, //click detection added for data-{elementId}
|
||||
@ -28,7 +27,7 @@ export default Ember.Mixin.create({
|
||||
|
||||
// Don't show on mobile
|
||||
if (this.site.mobileView) {
|
||||
DiscourseURL.routeTo(userPath(username));
|
||||
DiscourseURL.routeTo($target.attr("href"));
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -38,10 +37,10 @@ export default Ember.Mixin.create({
|
||||
}
|
||||
|
||||
const postId = $target.parents('article').data('post-id');
|
||||
|
||||
const wasVisible = this.get('visible');
|
||||
const previousTarget = this.get('cardTarget');
|
||||
const target = $target[0];
|
||||
|
||||
if (wasVisible) {
|
||||
this._close();
|
||||
if (target === previousTarget) { return; }
|
||||
|
||||
@ -37,7 +37,6 @@ CategoryList.reopenClass({
|
||||
c.topics = c.topics.map(t => Discourse.Topic.create(t));
|
||||
}
|
||||
|
||||
|
||||
switch(statPeriod) {
|
||||
case "week":
|
||||
case "month":
|
||||
|
||||
@ -1,71 +1,62 @@
|
||||
import { ajax } from 'discourse/lib/ajax';
|
||||
import RestModel from 'discourse/models/rest';
|
||||
import computed from 'ember-addons/ember-computed-decorators';
|
||||
import PermissionType from 'discourse/models/permission-type';
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import RestModel from "discourse/models/rest";
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
import PermissionType from "discourse/models/permission-type";
|
||||
|
||||
const TagGroup = RestModel.extend({
|
||||
@computed('name', 'tag_names')
|
||||
disableSave() {
|
||||
return Ember.isEmpty(this.get('name')) || Ember.isEmpty(this.get('tag_names')) || this.get('saving');
|
||||
export default RestModel.extend({
|
||||
@computed("name", "tag_names", "saving")
|
||||
disableSave(name, tagNames, saving) {
|
||||
return saving || Ember.isEmpty(name) || Ember.isEmpty(tagNames);
|
||||
},
|
||||
|
||||
@computed('permissions')
|
||||
@computed("permissions")
|
||||
permissionName: {
|
||||
get(permissions) {
|
||||
if (!permissions) return 'public';
|
||||
if (!permissions) return "public";
|
||||
|
||||
if (permissions['everyone'] === PermissionType.FULL) {
|
||||
return 'public';
|
||||
} else if (permissions['everyone'] === PermissionType.READONLY) {
|
||||
return 'visible';
|
||||
if (permissions["everyone"] === PermissionType.FULL) {
|
||||
return "public";
|
||||
} else if (permissions["everyone"] === PermissionType.READONLY) {
|
||||
return "visible";
|
||||
} else {
|
||||
return 'private';
|
||||
return "private";
|
||||
}
|
||||
},
|
||||
|
||||
set(value) {
|
||||
if (value === 'private') {
|
||||
this.set('permissions', {'staff': PermissionType.FULL});
|
||||
} else if (value === 'visible') {
|
||||
this.set('permissions', {'staff': PermissionType.FULL, 'everyone': PermissionType.READONLY});
|
||||
if (value === "private") {
|
||||
this.set("permissions", { "staff": PermissionType.FULL });
|
||||
} else if (value === "visible") {
|
||||
this.set("permissions", { "staff": PermissionType.FULL, "everyone": PermissionType.READONLY });
|
||||
} else {
|
||||
this.set('permissions', {'everyone': PermissionType.FULL});
|
||||
this.set("permissions", { "everyone": PermissionType.FULL });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
save() {
|
||||
let url = "/tag_groups";
|
||||
const self = this,
|
||||
isNew = this.get('id') === 'new';
|
||||
if (!isNew) {
|
||||
url = "/tag_groups/" + this.get('id');
|
||||
}
|
||||
this.set("savingStatus", I18n.t("saving"));
|
||||
this.set("saving", true);
|
||||
|
||||
this.set('savingStatus', I18n.t('saving'));
|
||||
this.set('saving', true);
|
||||
const isNew = this.get("id") === "new";
|
||||
const url = isNew ? "/tag_groups" : `/tag_groups/${this.get("id")}`;
|
||||
const data = this.getProperties("name", "tag_names", "parent_tag_name", "one_per_topic", "permissions");
|
||||
|
||||
return ajax(url, {
|
||||
data: {
|
||||
name: this.get('name'),
|
||||
tag_names: this.get('tag_names'),
|
||||
parent_tag_name: this.get('parent_tag_name') ? this.get('parent_tag_name') : undefined,
|
||||
one_per_topic: this.get('one_per_topic'),
|
||||
permissions: this.get('permissions')
|
||||
},
|
||||
type: isNew ? 'POST' : 'PUT'
|
||||
}).then(function(result) {
|
||||
if(result.tag_group && result.tag_group.id) {
|
||||
self.set('id', result.tag_group.id);
|
||||
data,
|
||||
type: isNew ? "POST" : "PUT"
|
||||
}).then(result => {
|
||||
if (result.tag_group && result.tag_group.id) {
|
||||
this.set("id", result.tag_group.id);
|
||||
}
|
||||
self.set('savingStatus', I18n.t('saved'));
|
||||
self.set('saving', false);
|
||||
}).finally(() => {
|
||||
this.set("savingStatus", I18n.t("saved"));
|
||||
this.set("saving", false);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
return ajax("/tag_groups/" + this.get('id'), {type: "DELETE"});
|
||||
return ajax(`/tag_groups/${this.get("id")}`, { type: "DELETE" });
|
||||
}
|
||||
});
|
||||
|
||||
export default TagGroup;
|
||||
|
||||
9
app/assets/javascripts/discourse/models/tag.js.es6
Normal file
@ -0,0 +1,9 @@
|
||||
import RestModel from "discourse/models/rest";
|
||||
import computed from "ember-addons/ember-computed-decorators";
|
||||
|
||||
export default RestModel.extend({
|
||||
@computed("count", "pm_count")
|
||||
totalCount(count, pmCount) {
|
||||
return count + pmCount;
|
||||
}
|
||||
});
|
||||
@ -1,4 +1,6 @@
|
||||
export default Discourse.Route.extend({
|
||||
showFooter: true,
|
||||
|
||||
beforeModel() {
|
||||
this.transitionTo("group.manage.profile");
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export default Discourse.Route.extend({
|
||||
showFooter: true,
|
||||
|
||||
titleToken() {
|
||||
return I18n.t('groups.manage.interaction.title');
|
||||
},
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export default Discourse.Route.extend({
|
||||
showFooter: true,
|
||||
|
||||
titleToken() {
|
||||
return I18n.t('groups.manage.membership.title');
|
||||
},
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export default Discourse.Route.extend({
|
||||
showFooter: true,
|
||||
|
||||
titleToken() {
|
||||
return I18n.t('groups.manage.profile.title');
|
||||
},
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export default Discourse.Route.extend({
|
||||
showFooter: true,
|
||||
|
||||
titleToken() {
|
||||
return I18n.t('groups.manage.title');
|
||||
},
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import Group from 'discourse/models/group';
|
||||
|
||||
export default Discourse.Route.extend({
|
||||
showFooter: true,
|
||||
|
||||
titleToken() {
|
||||
return I18n.t('admin.groups.new.title');
|
||||
},
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export default Discourse.Route.extend({
|
||||
showFooter: true,
|
||||
|
||||
model(params) {
|
||||
return this.store.find('tagGroup', params.id);
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export default Discourse.Route.extend({
|
||||
showFooter: true,
|
||||
|
||||
model() {
|
||||
return this.store.findAll('tagGroup');
|
||||
},
|
||||
|
||||
@ -1,6 +1,22 @@
|
||||
import Tag from "discourse/models/tag";
|
||||
|
||||
export default Discourse.Route.extend({
|
||||
model() {
|
||||
return this.store.findAll('tag');
|
||||
return this.store.findAll("tag").then(result => {
|
||||
if (result.extras) {
|
||||
if (result.extras.categories) {
|
||||
result.extras.categories.forEach(category => {
|
||||
category.tags = category.tags.map(t => Tag.create(t));
|
||||
});
|
||||
}
|
||||
if (result.extras.tag_groups) {
|
||||
result.extras.tag_groups.forEach(tagGroup => {
|
||||
tagGroup.tags = tagGroup.tags.map(t => Tag.create(t));
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
},
|
||||
|
||||
titleToken() {
|
||||
@ -10,7 +26,7 @@ export default Discourse.Route.extend({
|
||||
setupController(controller, model) {
|
||||
this.controllerFor('tags.index').setProperties({
|
||||
model,
|
||||
sortProperties: this.siteSettings.tags_sort_alphabetically ? ['id'] : ['count:desc', 'id']
|
||||
sortProperties: this.siteSettings.tags_sort_alphabetically ? ['id'] : ['totalCount:desc', 'id']
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@ -3,7 +3,9 @@ export default Discourse.Route.extend({
|
||||
period: { refreshModel: true },
|
||||
order: { refreshModel: true },
|
||||
asc: { refreshModel: true },
|
||||
name: { refreshModel: true, replace: true }
|
||||
name: { refreshModel: true, replace: true },
|
||||
group: { refreshModel: true },
|
||||
exclude_usernames: { refreshModel: true }
|
||||
},
|
||||
|
||||
refreshQueryWithoutTransition: true,
|
||||
@ -18,7 +20,9 @@ export default Discourse.Route.extend({
|
||||
period: "weekly",
|
||||
order: "likes_received",
|
||||
asc: null,
|
||||
name: ""
|
||||
name: "",
|
||||
group: null,
|
||||
exclude_usernames: null
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
{{#if showTop}}
|
||||
{{custom-html name="top"}}
|
||||
{{/if}}
|
||||
{{notification-consent-banner}}
|
||||
{{global-notice}}
|
||||
{{create-topics-notice}}
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
{{icon-or-image badge.icon}}
|
||||
{{icon-or-image badge}}
|
||||
<span class="badge-display-name">{{badge.name}}</span>
|
||||
{{yield}}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<div class='badge-contents'>
|
||||
<a href={{url}} class="badge-link">
|
||||
<div class='badge-icon {{badge.badgeTypeClassName}}'>
|
||||
{{icon-or-image badge.icon}}
|
||||
{{icon-or-image badge}}
|
||||
</div>
|
||||
<div class='badge-info'>
|
||||
<div class='badge-info-item'>
|
||||
|
||||
@ -1,24 +1,22 @@
|
||||
{{#each categories as |c|}}
|
||||
<div class='category-box category-box-{{unbound c.slug}}' style={{border-color c.color}}>
|
||||
<div class='category-box category-box-{{unbound c.slug}}' style={{border-color c.color}} data-url={{c.url}}>
|
||||
<div class='category-box-inner'>
|
||||
<a href={{c.url}}>
|
||||
<div class='category-box-heading'>
|
||||
{{#if c.uploaded_logo.url}}
|
||||
{{cdn-img src=c.uploaded_logo.url class="logo"}}
|
||||
<div class='category-box-heading'>
|
||||
{{#if c.uploaded_logo.url}}
|
||||
{{cdn-img src=c.uploaded_logo.url class="logo"}}
|
||||
{{/if}}
|
||||
|
||||
<h3>
|
||||
{{#if c.read_restricted}}
|
||||
{{d-icon 'lock'}}
|
||||
{{/if}}
|
||||
{{c.name}}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<h3>
|
||||
{{#if c.read_restricted}}
|
||||
{{d-icon 'lock'}}
|
||||
{{/if}}
|
||||
{{c.name}}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class='description'>
|
||||
{{{text-overflow class="overflow" text=c.description_excerpt}}}
|
||||
</div>
|
||||
</a>
|
||||
<div class='description'>
|
||||
{{{text-overflow class="overflow" text=c.description_excerpt}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{{#if isLoading}}
|
||||
<span class="title">{{computedTitle}}</span>
|
||||
<span class="title">{{title}}</span>
|
||||
<div class="spinner {{size}}"></div>
|
||||
{{else}}
|
||||
{{yield}}
|
||||
|
||||
@ -1,15 +1,10 @@
|
||||
|
||||
{{#if isNotSupported}}
|
||||
{{d-button icon="bell-slash" label="user.desktop_notifications.not_supported" disabled="true"}}
|
||||
{{/if}}
|
||||
{{#if isDefaultPermission}}
|
||||
{{d-button icon="bell-slash" label="user.desktop_notifications.perm_default" action="requestPermission"}}
|
||||
{{/if}}
|
||||
{{#if isDeniedPermission}}
|
||||
{{d-button icon="bell-slash" label="user.desktop_notifications.perm_denied_btn" action="recheckPermission" disabled='true'}}
|
||||
{{i18n "user.desktop_notifications.perm_denied_expl"}}
|
||||
{{/if}}
|
||||
{{#if isGrantedPermission}}
|
||||
{{else}}
|
||||
{{#if isEnabled}}
|
||||
{{d-button icon="bell-slash-o" label="user.desktop_notifications.disable" action="turnoff"}}
|
||||
{{i18n "user.desktop_notifications.currently_enabled"}}
|
||||
|
||||
@ -37,9 +37,26 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if group.bio_cooked}}<div class='bio'>{{{group.bio_cooked}}}</div>{{/if}}
|
||||
|
||||
<div class="metadata">
|
||||
<h3><a href={{groupPath}} {{action "showGroup"}}>{{ group.user_count }} {{i18n 'groups.user_count'}}</a></h3>
|
||||
<h3>
|
||||
<span class="desc">
|
||||
{{i18n 'groups.user_count'}}
|
||||
</span>
|
||||
{{ group.user_count }}
|
||||
</h3>
|
||||
<h3>
|
||||
{{#if group.is_group_owner}}
|
||||
|
||||
{{i18n "groups.index.is_group_owner"}}
|
||||
{{else if group.is_group_user}}
|
||||
|
||||
{{i18n "groups.index.is_group_user"}}
|
||||
{{/if}}
|
||||
</h3>
|
||||
</div>
|
||||
{{#if group.members}}
|
||||
<div class="members metadata">
|
||||
<span>
|
||||
{{#each group.members as |user|}}
|
||||
@ -50,5 +67,6 @@
|
||||
{{/if}}
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
{{#if showNotificationPromptBanner}}
|
||||
<div class="row">
|
||||
<div class="consent_banner alert alert-info">
|
||||
<div class="close" {{action "dismiss"}}><i class="fa fa-times d-icon d-icon-times"></i></div>
|
||||
{{i18n 'user.desktop_notifications.consent_prompt'}} <a {{action "turnon"}}>{{i18n 'user.desktop_notifications.enable'}}</a>.
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
@ -9,11 +9,7 @@
|
||||
{{/if}}
|
||||
{{#each sortedTags as |tag|}}
|
||||
<div class='tag-box'>
|
||||
{{#if tag.count}}
|
||||
{{discourse-tag tag.id isPrivateMessage=isPrivateMessage tagsForUser=tagsForUser}} <span class='tag-count'>x {{tag.count}}</span>
|
||||
{{else}}
|
||||
{{discourse-tag tag.id}}
|
||||
{{/if}}
|
||||
{{discourse-tag tag.id isPrivateMessage=isPrivateMessage tagsForUser=tagsForUser}} {{#if tag.pm_count}}{{d-icon "envelope"}}{{/if}}{{#if tag.totalCount}} <span class='tag-count'>x {{tag.totalCount}}</span>{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
<div class="clearfix" />
|
||||
|
||||
@ -156,6 +156,7 @@
|
||||
{{/if}}
|
||||
|
||||
{{#if showBadges}}
|
||||
{{#if user.featured_user_badges}}
|
||||
<div class="badge-section">
|
||||
{{#each user.featured_user_badges as |ub|}}
|
||||
{{user-badge badge=ub.badge user=user}}
|
||||
@ -166,6 +167,7 @@
|
||||
{{/link-to}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
@ -76,12 +76,12 @@
|
||||
|
||||
{{#footer-message education=footerEducation message=footerMessage}}
|
||||
{{#if latest}}
|
||||
{{#if canCreateTopicOnCategory}}<a href {{action "createTopic"}}>{{i18n 'topic.suggest_create_topic'}}</a>{{/if}}
|
||||
{{#if canCreateTopicOnCategory}}<a href {{action "createTopic"}}>{{i18n 'topic.suggest_create_topic'}}</a>.{{/if}}
|
||||
{{else if top}}
|
||||
{{#link-to "discovery.categories"}}{{i18n 'topic.browse_all_categories'}}{{/link-to}}, {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} {{i18n 'or'}} {{i18n 'filters.top.other_periods'}}
|
||||
{{#link-to "discovery.categories"}}{{i18n 'topic.browse_all_categories'}}{{/link-to}}, {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}} {{i18n 'or'}} {{i18n 'filters.top.other_periods'}}.
|
||||
{{top-period-buttons period=period action="changePeriod"}}
|
||||
{{else}}
|
||||
{{#link-to "discovery.categories"}} {{i18n 'topic.browse_all_categories'}}{{/link-to}} {{i18n 'or'}} {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}}
|
||||
{{#link-to "discovery.categories"}} {{i18n 'topic.browse_all_categories'}}{{/link-to}} {{i18n 'or'}} {{#link-to 'discovery.latest'}}{{i18n 'topic.view_latest_topics'}}{{/link-to}}.
|
||||
{{/if}}
|
||||
{{/footer-message}}
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
{{#load-more selector=".group-post" action=(action "loadMore")}}
|
||||
<div class="user-stream">
|
||||
{{#each model as |post|}}
|
||||
{{group-post post=post class="user-stream-item item"}}
|
||||
{{else}}
|
||||
<div>{{i18n emptyText}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{#load-more selector=".user-stream-item" action="loadMore"}}
|
||||
<div class="user-stream">
|
||||
{{#each model as |post|}}
|
||||
{{group-post post=post class="user-stream-item item"}}
|
||||
{{else}}
|
||||
<div>{{i18n emptyText}}</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{conditional-loading-spinner condition=loading}}
|
||||
{{/load-more}}
|
||||
|
||||
{{conditional-loading-spinner condition=loading}}
|
||||
|
||||