Merge branch 'master' into beta

This commit is contained in:
Sam 2018-05-17 12:09:55 +10:00
commit 8ef654d71f
406 changed files with 6706 additions and 2518 deletions

1
.gitignore vendored
View File

@ -78,6 +78,7 @@ discourse.sublime-workspace
*~
*.swp
*.swo
*.swm
# don't check in multisite config
config/multisite.yml

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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']

View File

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

View File

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

View File

@ -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"]
});

View File

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

View File

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

View File

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

View File

@ -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 : [];
}
});

View File

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

View File

@ -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')

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

@ -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({
}
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">&nbsp;</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}}

View File

@ -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">&nbsp;</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>

View File

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

View File

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

View File

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

View File

@ -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/>

View File

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

View File

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

View File

@ -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>&nbsp;</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">&mdash;</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>&mdash;</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>

View File

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

View File

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

View File

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

View File

@ -36,6 +36,9 @@ export default Ember.Component.extend({
@on("willDestroyElement")
_destroy() {
if (this._picker) {
this._picker.destroy();
}
this._picker = null;
},

View File

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

View File

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

View File

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

View File

@ -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')

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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-', ''));
});

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
});
}

View File

@ -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, "&lt;")
.replace(/>/g, "&gt;");
// 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`;
}
};

View File

@ -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"),
];

View 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');
}
}

View File

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

View File

@ -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))) {

View File

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

View File

@ -37,7 +37,6 @@ CategoryList.reopenClass({
c.topics = c.topics.map(t => Discourse.Topic.create(t));
}
switch(statPeriod) {
case "week":
case "month":

View File

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

View 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;
}
});

View File

@ -1,4 +1,6 @@
export default Discourse.Route.extend({
showFooter: true,
beforeModel() {
this.transitionTo("group.manage.profile");
}

View File

@ -1,4 +1,6 @@
export default Discourse.Route.extend({
showFooter: true,
titleToken() {
return I18n.t('groups.manage.interaction.title');
},

View File

@ -1,4 +1,6 @@
export default Discourse.Route.extend({
showFooter: true,
titleToken() {
return I18n.t('groups.manage.membership.title');
},

View File

@ -1,4 +1,6 @@
export default Discourse.Route.extend({
showFooter: true,
titleToken() {
return I18n.t('groups.manage.profile.title');
},

View File

@ -1,4 +1,6 @@
export default Discourse.Route.extend({
showFooter: true,
titleToken() {
return I18n.t('groups.manage.title');
},

View File

@ -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');
},

View File

@ -1,4 +1,6 @@
export default Discourse.Route.extend({
showFooter: true,
model(params) {
return this.store.find('tagGroup', params.id);
}

View File

@ -1,4 +1,6 @@
export default Discourse.Route.extend({
showFooter: true,
model() {
return this.store.findAll('tagGroup');
},

View File

@ -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']
});
},

View File

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

View File

@ -14,6 +14,7 @@
{{#if showTop}}
{{custom-html name="top"}}
{{/if}}
{{notification-consent-banner}}
{{global-notice}}
{{create-topics-notice}}
</div>

View File

@ -1,3 +1,3 @@
{{icon-or-image badge.icon}}
{{icon-or-image badge}}
<span class="badge-display-name">{{badge.name}}</span>
{{yield}}

View File

@ -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'>

View File

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

View File

@ -1,5 +1,5 @@
{{#if isLoading}}
<span class="title">{{computedTitle}}</span>
<span class="title">{{title}}</span>
<div class="spinner {{size}}"></div>
{{else}}
{{yield}}

View File

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

View File

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

View File

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

View File

@ -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" />

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More